diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index caa9366bdca..95f2d499ef3 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -821,6 +821,51 @@ jobs: - name: Custom agent platform suites (resolver, tokenizer, startup, runtime) run: pnpm test:custom-agent-platform + # Why required rather than folded into the advisory `e2e` job: this is the only + # lane that observes a real remote custom-agent launch (argv+env read from + # /proc inside a throwaway Docker SSH container). The path-filtered `e2e` job + # skips whenever a PR changes launch code without touching tests/e2e/**, and + # the release workflow only runs full E2E after publication — so remote launch + # would otherwise reach users unverified. Ubuntu-only: macOS/Windows runners + # cannot host a Linux Docker container. + ssh_custom_agent_e2e: + name: e2e ssh custom agent + runs-on: ubuntu-latest + timeout-minutes: 40 + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + persist-credentials: false + + # Why explicit: headless Electron still needs an X display, and the e2e + # workflow's equivalent lane installs it rather than trusting the image. + - name: Install xvfb + run: sudo apt-get update && sudo apt-get install -y xvfb + + - uses: ./.github/actions/install-node-dependencies + with: + native-runtime: electron + + # Why no SKIP_BUILD: this lane owns its build (no shared e2e artifact in + # PR checks), and globalSetup adds the SSH relay bundle because the runner + # script exports ORCA_E2E_SSH_DOCKER=1. Docker is preinstalled on + # ubuntu-latest. + - name: Run SSH custom-agent e2e + env: + ORCA_E2E_FORWARD_APP_LOGS: '1' + run: xvfb-run --auto-servernum pnpm run test:e2e:ssh-custom-agent + + - name: Upload Playwright traces + if: failure() + uses: actions/upload-artifact@v7 + with: + name: playwright-traces-pr-ssh-custom-agent + path: test-results/ + retention-days: 7 + if-no-files-found: ignore + verify: if: always() needs: @@ -837,17 +882,20 @@ jobs: - package - package_windows - custom_agent_platform + - ssh_custom_agent_e2e runs-on: ubuntu-latest steps: - # Why: e2e is deliberately absent from needs. The suite is currently red on - # main (every scheduled run), so gating merges on it would block any PR that - # touches tests/e2e/** — including the ones fixing the suite. Until it is - # green the job runs and reports for E2E-path PRs without blocking. To flip - # it on: add `e2e` to needs, add E2E to the env below, and require - # `"$E2E" = success || skipped` after the loop — skipped is the normal - # result for a path-filtered job and must keep passing, so it has to be - # checked outside the loop or it would excuse the jobs above. + # Why: the path-filtered e2e job is deliberately absent from needs. That + # suite is currently red on main (every scheduled run), so gating merges on + # it would block any PR that touches tests/e2e/** — including the ones + # fixing the suite. Until it is green the job runs and reports for E2E-path + # PRs without blocking. To flip it on: add `e2e` to needs, add E2E to the + # env below, and require `"$E2E" = success || skipped` after the loop — + # skipped is the normal result for a path-filtered job and must keep + # passing, so it has to be checked outside the loop or it would excuse the + # jobs above. ssh_custom_agent_e2e is a different case: it is unfiltered, + # so `skipped` is never a legitimate result and it belongs in the loop. - name: Require successful checks env: CODE_PATHS: ${{ needs.code_paths.result }} @@ -864,6 +912,7 @@ jobs: PACKAGE: ${{ needs.package.result }} PACKAGE_WINDOWS: ${{ needs.package_windows.result }} CUSTOM_AGENT_PLATFORM: ${{ needs.custom_agent_platform.result }} + SSH_CUSTOM_AGENT_E2E: ${{ needs.ssh_custom_agent_e2e.result }} run: | if [ "$CODE_PATHS" != "success" ]; then exit 1 @@ -904,7 +953,8 @@ jobs: "$MANAGED_HOOK_NODE18" \ "$PACKAGE" \ "$PACKAGE_WINDOWS" \ - "$CUSTOM_AGENT_PLATFORM"; do + "$CUSTOM_AGENT_PLATFORM" \ + "$SSH_CUSTOM_AGENT_E2E"; do if [ "$result" != "success" ]; then exit 1 fi diff --git a/bugs.md b/bugs.md new file mode 100644 index 00000000000..c89ffe7b55a --- /dev/null +++ b/bugs.md @@ -0,0 +1,385 @@ +The zero-regression bar is not met. Review of af702b1c54 against + + merge-base 9772da844a found 4 P0 release blockers, 27 P1 regressions, + + and 9 P2 issues. The branch also fails typecheck, desktop build, lint, + + and its new SSH E2E coverage. + + ## P0 — Release blockers + + 1. Mobile can execute AI text as shell commands against older hosts. + + Identity-only launch is sent unconditionally at mobile/src/session/ + + identity-create-terminal-params.ts:15. Old hosts strip it and + + create a bare shell; PR triage and review then submit provider/ + + user-controlled text with Enter at mobile/src/session/pr-ai-triage- + + launch.ts:42 and mobile/src/session/use-mobile-diff-review-send- + + actions.ts:109. Filenames, titles, comments, or check output + + containing shell metacharacters can execute locally or over SSH. + + 2. Catalog/reference authoring acknowledges changes before they are + + durable. Mutations return success immediately at src/main/agent- + + launch/agent-catalog-service.ts:247, while persistence waits 1–5 + + seconds and merely logs failures at src/main/persistence.ts:3946. + + Crash, power loss, or disk failure can lose acknowledged agents/ + + references or resurrect deletions. + + 3. Migration accepts an unreadable pre-v1 backup as valid. Read + + failure returns “usable” at src/main/agent-launch/agent-catalog- + + pre-v1-backup.ts:16. Reproduced with a directory at the backup + + path: migration returned success without a readable rollback point. + + 4. New “durable” launch stores silently swallow every write failure + + while callers report success: + + - src/main/agent-launch/agent-launch-operation-store- + + persistence.ts:269 + + - src/main/agent-launch/agent-session-record-store- + + persistence.ts:171 + + - src/main/agent-launch/background-agent-launch-store- + + persistence.ts:85 + + These stores also use the non-fsync writer. Restart can lose + + idempotency, recovery, capacity, session-resume, or background- + + launch state. + + ## P1 — Functionality, reliability, security, and performance + + 1. Paired web never consumes the custom-agent catalog. The host + + publishes it at src/main/runtime/rpc/methods/client-ui.ts:38, but + + paired web discards it at src/renderer/src/web/web-preload- + + api.ts:3926, rejects catalog access at src/renderer/src/web/web- + + preload-api.ts:756, and no-ops revision events at src/renderer/src/ + + hooks/useIpcEvents.ts:1007. Custom agents cannot be selected or + + refreshed; a custom default becomes Blank. + + 2. Paired-web identity launches are not capability-gated. src/ + + renderer/src/runtime/web-runtime-session.ts:261 sends agentLaunch + + to old hosts, which create a bare shell and report success. The + + explicit gate already exists in src/renderer/src/runtime/agent- + + launch-identity-negotiation.ts:1. + + 3. Folder-workspace quick-create has the same older-host regression: + + it sends an empty command plus only agentLaunch at src/renderer/ + + src/components/sidebar/folder-workspace-composer-submit.ts:53. Old + + hosts open a blank shell and drop the linked draft/note. + + 4. launchToken changed the daemon and SSH relay wire without version/ + + capability negotiation. The daemon remains v33 at src/main/daemon/ + + daemon-protocol-version.ts:1, while new main sends the field at + + src/main/daemon/daemon-pty-adapter.ts:512. Old surviving daemons/ + + relays ignore it. After a main crash, the live agent appears absent + + and Retry can create a duplicate. + + 5. A successful empty scoped SSH relist is never authoritative because + + connection IDs are recorded only while iterating returned sessions + + at src/main/runtime/orca-runtime.ts:32363. Pending launches remain + + unknown, retain capacity, and cannot retry. + + 6. Catalog repair deadlocks with more than 256 corrupt rows. Snapshot + + generation requests tokens for every row, but src/main/agent- + + launch/agent-catalog-repair-mutations.ts:35 evicts them at 256. + + Reproduced with 257 rows: every repair token becomes stale. + + 7. Reference payload budgeting measures the pre-persistence + + representation at src/main/agent-launch/agent-catalog- + + service.ts:229, not the expanded projection. A reproduced 180 KB + + input was accepted and persisted as a 541 KB payload over the 512 + + KiB limit. + + 8. Quick-command RPC updates bypass reference authority at src/main/ + + runtime/orca-runtime.ts:4016: no live/enabled validation, reference + + revision, tombstone handling, or change event. + + 9. Tombstone pruning is quadratic. src/main/agent-launch/agent- + + catalog-tombstone-gc.ts:23 rescans every reference owner for every + + tombstone. A minimal 3,500-row benchmark took about 591 ms before + + real scanner overhead. + + 10. Mobile replay authorization changes are omitted from the admission + + fingerprint. Replay environment can change after an env rotation + + while the fingerprint remains identical: src/main/agent-launch/ + + resolve-agent-launch-result.ts:107. + + 11. WSL descriptors can be classified as native/local, leaving Windows + + or UNC paths inside Linux argv: src/main/agent-launch/agent-launch- + + host-state.ts:124, src/main/runtime/orca-runtime.ts:25930. + + 12. The command-length cap runs before the final prompt is appended. + + RPC permits 100,000 characters at src/main/runtime/rpc/methods/ + + agent-launch-spawn-schema.ts:44, the cap checks earlier at src/ + + main/agent-launch/resolve-agent-launch.ts:296, and prompt suffixes + + are added later at src/shared/resolved-agent-startup-plan.ts:181. + + Windows can receive an oversized command instead of a typed + + failure. + + 13. Stage-two host-state rejection can leak reservations because the + + read occurs before guarded transaction cleanup: src/main/agent- + + launch/agent-launch-worktree-resolution.ts:141, src/main/agent- + + launch/agent-launch-worktree-transaction.ts:115. Repeated SSH + + disconnect races can exhaust capacity until restart. + + 14. Admission principals identify only mobile or runtime, not devices, + + by design at src/main/agent-launch/agent-launch-admission- + + store.ts:25. Multiple phones share limits and recovery rows; + + worktree/background Forget does not verify the stored principal, + + allowing one paired device to forget another’s launch. + + 15. orchestration.dispatchForget claims owner authorization but + + performs no Run/caller ownership check at src/main/runtime/rpc/ + + methods/orchestration.ts:1465. Stronger mutations use src/main/ + + runtime/rpc/methods/orchestration-run-scope.ts:37. + + 16. Environment-size validation checks only resolved custom env, not + + the actual inherited/default spawn environment. Native Windows can + + exceed CreateProcess’s environment-block limit and fail opaquely. + + 17. Settled operation history is globally unbounded. Only each + + individual scope is capped at src/main/agent-launch/agent-launch- + + operation-store.ts:237; unique scopes live forever and every write + + serializes all scopes. At 5,000 scopes: 5,000 retained, + + approximately 2.8 GB cumulative serialization, 8.2 seconds before + + encryption/disk. + + 18. Session records are permanently unbounded and synchronously + + rewrite encrypted full history at src/main/agent-launch/agent- + + session-record-store.ts:101. Valid-cap benchmark: 1,000 records, + + 16.8 MB final payload, 8.4 GB cumulative writes, 11 seconds before + + real encryption/disk. + + 19. Unbound launch staging also leaks. Runtime/mobile registrations + + provide terminalId but no paneKey, while the sole cleanup only + + removes by paneKey: src/main/agent-launch/agent-session-launch- + + registration.ts:32, src/main/agent-launch/agent-session-record- + + store.ts:170. + + 20. Background launch attempts are globally unbounded at src/main/ + + agent-launch/background-agent-launch-store.ts:43, with full-ledger + + writes on creation and settlement. At 5,000 attempts: 4.7 GB + + cumulative serialization and 8.1 seconds before disk. + + 21. Production launch rebuilds the full normalized catalog 2–3 times + + per launch at src/main/agent-launch/agent-launch-spawn.ts:155 and + + src/main/agent-launch/agent-launch-boundary.ts:343. At a valid + + 2,500-agent catalog, the three-pass p95 was about 32 ms of main- + + thread CPU before preflight/spawn. The registered performance test + + injects a prebuilt catalog and misses this production work. + + 22. useLocalAgentCatalog creates a separate full IPC fetch, cache, and + + settings listener for every split group and closed quick-command + + dialog: src/renderer/src/hooks/useLocalAgentCatalog.ts:28, src/ + + renderer/src/components/tab-bar/use-tab-bar-runtime-model.ts:151. + + Sixteen 2,500-agent builds cost about 161 ms and clone roughly 9.7 + + MB. + + 23. Direct work-item launch rejects custom overrides/defaults because + + it validates them against built-in-only detection at src/renderer/ + + src/lib/launch-work-item-direct.ts:142. + + 24. Source-control “Don’t save” launches stale persisted CLI args. + + Current edited agentArgs exist at src/renderer/src/components/ + + right-sidebar/runSourceControlAgentActionStart.ts:123, but the + + launch sends only a recipe locator at line 136. + + 25. The source-control action picker omits named custom agents because + + options are built from the built-in catalog/detection only: src/ + + renderer/src/components/right-sidebar/ + + useSourceControlAgentActionDialog.ts:146. + + 26. Automation terminal cleanup is discarded. src/main/automations/ + + service.ts:143 early-returns for already-final runs, but the + + renderer intentionally sends a second update clearing retired + + terminal pointers at src/renderer/src/hooks/ + + useAutomationDispatchEvents.ts:377. “View run” can target a dead + + terminal. + + 27. Remote capacity-recovery sheets do not live-refresh. src/renderer/ + + src/components/agent/AgentLaunchCapacityRecoverySheet.tsx:135 + + subscribes only to local preload events, not remote runtime + + worktreesChanged. + + 28. All four new SSH custom-agent E2Es fail before exercising the + + feature. tests/e2e/helpers/docker-ssh-custom-agent-remote.ts:112 + + treats SshTargetAddResult as the target instead of + + destructuring .target, producing SSH target "undefined" not found. + + 29. The broken SSH suite is non-blocking in PR verification, and the + + release workflow runs full E2E only after publication: .github/ + + workflows/pr.yml:541. + + ## P2 — Narrow but concrete regressions + + - Repaired null defaults map back to legacy Auto and can unexpectedly + + launch an installed agent: src/shared/tui-agent-selection.ts:82. + + - Legacy Windows prefix parsing does not understand PowerShell + + backticks or cmd carets: src/shared/legacy-agent-prefix- + + tokenizer.ts:115. + + - Duplicating a built-in flattens grouped args with .join(' '), losing + + quoted boundaries: src/main/agent-launch/agent-catalog-lifecycle- + + mutations.ts:117. + + - Future catalog schema versions remain writable by older app builds: + + src/shared/agent-catalog-schema-migration.ts:54. + + - Quick-create can submit before the catalog loads and silently choose + + a built-in: src/renderer/src/components/ + + NewWorkspaceComposerModal.tsx:159. + + - Required Linux packaging bypasses the CLI require-graph verifier: + + package.json:72, config/scripts/run-electron-vite-targets-in- + + parallel.mjs:35. This reopens a previously shipped dead-CLI failure + + class. + + - Recovery UI advertises any existing backup as restorable even if it + + cannot be read. + + - settings.get now carries up to 512 KiB of catalog data on unrelated + + mobile/paired-web settings reads; paired web then discards it: src/ + + main/runtime/rpc/methods/client-ui.ts:38. + + - Mobile revision caches clear data but retain every historical host + + key for process lifetime. \ No newline at end of file diff --git a/config/scripts/pr-e2e-gate-contract.test.mjs b/config/scripts/pr-e2e-gate-contract.test.mjs index 7cf2b4e11b4..b28830aba12 100644 --- a/config/scripts/pr-e2e-gate-contract.test.mjs +++ b/config/scripts/pr-e2e-gate-contract.test.mjs @@ -102,6 +102,20 @@ describe('PR E2E gate contract', () => { expect(verifyStep.run).not.toContain('$E2E') }) + it('blocks merges on the SSH custom-agent E2E lane', () => { + // Why unfiltered and required: it is the only live evidence that the host + // resolves and spawns a custom agent on a remote, and a launch-code change + // can break it without touching tests/e2e/**. Leaving it advisory let a + // broken suite reach a release, where full E2E only runs after publication. + const sshJob = prWorkflow.jobs.ssh_custom_agent_e2e + expect(sshJob.if).toBeUndefined() + expect(sshJob['continue-on-error']).toBeUndefined() + expect(sshJob.steps.find((step) => step.name === 'Run SSH custom-agent e2e').run).toContain( + 'pnpm run test:e2e:ssh-custom-agent' + ) + expect(prWorkflow.jobs.verify.needs).toContain('ssh_custom_agent_e2e') + }) + it('passes only changed specs to the reusable E2E workflow', () => { // Why: without this the job could lose its filter and run on every PR — the // cost the path filter exists to avoid — while the gate assertions above diff --git a/config/scripts/pr-workflow-parallelism.test.mjs b/config/scripts/pr-workflow-parallelism.test.mjs index d32a25abc08..e13e4332246 100644 --- a/config/scripts/pr-workflow-parallelism.test.mjs +++ b/config/scripts/pr-workflow-parallelism.test.mjs @@ -102,8 +102,12 @@ describe('PR workflow parallelism', () => { .split(/\s+/) .filter((token) => !['apt-get', 'install', 'sudo', ''].includes(token)) .filter((token) => !token.startsWith('-')) - const jobsInstallingPackages = Object.entries(workflow.jobs) - .filter(([, job]) => (job.steps ?? []).some((step) => aptPackages(step).length > 0)) + // Scoped to shells: other lanes legitimately apt-install unrelated tooling. + const shells = ['zsh', 'fish', 'bash', 'dash', 'ksh'] + const jobsInstallingShells = Object.entries(workflow.jobs) + .filter(([, job]) => + (job.steps ?? []).some((step) => aptPackages(step).some((pkg) => shells.includes(pkg))) + ) .map(([name]) => name) expect(shellStep).toBeDefined() @@ -111,7 +115,7 @@ describe('PR workflow parallelism', () => { expect(shellStep.run.split(/\s+/)).toContain('--maxWorkers=1') // Why the whole workflow, not just the general shards: any other lane installing // these shells would silently start running the real-shell tests twice. - expect(jobsInstallingPackages).toEqual(['shell_contracts']) + expect(jobsInstallingShells).toEqual(['shell_contracts']) // Why each shell is asserted: the live tests skip themselves when the binary is // missing, so a dropped package silently empties this lane instead of failing it. const shellPackages = workflow.jobs.shell_contracts.steps.flatMap(aptPackages) @@ -463,7 +467,8 @@ describe('PR workflow parallelism', () => { 'managed_hook_node18', 'package', 'package_windows', - 'custom_agent_platform' + 'custom_agent_platform', + 'ssh_custom_agent_e2e' ]) const verifyStep = workflow.jobs.verify.steps.find( (step) => step.name === 'Require successful checks' diff --git a/config/scripts/run-electron-vite-targets-in-parallel.mjs b/config/scripts/run-electron-vite-targets-in-parallel.mjs index 2c0d0064424..927df0f176f 100644 --- a/config/scripts/run-electron-vite-targets-in-parallel.mjs +++ b/config/scripts/run-electron-vite-targets-in-parallel.mjs @@ -2,31 +2,28 @@ import { spawn } from 'node:child_process' import { fileURLToPath } from 'node:url' const buildScript = fileURLToPath(new URL('./run-electron-vite-build.mjs', import.meta.url)) -// Keep this wrapper CommonJS (the `.cts` extension) so electron-vite can load -// each parallel target without sharing its timestamp-named ESM temp file. -const targetConfig = fileURLToPath(new URL('../electron-vite-target.config.cts', import.meta.url)) +const verifyRequiresScript = fileURLToPath( + new URL('./verify-cli-require-resolution.mjs', import.meta.url) +) +const targetConfig = fileURLToPath(new URL('../electron-vite-target.config.ts', import.meta.url)) const targets = ['main', 'preload', 'renderer'] -function buildTarget(target) { +function runNodeScript(args, env, label) { return new Promise((resolve, reject) => { - const child = spawn( - process.execPath, - [buildScript, '--config', targetConfig, '--ignoreConfigWarning'], - { - stdio: 'inherit', - env: { - ...process.env, - ORCA_ELECTRON_VITE_TARGET: target - } + const child = spawn(process.execPath, args, { + stdio: 'inherit', + env: { + ...process.env, + ...env } - ) + }) child.on('error', reject) child.on('exit', (code, signal) => { if (signal) { - reject(new Error(`Electron Vite ${target} build exited with signal ${signal}`)) + reject(new Error(`${label} exited with signal ${signal}`)) } else if (code !== 0) { - reject(new Error(`Electron Vite ${target} build exited with code ${code}`)) + reject(new Error(`${label} exited with code ${code}`)) } else { resolve() } @@ -34,6 +31,14 @@ function buildTarget(target) { }) } +function buildTarget(target) { + return runNodeScript( + [buildScript, '--config', targetConfig, '--ignoreConfigWarning'], + { ORCA_ELECTRON_VITE_TARGET: target }, + `Electron Vite ${target} build` + ) +} + const results = await Promise.allSettled(targets.map(buildTarget)) const failures = results.filter((result) => result.status === 'rejected') @@ -43,3 +48,12 @@ if (failures.length > 0) { } process.exit(1) } + +// Why: build:electron-vite chains this verifier, and Linux packaging takes the +// parallel path — skipping it here reopens the dead-CLI require graph (1.4.150-rc.1.perf). +try { + await runNodeScript([verifyRequiresScript], {}, 'CLI require resolution check') +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)) + process.exit(1) +} diff --git a/config/scripts/run-electron-vite-targets-in-parallel.test.mjs b/config/scripts/run-electron-vite-targets-in-parallel.test.mjs new file mode 100644 index 00000000000..15c4f5ba694 --- /dev/null +++ b/config/scripts/run-electron-vite-targets-in-parallel.test.mjs @@ -0,0 +1,38 @@ +import { readFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +const projectDir = resolve(import.meta.dirname, '../..') +const packageJson = JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf8')) +const runnerSource = readFileSync( + join(projectDir, 'config/scripts/run-electron-vite-targets-in-parallel.mjs'), + 'utf8' +) + +// Why: the serial script is the source of truth for what a completed +// electron-vite build owes, so renaming its verifier fails here instead of +// silently leaving the parallel path — the one Linux packaging takes — unverified. +const serialVerifier = packageJson.scripts['build:electron-vite'].match( + /config\/scripts\/(verify-[\w-]+\.mjs)/ +)?.[1] + +describe('parallel electron-vite target runner', () => { + it('verifies the compiled CLI require graph like the serial build does', () => { + expect(serialVerifier).toBe('verify-cli-require-resolution.mjs') + expect(runnerSource).toContain(`./${serialVerifier}`) + }) + + it('runs the verifier only after every target build succeeded', () => { + const failureGate = runnerSource.indexOf('failures.length > 0') + const verifierRun = runnerSource.indexOf('runNodeScript([verifyRequiresScript]') + + expect(failureGate).toBeGreaterThan(-1) + expect(verifierRun).toBeGreaterThan(failureGate) + }) + + it('keeps packaging on the parallel entry point this runner backs', () => { + expect(packageJson.scripts['build:electron-vite:parallel']).toContain( + 'run-electron-vite-targets-in-parallel.mjs' + ) + }) +}) diff --git a/config/scripts/verify-cli-require-resolution.mjs b/config/scripts/verify-cli-require-resolution.mjs index d65b81292bf..aa155c99d7d 100644 --- a/config/scripts/verify-cli-require-resolution.mjs +++ b/config/scripts/verify-cli-require-resolution.mjs @@ -12,6 +12,12 @@ import { pathToFileURL } from 'node:url' const RELATIVE_REQUIRE_PATTERN = /\brequire\(\s*(["'])(\.{1,2}\/[^"']+)\1\s*\)/g +// Why: build:cli emits compiled specs into out/, but the shipped CLI never +// requires them — they run from src under vitest. Seeding the walk with them +// would fail the build on imports (e.g. main-side parity checks) that no user +// command can reach. They are still walked if a real entry pulls one in. +const COMPILED_SPEC_PATTERN = /\.(test|spec)\.js$/ + function listJsFilesRecursively(dir) { const files = [] for (const entry of readdirSync(dir, { withFileTypes: true })) { @@ -51,7 +57,7 @@ export function verifyCliRequireResolution({ return { checkedFiles: 0, missing: [], skipped: true } } - const queue = listJsFilesRecursively(cliDir) + const queue = listJsFilesRecursively(cliDir).filter((file) => !COMPILED_SPEC_PATTERN.test(file)) const visited = new Set(queue) const missing = [] while (queue.length > 0) { diff --git a/config/scripts/verify-cli-require-resolution.test.mjs b/config/scripts/verify-cli-require-resolution.test.mjs index b9b33456291..fb79418f1cc 100644 --- a/config/scripts/verify-cli-require-resolution.test.mjs +++ b/config/scripts/verify-cli-require-resolution.test.mjs @@ -75,6 +75,29 @@ describe('verifyCliRequireResolution', () => { expect(result.missing).toEqual([]) }) + it('ignores compiled specs that import main modules the shipped CLI never loads', () => { + // serve-electron-flag-parity.test.ts imports a main module listed only for + // typecheck parity; no CLI command can reach it. + const projectDir = makeProject({ + 'out/cli/index.js': 'module.exports = {}', + 'out/cli/serve-electron-flag-parity.test.js': `require("../main/startup/serve-mode-argv");` + }) + const result = verifyCliRequireResolution({ projectDir }) + expect(result.missing).toEqual([]) + expect(result.checkedFiles).toBe(1) + }) + + it('still reports a spec pulled in by a real CLI entry', () => { + const projectDir = makeProject({ + 'out/cli/index.js': `require("./fixtures.test");`, + 'out/cli/fixtures.test.js': `require("../main/deleted-by-clean");` + }) + const result = verifyCliRequireResolution({ projectDir }) + expect(result.missing).toEqual([ + { from: path.join('out', 'cli', 'fixtures.test.js'), specifier: '../main/deleted-by-clean' } + ]) + }) + it('skips when out/cli has not been built', () => { const projectDir = makeProject({ 'out/main/index.js': '' }) const result = verifyCliRequireResolution({ projectDir }) diff --git a/mobile/app/h/[hostId]/tasks.tsx b/mobile/app/h/[hostId]/tasks.tsx index e3dbc69955b..a306ca21c58 100644 --- a/mobile/app/h/[hostId]/tasks.tsx +++ b/mobile/app/h/[hostId]/tasks.tsx @@ -1,77 +1,15171 @@ -import { useMobileTasksRouteAndItemState } from '../../../src/tasks/use-mobile-tasks-route-and-item-state' -import { useMobileTasksWorkspaceAndProjectState } from '../../../src/tasks/use-mobile-tasks-workspace-and-project-state' -import { useMobileTasksProjectProjection } from '../../../src/tasks/use-mobile-tasks-project-projection' -import { useMobileTasksProjectRepositoryResolution } from '../../../src/tasks/use-mobile-tasks-project-repository-resolution' -import { useMobileTasksClientSettingsActions } from '../../../src/tasks/use-mobile-tasks-client-settings-actions' -import { useMobileTasksRuntimeHydration } from '../../../src/tasks/use-mobile-tasks-runtime-hydration' -import { useMobileTasksProviderLoadActions } from '../../../src/tasks/use-mobile-tasks-provider-load-actions' -import { useMobileTasksTaskListLoading } from '../../../src/tasks/use-mobile-tasks-task-list-loading' -import { useMobileTasksTaskPaginationActions } from '../../../src/tasks/use-mobile-tasks-task-pagination-actions' -import { useMobileTasksProjectLoadingActions } from '../../../src/tasks/use-mobile-tasks-project-loading-actions' -import { useMobileTasksListAndDetailEffects } from '../../../src/tasks/use-mobile-tasks-list-and-detail-effects' -import { useMobileTasksItemDetailMetadataEffects } from '../../../src/tasks/use-mobile-tasks-item-detail-metadata-effects' -import { useMobileTasksItemDetailLoading } from '../../../src/tasks/use-mobile-tasks-item-detail-loading' -import { useMobileTasksProjectDetailLoading } from '../../../src/tasks/use-mobile-tasks-project-detail-loading' -import { useMobileTasksProjectMetadataLoading } from '../../../src/tasks/use-mobile-tasks-project-metadata-loading' -import { useMobileTasksWorkspaceCreateProjection } from '../../../src/tasks/use-mobile-tasks-workspace-create-projection' -import { useMobileTasksWorkspaceSourceEffects } from '../../../src/tasks/use-mobile-tasks-workspace-source-effects' -import { useMobileTasksWorkspaceSparseActions } from '../../../src/tasks/use-mobile-tasks-workspace-sparse-actions' -import { useMobileTasksWorkspaceSshState } from '../../../src/tasks/use-mobile-tasks-workspace-ssh-state' -import { useMobileTasksWorkspaceCreateActions } from '../../../src/tasks/use-mobile-tasks-workspace-create-actions' -import { useMobileTasksProjectWorkspaceCommentActions } from '../../../src/tasks/use-mobile-tasks-project-workspace-comment-actions' -import { useMobileTasksProjectThreadReplyActions } from '../../../src/tasks/use-mobile-tasks-project-thread-reply-actions' -import { useMobileTasksProjectMetadataActions } from '../../../src/tasks/use-mobile-tasks-project-metadata-actions' -import { useMobileTasksProjectReviewCheckActions } from '../../../src/tasks/use-mobile-tasks-project-review-check-actions' -import { useMobileTasksProjectFileMergeActions } from '../../../src/tasks/use-mobile-tasks-project-file-merge-actions' -import { useMobileTasksGitlabGithubStatusActions } from '../../../src/tasks/use-mobile-tasks-gitlab-github-status-actions' -import { useMobileTasksHostedMetadataActions } from '../../../src/tasks/use-mobile-tasks-hosted-metadata-actions' -import { useMobileTasksHostedCommentReviewActions } from '../../../src/tasks/use-mobile-tasks-hosted-comment-review-actions' -import { useMobileTasksGithubCheckFileActions } from '../../../src/tasks/use-mobile-tasks-github-check-file-actions' -import { useMobileTasksGithubReplyMergeActions } from '../../../src/tasks/use-mobile-tasks-github-reply-merge-actions' -import { useMobileTasksLinearItemActions } from '../../../src/tasks/use-mobile-tasks-linear-item-actions' -import { useMobileTasksTaskCreateActions } from '../../../src/tasks/use-mobile-tasks-task-create-actions' -import { useMobileTasksDetailCommentRenderers } from '../../../src/tasks/use-mobile-tasks-detail-comment-renderers' -import { useMobileTasksPickerProjection } from '../../../src/tasks/use-mobile-tasks-picker-projection' -import { useMobileTasksProviderViewProjection } from '../../../src/tasks/use-mobile-tasks-provider-view-projection' -import { useMobileTasksConnectionPresentation } from '../../../src/tasks/use-mobile-tasks-connection-presentation' -import { MobileTasksLegacySurface } from '../../../src/tasks/MobileTasksLegacySurface' +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import type { ReactNode } from 'react' +import { + ActivityIndicator, + FlatList, + Linking, + Platform, + Pressable, + ScrollView, + StyleSheet, + Text, + TextInput, + View +} from 'react-native' +import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context' +import * as Clipboard from 'expo-clipboard' +import { useLocalSearchParams, useRouter } from 'expo-router' +import { + AlertTriangle, + Check, + ChevronDown, + ChevronLeft, + ChevronRight, + ChevronUp, + Copy, + ExternalLink, + GitBranch, + Lock, + Pencil, + Plus, + RefreshCw, + Send, + X +} from 'lucide-react-native' +import type { RpcClient } from '../../../src/transport/rpc-client' +import type { RpcSuccess } from '../../../src/transport/types' +import { useHostClient } from '../../../src/transport/client-context' +import { + useLastConnectedAt, + useRelayRecoveryStatus, + useReconnectAttempt +} from '../../../src/transport/client-context-connection-metrics' +import { classifyConnection } from '../../../src/transport/connection-health' +import { StatusDot } from '../../../src/components/StatusDot' +import { ActionSheetModal } from '../../../src/components/ActionSheetModal' +import { BottomDrawer } from '../../../src/components/BottomDrawer' +import { ConfirmModal } from '../../../src/components/ConfirmModal' +import { MobileMarkdown } from '../../../src/components/MobileMarkdown' +import { MobileAgentIcon } from '../../../src/components/MobileAgentIcon' +import { MobileWorkspaceNameInput } from '../../../src/components/MobileWorkspaceNameInput' +import { MobileSearchField } from '../../../src/components/MobileSearchField' +import { MobileSyntaxSegments } from '../../../src/components/MobileSyntaxSegments' +import { PickerModal, type PickerOption } from '../../../src/components/PickerModal' +import { TaskProviderLogo } from '../../../src/components/TaskProviderLogo' +import { + buildGitHubPrFileDiffPreview, + type GitHubPrFileDiffLine +} from '../../../src/tasks/github-pr-file-diff' +import { + highlightMobileDiffLines, + resolveMobileSyntaxLanguage +} from '../../../src/session/mobile-file-syntax' +import { buildGitHubCheckSummary } from '../../../src/tasks/github-check-summary' +import { buildGitLabCheckSummary } from '../../../src/tasks/gitlab-check-summary' +import { + getHostedMergeLabel, + getHostedReviewLabel, + getHostedReviewSignalTone, + getHostedChecksLabel +} from '../../../src/tasks/mobile-hosted-check-status' +import { buildTaskWorkspaceCreateParams } from '../../../src/tasks/workspace-create-params' +import { MOBILE_TASKS_CAPABILITY } from '../../../src/tasks/mobile-tasks-capability' +import { + filterWorkspaceAgents, + isWorkspaceAgentEnabled, + pickWorkspaceAgent, + resolveWorkspaceAgentSelection, + workspaceAgentLabel, + type WorkspaceAgentChoice +} from '../../../src/tasks/workspace-agent-selection' +import { shouldResolveHostedReviewStartPoint } from '../../../src/tasks/hosted-review-start-point' +import { getLinkedWorkItemSuggestedName } from '../../../src/tasks/mobile-workspace-name' +import { + dropFailedGitHubRepoSlugEntries, + filterGitHubProjectRowsForRepos, + findRepoForGitHubProjectRepository, + type GitHubRepoSlugCacheEntry +} from '../../../src/tasks/github-project-repo-match' +import { + parseGitHubProjectInput as parseProjectInput, + type GitHubProjectOwnerType, + type GitHubProjectPartialFailure, + type GitHubProjectRef, + type GitHubProjectSettings, + type GitHubProjectSummary, + type GitHubProjectViewSummary +} from '../../../src/tasks/github-project-reference' +import { + extractGitHubIssueSourceFallback, + extractGitHubIssueSourceError, + type GitHubIssueSourceFallback, + type GitHubIssueSourceError +} from '../../../src/tasks/github-work-item-source-errors' +import { parseSparsePresetDirectories } from '../../../src/tasks/sparse-preset-draft' +import { + deriveWorkspaceSshGate, + workspaceSshStatusLabel +} from '../../../src/tasks/workspace-ssh-gate' +import { WORKTREE_CREATE_TIMEOUT_MS } from '../../../src/tasks/workspace-create-timeout' +import { + isSetupHookTrusted, + normalizeSetupHookTrust, + trustedOrcaHooksWithSetupApproval, + wasSetupHookPreviouslyApproved +} from '../../../src/tasks/setup-hook-trust' +import { colors, radii, spacing, typography } from '../../../src/theme/mobile-theme' +import { triggerMediumImpact } from '../../../src/platform/haptics' +import { + groupRows, + isIterationCurrent, + sortRows, + type ProjectGroup +} from '../../../../src/shared/github/project-group-sort' +import type { + GitHubProjectSortDirection, + GitHubProjectTable as SharedGitHubProjectTable +} from '../../../../src/shared/github/project-types' +import { + CROSS_REPO_DISPLAY_LIMIT, + isGitHubWorkItemsSshRemoteRequiredError, + PER_REPO_FETCH_LIMIT +} from '../../../src/tasks/mobile-work-items' +import { + filterAvailableTaskProviders, + normalizeVisibleTaskProviders, + resolveVisibleTaskProvider, + type TaskProvider +} from '../../../src/tasks/mobile-task-providers' +import { hasSettledHostRepoList } from '../../../src/tasks/host-repo-list' +import { useHostRepoList } from '../../../src/tasks/use-host-repo-list' +import { isHostedTaskRepo, reconcileRepoSelection } from '../../../src/tasks/hosted-repo-selection' +import { + extractLinearIssueReadItems, + type LinearMobileIssue +} from '../../../src/tasks/linear-mobile-issue-read' +import { MOBILE_TUI_AGENT_AUTO_PICK_ORDER } from '../../../src/tasks/mobile-tui-agents' +import { resolveComposerBranchSelection } from '../../../src/tasks/mobile-composer-branch-selection' +import { + clearMobileTaskCopyFeedbackTimer, + scheduleMobileTaskCopyFeedbackReset +} from '../../../src/tasks/mobile-task-copy-feedback-timer' +import type { + GitHubOwnerRepo, + ProviderCheckSummary +} from '../../../../src/shared/github/pull-request-types' +import type { PersistedTrustedOrcaHooks } from '../../../../src/shared/orca-yaml-hook-types' +import type { BaseRefSearchResult } from '../../../../src/shared/repo-types' +import type { TuiAgent } from '../../../../src/shared/tui-agent' +import type { SparsePreset } from '../../../../src/shared/worktree/create-types' +import type { SshConnectionState } from '../../../../src/shared/ssh-types' +import type { HostedReviewDecision } from '../../../../src/shared/hosted-review' +import { + githubProjectHost, + githubProjectIdentityKey as githubProjectKey +} from '../../../../src/shared/github/project-identity' + +type RepoSummary = { + id: string + displayName: string + path: string + badgeColor?: string + kind?: 'git' | 'folder' + connectionId?: string | null + issueSourcePreference?: IssueSourcePreference + /** Fork parent resolved by the host; drives upstream Project row matching. */ + upstream?: { owner: string; repo: string; host?: string } | null +} + +type IssueSourcePreference = 'upstream' | 'origin' | 'auto' + +type GitHubWorkItem = { + id: string + type: 'issue' | 'pr' + number: number + title: string + state: 'open' | 'closed' | 'merged' | 'draft' + url: string + labels: string[] + updatedAt: string + author: string | null + branchName?: string + baseRefName?: string + isCrossRepository?: boolean + additions?: number + deletions?: number + changedFiles?: number + repoId: string + repoName: string + reviewDecision?: string | null + reviewRequests?: GitHubAssignableUser[] + latestReviews?: GitHubPRReviewSummary[] + checksSummary?: ProviderCheckSummary + mergeable?: GitHubPRMergeableState + mergeStateStatus?: string | null +} +type GitHubAssignableUser = { + login: string + name?: string | null + avatarUrl?: string | null +} +type GitHubPRReviewSummary = { + login: string + state?: string | null + avatarUrl?: string | null +} +type GitHubPRMergeableState = 'MERGEABLE' | 'CONFLICTING' | 'UNKNOWN' + +type GitHubPRReviewerRow = { + login: string + name?: string | null + avatarUrl?: string | null + stateLabel: string +} +type GitHubRepoSources = { + issues: GitHubOwnerRepo | null + prs: GitHubOwnerRepo | null + upstreamCandidate: GitHubOwnerRepo | null +} +type TaskRuntimeStatus = { + capabilities?: string[] +} + +type TasksSupportState = + | { kind: 'unknown'; client: RpcClient | null } + | { kind: 'supported'; client: RpcClient } + | { kind: 'unsupported'; client: RpcClient } +type GitLabWorkItem = { + id: string + type: 'issue' | 'mr' + number: number + title: string + state: 'opened' | 'closed' | 'merged' | 'locked' | 'draft' + url: string + labels: string[] + updatedAt: string + author: string | null + branchName?: string + baseRefName?: string + isCrossRepository?: boolean + projectRef?: { host: string; path: string } + checksSummary?: ProviderCheckSummary + mergeable?: 'MERGEABLE' | 'CONFLICTING' | 'UNKNOWN' + reviewDecision?: HostedReviewDecision + reviewerCount?: number + repoId: string + repoName: string +} + +type GitLabTodo = { + id: number + actionName: string + targetType: string + targetIid: number | null + targetTitle: string + targetUrl: string + projectPath: string + authorUsername: string + updatedAt: string + state: 'pending' | 'done' +} + +type GitPushTarget = { + remoteName: string + branchName: string + remoteUrl?: string +} + +type SetupDecision = 'inherit' | 'run' | 'skip' +type SetupRunPolicy = 'ask' | 'run-by-default' | 'skip-by-default' + +type RepoHooksResponse = { + hooks: { scripts?: { setup?: string } } | null + source: string | null + setupRunPolicy?: SetupRunPolicy + setupTrust?: { + contentHash: string + scriptContent: string + } +} + +type LinearProject = { + id: string + name: string + url?: string + color?: string +} + +type LinearIssueChild = { + id: string + identifier: string + title: string + url: string +} + +type LinearIssue = LinearMobileIssue + +type LinearState = { + id: string + name: string + type: string + color?: string +} + +type LinearTeam = { + id: string + workspaceId?: string + workspaceName?: string + name: string + key: string +} + +type DetailComment = { + id: string | number + author?: string + authorAvatarUrl?: string + user?: { displayName?: string } + isBot?: boolean + body: string + createdAt?: string + url?: string + reactions?: Array<{ + content: + | 'thumbs_up' + | 'thumbs_down' + | 'laugh' + | 'confused' + | 'heart' + | 'hooray' + | 'rocket' + | 'eyes' + count: number + }> + path?: string + line?: number + startLine?: number + threadId?: string + isResolved?: boolean +} + +type GitHubDetailFile = { + path: string + oldPath?: string + status?: 'added' | 'modified' | 'removed' | 'renamed' | 'copied' | 'changed' | 'unchanged' + additions?: number + deletions?: number + isBinary?: boolean + viewerViewedState?: 'DISMISSED' | 'VIEWED' | 'UNVIEWED' +} + +type GitHubDetailCheck = { + name: string + status: string + conclusion?: string | null + url?: string | null +} + +type GitHubPRFileContents = { + original: string + modified: string + originalIsBinary: boolean + modifiedIsBinary: boolean +} + +type DetailPayload = + | { + provider: 'github' + body: string + comments: DetailComment[] + labels: string[] + assignees: string[] + reviewDecision?: string | null + reviewRequests: GitHubAssignableUser[] + latestReviews: GitHubPRReviewSummary[] + headSha?: string + baseSha?: string + pullRequestId?: string + checks: GitHubDetailCheck[] + files: GitHubDetailFile[] + } + | { + provider: 'gitlab' + body: string + comments: DetailComment[] + labels: string[] + assignees: string[] + pipelineJobs: Array<{ + id?: number + name: string + stage: string + status: string + webUrl?: string | null + duration?: number | null + }> + } + | { + provider: 'linear' + description: string + comments: DetailComment[] + labels: string[] + assignee?: string + project?: LinearProject + children: LinearIssueChild[] + } + +type GitHubTaskKind = 'issues' | 'prs' +type GitHubMode = GitHubTaskKind | 'project' +type GitHubPreset = 'issues' | 'my-issues' | 'prs' | 'my-prs' | 'review' +type GitLabView = 'project' | 'todos' +type GitLabFilter = 'opened' | 'merged' | 'closed' | 'all' +type LinearFilter = 'assigned' | 'created' | 'all' | 'completed' +type LinearViewMode = 'list' | 'board' +type LinearGroupBy = 'none' | 'status' | 'assignee' | 'priority' | 'team' +type LinearOrderBy = 'priority' | 'updated' | 'identifier' +type LinearDisplayProperty = 'state' | 'priority' | 'assignee' | 'team' | 'labels' | 'updated' +type TaskSort = 'updated' | 'repository' +type DetailCommentGroup = + | { kind: 'standalone'; comment: DetailComment } + | { kind: 'thread'; threadId: string; root: DetailComment; replies: DetailComment[] } +type TaskResumeState = { + githubMode?: 'items' | 'project' + githubItemsPreset?: GitHubPreset | 'all' | null + githubItemsQuery?: string + githubProjectHiddenFieldIdsByView?: Record + linearPreset?: LinearFilter + linearQuery?: string +} +type RuntimeTaskSettings = { + defaultTuiAgent?: TuiAgent | 'blank' | null + disabledTuiAgents?: TuiAgent[] + agentCmdOverrides?: Record + defaultTaskSource?: TaskProvider + defaultTaskViewPreset?: GitHubPreset | 'all' + visibleTaskProviders?: TaskProvider[] + defaultRepoSelection?: string[] | null + defaultLinearTeamSelection?: string[] | null + githubProjects?: GitHubProjectSettings +} + +type LinearWorkspace = { + id: string + organizationName?: string + displayName?: string +} + +type LinearStatusResponse = { + connected?: boolean + workspaces?: LinearWorkspace[] + selectedWorkspaceId?: string | 'all' | null + activeWorkspaceId?: string | null +} + +type GitHubIssueType = { + id: string + name: string + color: string | null + description: string | null +} +type GitHubProjectField = + | { + kind: 'field' + id: string + name: string + dataType: string + } + | { + kind: 'single-select' + id: string + name: string + dataType: 'SINGLE_SELECT' + options: Array<{ id: string; name: string; color: string }> + } + | { + kind: 'iteration' + id: string + name: string + dataType: 'ITERATION' + iterations: Array<{ + id: string + title: string + startDate: string + duration: number + completed?: boolean + }> + } +type GitHubProjectSort = { + direction: GitHubProjectSortDirection + field: GitHubProjectField +} +type GitHubProjectFieldValue = + | { kind: 'single-select'; fieldId: string; optionId: string; name: string; color: string } + | { + kind: 'iteration' + fieldId: string + iterationId: string + title: string + startDate: string + duration: number + } + | { kind: 'text'; fieldId: string; text: string } + | { kind: 'number'; fieldId: string; number: number } + | { kind: 'date'; fieldId: string; date: string } + | { kind: 'labels'; fieldId: string; labels: Array<{ name: string; color: string }> } + | { kind: 'users'; fieldId: string; users: Array<{ login: string; name: string | null }> } +type GitHubProjectFieldMutationValue = + | { kind: 'text'; text: string } + | { kind: 'number'; number: number } + | { kind: 'date'; date: string } + | { kind: 'single-select'; optionId: string } + | { kind: 'iteration'; iterationId: string } +type GitHubProjectRow = { + id: string + itemType: 'ISSUE' | 'PULL_REQUEST' | 'DRAFT_ISSUE' | 'REDACTED' + content: { + number: number | null + title: string + body: string | null + url: string | null + state: string | null + stateReason?: string | null + isDraft: boolean | null + repository: string | null + issueType?: GitHubIssueType | null + labels: Array<{ name: string; color: string }> + assignees: Array<{ login: string; name: string | null }> + parentIssue?: { number: number; title: string; url: string } | null + } + fieldValuesByFieldId?: Record + updatedAt: string + position?: number +} +type GitHubProjectTable = { + project: GitHubProjectRef & { + id: string + title: string + url: string + } + selectedView: { + id: string + number: number + name: string + filter: string + layout: 'TABLE_LAYOUT' | 'BOARD_LAYOUT' | 'ROADMAP_LAYOUT' + fields?: GitHubProjectField[] + groupByFields?: GitHubProjectField[] + sortByFields?: GitHubProjectSort[] + } + rows: GitHubProjectRow[] + totalCount: number + parentFieldDropped?: boolean +} + +type TaskItem = + | { + key: string + provider: 'github' + title: string + subtitle: string + status: string + updatedAt: string + source: GitHubWorkItem + } + | { + key: string + provider: 'gitlab' + title: string + subtitle: string + status: string + updatedAt: string + source: GitLabWorkItem + } + | { + key: string + provider: 'gitlabTodo' + title: string + subtitle: string + status: string + updatedAt: string + source: GitLabTodo + } + | { + key: string + provider: 'linear' + title: string + subtitle: string + status: string + updatedAt: string + source: LinearIssue + } + +type ActionableTaskItem = Exclude +type HostedReviewMergeMethod = 'merge' | 'squash' | 'rebase' +type HostedReviewItem = + | Extract + | Extract +type PendingHostedMerge = { + item: HostedReviewItem + method: HostedReviewMergeMethod +} +type PendingProjectGitHubMerge = { + row: GitHubProjectRow + method: HostedReviewMergeMethod +} +type PendingHostedStateChange = + | { + source: 'task' + item: Extract | Extract + nextState: 'open' | 'opened' | 'closed' + } + | { + source: 'project' + row: GitHubProjectRow + nextState: 'open' | 'closed' + } + +type SetupPrompt = { + item: ActionableTaskItem + repoIdOverride?: string + agentOverride?: WorkspaceAgentChoice + workspaceNameOverride?: string + noteOverride?: string + baseBranchOverride?: string + branchNameOverride?: string + sparseCheckoutOverride?: { directories: string[]; presetId?: string } + repoName: string + command: string + source: string | null +} + +type WorkspaceCreateArgs = { + item: ActionableTaskItem + repoIdOverride?: string + setupOverride?: Exclude + agentOverride?: WorkspaceAgentChoice + workspaceNameOverride?: string + noteOverride?: string + baseBranchOverride?: string + branchNameOverride?: string + sparseCheckoutOverride?: { directories: string[]; presetId?: string } +} + +type OrcaYamlTrustPrompt = WorkspaceCreateArgs & { + repoId: string + repoName: string + scriptContent: string + contentHash: string + previouslyApproved: boolean +} + +type WorkspaceCreateDraft = { + item: ActionableTaskItem + repoIdOverride?: string +} + +type WorkspaceSparseDraft = { + mode: 'new' | 'edit' + presetId?: string + name: string + directoriesText: string +} + +function sortSparsePresetsByName(presets: SparsePreset[]): SparsePreset[] { + return [...presets].sort((left, right) => left.name.localeCompare(right.name)) +} + +function workspaceAgentIconId(agent: WorkspaceAgentChoice): string { + return agent === 'blank' ? '__blank__' : agent +} + +type ProjectRepoNotInOrcaPrompt = { + owner: string + repo: string + url: string | null +} + +type TaskListEntry = + | { type: 'section'; key: string; label: string; color: string } + | { type: 'item'; key: string; item: TaskItem } + +const PROVIDER_OPTIONS: PickerOption[] = [ + { + value: 'github', + label: 'GitHub', + subtitle: 'Issues and pull requests', + renderIcon: (selected) => ( + + ) + }, + { + value: 'gitlab', + label: 'GitLab', + subtitle: 'Issues and merge requests', + renderIcon: (selected) => ( + + ) + }, + { + value: 'linear', + label: 'Linear', + subtitle: 'Assigned and team issues', + renderIcon: (selected) => ( + + ) + } +] + +const GITLAB_FILTER_OPTIONS: PickerOption[] = [ + { value: 'opened', label: 'Open', subtitle: 'Open issues and merge requests' }, + { value: 'merged', label: 'Merged', subtitle: 'Merged merge requests' }, + { value: 'closed', label: 'Closed', subtitle: 'Closed issues and merge requests' }, + { value: 'all', label: 'All', subtitle: 'Any GitLab state' } +] + +const LINEAR_FILTER_OPTIONS: PickerOption[] = [ + { value: 'all', label: 'All', subtitle: 'Open issues across connected workspaces' }, + { value: 'assigned', label: 'My Issues', subtitle: 'Issues assigned to you' }, + { value: 'created', label: 'Created', subtitle: 'Issues created by you' }, + { value: 'completed', label: 'Completed', subtitle: 'Recently completed issues' } +] + +const LINEAR_VIEW_OPTIONS: PickerOption[] = [ + { value: 'list', label: 'List', subtitle: 'Compact issue rows' }, + { value: 'board', label: 'Board', subtitle: 'Grouped columns' } +] + +function taskWorkspaceFallback(item: ActionableTaskItem): string { + if (item.provider === 'github' || item.provider === 'gitlab') { + return `${item.source.type}-${item.source.number}` + } + return item.source.identifier.toLowerCase() +} + +function taskWorkspaceSuggestedName(item: ActionableTaskItem): string { + return getLinkedWorkItemSuggestedName(item) || taskWorkspaceFallback(item) +} + +const COMMENT_REACTION_EMOJI: Record< + NonNullable[number]['content'], + string +> = { + thumbs_up: '+1', + thumbs_down: '-1', + laugh: 'laugh', + confused: 'confused', + heart: 'heart', + hooray: 'hooray', + rocket: 'rocket', + eyes: 'eyes' +} + +const LINEAR_GROUP_OPTIONS: PickerOption[] = [ + { value: 'none', label: 'No grouping' }, + { value: 'status', label: 'Status' }, + { value: 'assignee', label: 'Assignee' }, + { value: 'priority', label: 'Priority' }, + { value: 'team', label: 'Team' } +] + +const LINEAR_ORDER_OPTIONS: PickerOption[] = [ + { value: 'priority', label: 'Priority' }, + { value: 'updated', label: 'Updated' }, + { value: 'identifier', label: 'Identifier' } +] + +const LINEAR_DISPLAY_OPTIONS: PickerOption[] = [ + { value: 'state', label: 'Status' }, + { value: 'priority', label: 'Priority' }, + { value: 'assignee', label: 'Assignee' }, + { value: 'team', label: 'Team' }, + { value: 'labels', label: 'Labels' }, + { value: 'updated', label: 'Updated' } +] + +const DEFAULT_LINEAR_DISPLAY_PROPERTIES: LinearDisplayProperty[] = [ + 'state', + 'priority', + 'assignee', + 'team', + 'labels', + 'updated' +] + +const GITHUB_KIND_OPTIONS: PickerOption[] = [ + { value: 'issues', label: 'Issues', subtitle: 'GitHub issues' }, + { value: 'prs', label: 'PRs', subtitle: 'GitHub pull requests' }, + { value: 'project', label: 'Projects', subtitle: 'GitHub Projects views' } +] + +const ISSUE_PRESETS: PickerOption[] = [ + { value: 'issues', label: 'Open', subtitle: 'Open GitHub issues' }, + { value: 'my-issues', label: 'Assigned to me', subtitle: 'Open issues assigned to you' } +] + +const PR_PRESETS: PickerOption[] = [ + { value: 'prs', label: 'Open', subtitle: 'Open pull requests' }, + { value: 'my-prs', label: 'Mine', subtitle: 'Pull requests authored by you' }, + { value: 'review', label: 'Needs review', subtitle: 'Review requests assigned to you' } +] + +const GITLAB_VIEW_OPTIONS: PickerOption[] = [ + { value: 'project', label: 'Project MRs', subtitle: 'Merge requests and issues by repository' }, + { value: 'todos', label: 'My Todos', subtitle: 'Pending GitLab todos' } +] + +const SORT_OPTIONS: PickerOption[] = [ + { value: 'updated', label: 'Updated', subtitle: 'Newest activity first' }, + { + value: 'repository', + label: 'Repository', + subtitle: 'Group by repository, then newest activity' + } +] + +type ProjectSortOverride = { fieldId: string; direction: GitHubProjectSortDirection } +type ProjectListEntry = + | { type: 'group'; group: ProjectGroup; collapsed: boolean } + | { type: 'row'; row: GitHubProjectRow } +type LinearIssueSection = { key: string; label: string; color: string; issues: LinearIssue[] } +type LinearListEntry = + | { type: 'section'; section: LinearIssueSection } + | { type: 'issue'; issue: LinearIssue } + +const PROJECT_VIEW_DEFAULT_SORT = '__view_default__' +const GITHUB_REPO_CONCURRENCY = 3 +const MAX_RENDERED_PR_DIFF_LINES = 400 +const GITLAB_PER_PAGE = 50 +const LINEAR_LIMIT = 50 +// 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 +// Why: the mobile detail drawer should support quick triage and core actions. +// Desktop keeps the broad metadata editing surface for dense issue/PR work. +const SHOW_MOBILE_DETAIL_LABEL_CHIPS = false +const SHOW_MOBILE_DETAIL_METADATA_EDITORS = false +const SHOW_MOBILE_DETAIL_REVIEW_PANELS = false +const SHOW_MOBILE_LINEAR_DETAIL_TOOLS = false +const SHOW_MOBILE_COMMENT_THREAD_TOOLS = false +const SHOW_MOBILE_PROJECT_METADATA_EDITORS = false +const SHOW_MOBILE_PROJECT_REVIEW_PANELS = false +const EMPTY_GITHUB_PROJECT_SETTINGS: GitHubProjectSettings = { + pinned: [], + recent: [], + lastViewByProject: {}, + activeProject: null +} + +function isSuccess(response: unknown): response is RpcSuccess { + return Boolean(response && typeof response === 'object' && (response as RpcSuccess).ok) +} + +function taskTime(value: string): number { + const time = Date.parse(value) + return Number.isFinite(time) ? time : 0 +} + +function formatUpdatedAt(value: string): string { + const time = taskTime(value) + if (!time) { + return '' + } + const minutes = Math.max(0, Math.floor((Date.now() - time) / 60_000)) + if (minutes < 60) { + return `${minutes}m` + } + const hours = Math.floor(minutes / 60) + if (hours < 24) { + return `${hours}h` + } + return `${Math.floor(hours / 24)}d` +} + +function getTaskPresetQuery(preset: GitHubPreset): string { + switch (preset) { + case 'my-issues': + return 'assignee:@me is:issue is:open' + case 'prs': + return 'is:pr is:open' + case 'my-prs': + return 'author:@me is:pr is:open' + case 'review': + return 'review-requested:@me is:pr is:open' + case 'issues': + default: + return 'is:issue is:open' + } +} + +function isTaskProvider(value: unknown): value is TaskProvider { + return value === 'github' || value === 'gitlab' || value === 'linear' +} + +function normalizeGitHubPreset(value: unknown): GitHubPreset { + return value === 'my-issues' || + value === 'prs' || + value === 'my-prs' || + value === 'review' || + value === 'issues' + ? value + : 'issues' +} + +function normalizeLinearFilter(value: unknown): LinearFilter { + return value === 'assigned' || value === 'created' || value === 'completed' || value === 'all' + ? value + : 'all' +} + +function githubKindFromQuery(query: string, fallbackPreset: GitHubPreset): GitHubTaskKind { + if (/\bis:pr\b/i.test(query)) { + return 'prs' + } + if (/\bis:issue\b/i.test(query)) { + return 'issues' + } + return fallbackPreset === 'prs' || fallbackPreset === 'my-prs' || fallbackPreset === 'review' + ? 'prs' + : 'issues' +} + +function projectRowType(row: GitHubProjectRow): 'issue' | 'pr' | null { + if (row.itemType === 'ISSUE') { + return 'issue' + } + if (row.itemType === 'PULL_REQUEST') { + return 'pr' + } + return null +} + +function canCreateWorkspaceFromProjectRow(row: GitHubProjectRow): boolean { + // Why: desktop only exposes Project "Start work" for backed issue/PR rows + // with enough GitHub identity to build the linked work item. + return projectRowType(row) !== null && row.content.number != null && Boolean(row.content.url) +} + +function splitRepositorySlug(slug: string | null): { owner: string; repo: string } | null { + const [owner, repo] = slug?.split('/') ?? [] + return owner && repo ? { owner, repo } : null +} + +function projectRowGitHubRepository(row: GitHubProjectRow, host: string): GitHubOwnerRepo | null { + const slug = splitRepositorySlug(row.content.repository) + return slug ? { ...slug, host } : null +} + +const GITHUB_PROJECT_OPTION_COLORS: Record = { + GRAY: '#8b949e', + RED: '#f85149', + ORANGE: '#db6d28', + YELLOW: '#d29922', + GREEN: '#3fb950', + BLUE: '#58a6ff', + PURPLE: '#bc8cff', + PINK: '#db61a2' +} + +function githubProjectOptionColor(color: string | null | undefined): string { + if (!color) { + return colors.textMuted + } + const upper = color.toUpperCase() + const mapped = GITHUB_PROJECT_OPTION_COLORS[upper] + if (mapped) { + return mapped + } + const hex = color.startsWith('#') ? color : `#${color}` + return /^#[0-9a-fA-F]{6}$/.test(hex) ? hex : colors.textMuted +} + +function projectRowStatusLabel(row: GitHubProjectRow): string { + if (row.itemType === 'DRAFT_ISSUE') { + return 'Draft' + } + if (row.itemType === 'REDACTED') { + return 'Redacted' + } + if (row.content.isDraft) { + return 'Draft' + } + if (row.content.state === 'MERGED') { + return 'Merged' + } + if (row.content.state === 'CLOSED') { + return 'Closed' + } + return 'Open' +} + +function scopeGitHubTaskSearch(query: string, kind: GitHubTaskKind): string { + const trimmed = query.trim() + if (!trimmed) { + return getTaskPresetQuery(kind === 'prs' ? 'prs' : 'issues') + } + if (/\bis:(?:issue|pr)\b/i.test(trimmed)) { + return trimmed + } + return `${kind === 'prs' ? 'is:pr' : 'is:issue'} ${trimmed}` +} + +function gitHubStatusLabel(item: GitHubWorkItem): string { + if (item.state === 'merged') { + return 'Merged' + } + if (item.state === 'draft') { + return 'Draft' + } + return item.state === 'closed' ? 'Closed' : 'Open' +} + +function gitHubTaskSubtitle(item: GitHubWorkItem): string { + return `${item.repoName} ${item.type === 'pr' ? '#' : '#'}${item.number}` +} + +function createGitHubTask(repo: RepoSummary, item: Omit) { + const source: GitHubWorkItem = { ...item, repoId: repo.id, repoName: repo.displayName } + return { + key: `github:${repo.id}:${item.type}:${item.number}`, + provider: 'github' as const, + title: item.title, + subtitle: gitHubTaskSubtitle(source), + status: gitHubStatusLabel(source), + updatedAt: item.updatedAt, + source + } +} + +function gitLabStatusLabel(item: GitLabWorkItem): string { + if (item.state === 'opened') { + return 'Open' + } + if (item.state === 'merged') { + return 'Merged' + } + if (item.state === 'draft') { + return 'Draft' + } + return item.state === 'closed' ? 'Closed' : 'Locked' +} + +function createGitLabTask(repo: RepoSummary, item: Omit) { + const source: GitLabWorkItem = { ...item, repoId: repo.id, repoName: repo.displayName } + return { + key: `gitlab:${repo.id}:${item.type}:${item.number}`, + provider: 'gitlab' as const, + title: item.title, + subtitle: `${repo.displayName} ${item.type === 'mr' ? '!' : '#'}${item.number}`, + status: gitLabStatusLabel(source), + updatedAt: item.updatedAt, + source + } +} + +function gitLabTodoTargetLabel(todo: Pick): string { + if (todo.targetType === 'MergeRequest') { + return 'Merge request' + } + if (todo.targetType === 'Issue') { + return 'Issue' + } + return 'GitLab todo' +} + +function gitLabTodoTargetRef(todo: Pick): string { + if (!todo.targetIid) { + return '' + } + if (todo.targetType === 'MergeRequest') { + return `!${todo.targetIid}` + } + if (todo.targetType === 'Issue') { + return `#${todo.targetIid}` + } + return String(todo.targetIid) +} + +function createGitLabTodoTask(todo: GitLabTodo): TaskItem { + const targetRef = gitLabTodoTargetRef(todo) + return { + key: `gitlab-todo:${todo.id}`, + provider: 'gitlabTodo', + title: todo.targetTitle || todo.targetUrl, + subtitle: `${todo.projectPath}${targetRef ? ` ${targetRef}` : ''}`, + status: todo.actionName.replace(/_/g, ' ') || 'Todo', + updatedAt: todo.updatedAt, + source: todo + } +} + +async function mapWithConcurrency( + items: T[], + limit: number, + worker: (item: T) => Promise +): Promise { + const results: R[] = [] + let nextIndex = 0 + async function run(): Promise { + while (nextIndex < items.length) { + const index = nextIndex + nextIndex += 1 + results[index] = await worker(items[index]!) + } + } + await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => run())) + return results +} + +function createLinearTask(issue: LinearIssue): TaskItem { + return { + key: `linear:${issue.workspaceId ?? 'workspace'}:${issue.id}`, + provider: 'linear', + title: issue.title, + subtitle: `${issue.identifier} · ${issue.team.name}`, + status: issue.state.name, + updatedAt: issue.updatedAt, + source: issue + } +} + +const LINEAR_PRIORITY_LABELS: Record = { + 0: 'None', + 1: 'Urgent', + 2: 'High', + 3: 'Medium', + 4: 'Low' +} + +function getLinearPriorityLabel(priority: number): string { + return LINEAR_PRIORITY_LABELS[priority] ?? `P${priority}` +} + +function getLinearPriorityRank(priority: number): number { + return priority === 0 ? 5 : priority +} + +function formatGitHubReviewState(state: string | null | undefined): string { + switch (state) { + case 'APPROVED': + return 'Approved' + case 'CHANGES_REQUESTED': + return 'Changes requested' + case 'COMMENTED': + return 'Commented' + case 'DISMISSED': + return 'Dismissed' + case 'PENDING': + return 'Pending' + default: + return 'Reviewed' + } +} + +function getGitHubReviewerRows(item: { + reviewRequests?: GitHubAssignableUser[] + latestReviews?: GitHubPRReviewSummary[] +}): GitHubPRReviewerRow[] { + const byLogin = new Map() + for (const user of item.reviewRequests ?? []) { + const login = user.login.trim() + if (!login) { + continue + } + byLogin.set(login.toLowerCase(), { + login, + name: user.name, + avatarUrl: user.avatarUrl, + stateLabel: 'Requested' + }) + } + for (const review of item.latestReviews ?? []) { + const login = review.login.trim() + const key = login.toLowerCase() + if (!login || byLogin.has(key)) { + continue + } + byLogin.set(key, { + login, + name: null, + avatarUrl: review.avatarUrl, + stateLabel: formatGitHubReviewState(review.state) + }) + } + return Array.from(byLogin.values()) +} + +function getGitHubReviewSummary(item: { + reviewDecision?: string | null + reviewRequests?: GitHubAssignableUser[] + latestReviews?: GitHubPRReviewSummary[] +}): string { + if (item.reviewDecision === 'APPROVED') { + return 'Approved' + } + if (item.reviewDecision === 'CHANGES_REQUESTED') { + return 'Changes requested' + } + const rows = getGitHubReviewerRows(item) + if (rows.length === 0) { + return 'No reviewers' + } + if (rows.length === 1) { + return `${rows[0]!.login} - ${rows[0]!.stateLabel}` + } + return `${rows[0]!.login} +${rows.length - 1}` +} + +function formatGitHubPRDelta(item: GitHubWorkItem): string | null { + const parts: string[] = [] + if (typeof item.additions === 'number') { + parts.push(`+${item.additions}`) + } + if (typeof item.deletions === 'number') { + parts.push(`-${item.deletions}`) + } + if (typeof item.changedFiles === 'number') { + parts.push(`${item.changedFiles} ${item.changedFiles === 1 ? 'file' : 'files'}`) + } + return parts.length > 0 ? parts.join(' ') : null +} + +function hostedBranchSummary(item: TaskItem): { head: string; base: string } | null { + if (item.provider === 'github' && item.source.type === 'pr') { + return { + head: item.source.branchName?.trim() || 'unknown head', + base: item.source.baseRefName?.trim() || 'base' + } + } + if (item.provider === 'gitlab' && item.source.type === 'mr') { + return { + head: item.source.branchName?.trim() || 'unknown head', + base: item.source.baseRefName?.trim() || 'base' + } + } + return null +} + +function getGitHubMergeLabel(item: GitHubWorkItem): string { + if (item.mergeable === undefined && item.mergeStateStatus === undefined) { + return 'Merge' + } + if (item.state === 'merged') { + return 'Merged' + } + if (item.state === 'closed') { + return 'Closed' + } + if (item.mergeable === 'CONFLICTING') { + return 'Conflicts' + } + if (item.mergeStateStatus === 'BEHIND') { + return 'Behind' + } + if (item.mergeStateStatus === 'BLOCKED') { + return 'Blocked' + } + if (item.mergeable === 'MERGEABLE' || item.mergeStateStatus === 'CLEAN') { + return 'Able to merge' + } + return 'Unknown' +} + +function getHostedReviewMergeMethodLabel(method: HostedReviewMergeMethod): string { + if (method === 'squash') { + return 'Squash and merge' + } + if (method === 'rebase') { + return 'Rebase and merge' + } + return 'Create merge commit' +} + +function hostedReviewMergeTargetLabel(item: HostedReviewItem): string { + return item.provider === 'gitlab' ? 'merge request' : 'PR' +} + +function getHostedMergeConfirmMessage(pending: PendingHostedMerge): string { + const target = hostedReviewMergeTargetLabel(pending.item) + if (pending.method === 'squash') { + return `Squash and merge ${target} #${pending.item.source.number}?` + } + const action = pending.method === 'rebase' ? 'Rebase and merge' : 'Merge' + return `${action} ${target} #${pending.item.source.number}?` +} + +function getProjectGitHubMergeConfirmMessage(pending: PendingProjectGitHubMerge): string { + const number = pending.row.content.number + if (pending.method === 'squash') { + return `Squash and merge PR #${number}?` + } + const action = pending.method === 'rebase' ? 'Rebase and merge' : 'Merge' + return `${action} PR #${number}?` +} + +function hostedStateChangeAction(nextState: PendingHostedStateChange['nextState']): string { + return nextState === 'closed' ? 'Close' : 'Reopen' +} + +function hostedStateChangeTarget(pending: PendingHostedStateChange): { + titleTarget: string + labelTarget: string + number: number | null +} { + if (pending.source === 'project') { + const type = projectRowType(pending.row) + return { + titleTarget: type === 'pr' ? 'Pull Request' : 'Issue', + labelTarget: type === 'pr' ? 'PR' : 'Issue', + number: pending.row.content.number + } + } + if (pending.item.provider === 'gitlab') { + return { + titleTarget: pending.item.source.type === 'mr' ? 'Merge Request' : 'Issue', + labelTarget: pending.item.source.type === 'mr' ? 'MR' : 'Issue', + number: pending.item.source.number + } + } + return { + titleTarget: pending.item.source.type === 'pr' ? 'Pull Request' : 'Issue', + labelTarget: pending.item.source.type === 'pr' ? 'PR' : 'Issue', + number: pending.item.source.number + } +} + +function getHostedStateConfirmTitle(pending: PendingHostedStateChange): string { + const target = hostedStateChangeTarget(pending) + return `${hostedStateChangeAction(pending.nextState)} ${target.titleTarget}` +} + +function getHostedStateConfirmMessage(pending: PendingHostedStateChange): string { + const target = hostedStateChangeTarget(pending) + return `${hostedStateChangeAction(pending.nextState)} ${target.labelTarget} #${target.number}?` +} + +function getHostedStateConfirmLabel(pending: PendingHostedStateChange): string { + const target = hostedStateChangeTarget(pending) + return `${hostedStateChangeAction(pending.nextState)} ${target.labelTarget}` +} + +function mergeGitHubAssignableUsers( + users: GitHubAssignableUser[], + seeds: GitHubAssignableUser[] +): GitHubAssignableUser[] { + const byLogin = new Map() + for (const user of [...users, ...seeds]) { + const login = user.login.trim() + if (!login || byLogin.has(login.toLowerCase())) { + continue + } + byLogin.set(login.toLowerCase(), { ...user, login }) + } + return [...byLogin.values()] +} + +function getGitHubReviewerSeedUsers(item: { + reviewRequests?: GitHubAssignableUser[] + latestReviews?: GitHubPRReviewSummary[] + author?: string | null +}): GitHubAssignableUser[] { + const byLogin = new Map() + const add = (user: GitHubAssignableUser): void => { + const login = user.login.trim() + if (!login || byLogin.has(login.toLowerCase())) { + return + } + byLogin.set(login.toLowerCase(), { ...user, login }) + } + for (const user of item.reviewRequests ?? []) { + add(user) + } + for (const review of item.latestReviews ?? []) { + add({ + login: review.login, + name: null, + avatarUrl: review.avatarUrl ?? null + }) + } + if (item.author) { + add({ login: item.author, name: null, avatarUrl: null }) + } + return [...byLogin.values()] +} + +function sameGitHubOwnerRepo( + a: GitHubOwnerRepo | null | undefined, + b: GitHubOwnerRepo | null | undefined +): boolean { + return ( + !!a && + !!b && + a.owner.toLowerCase() === b.owner.toLowerCase() && + a.repo.toLowerCase() === b.repo.toLowerCase() + ) +} + +function hasGitHubIssueSourceChoice(sources: GitHubRepoSources | undefined): boolean { + return Boolean( + sources?.prs && + sources.upstreamCandidate && + !sameGitHubOwnerRepo(sources.prs, sources.upstreamCandidate) + ) +} + +function issueSourceSlug(source: GitHubOwnerRepo | null | undefined): string { + return source ? `${source.owner}/${source.repo}` : 'Unknown' +} + +function compareLinearIssues(a: LinearIssue, b: LinearIssue, orderBy: LinearOrderBy): number { + if (orderBy === 'updated') { + return taskTime(b.updatedAt) - taskTime(a.updatedAt) + } + if (orderBy === 'identifier') { + return a.identifier.localeCompare(b.identifier, undefined, { numeric: true }) + } + const priorityDelta = getLinearPriorityRank(a.priority) - getLinearPriorityRank(b.priority) + return priorityDelta || taskTime(b.updatedAt) - taskTime(a.updatedAt) +} + +function getLinearIssueGroup( + issue: LinearIssue, + groupBy: LinearGroupBy +): { + key: string + label: string + color: string +} { + if (groupBy === 'status') { + return { key: `status:${issue.state.name}`, label: issue.state.name, color: issue.state.color } + } + if (groupBy === 'assignee') { + return { + key: `assignee:${issue.assignee?.id ?? issue.assignee?.displayName ?? 'unassigned'}`, + label: issue.assignee?.displayName ?? 'Unassigned', + color: colors.accentBlue + } + } + if (groupBy === 'priority') { + return { + key: `priority:${issue.priority}`, + label: getLinearPriorityLabel(issue.priority), + color: issue.priority === 1 ? colors.statusRed : colors.accentBlue + } + } + if (groupBy === 'team') { + return { key: `team:${issue.team.id}`, label: issue.team.name, color: issue.state.color } + } + return { key: 'all', label: 'Issues', color: colors.accentBlue } +} + +function groupLinearIssues( + issues: LinearIssue[], + groupBy: LinearGroupBy, + orderBy: LinearOrderBy +): LinearIssueSection[] { + const sorted = [...issues].sort((a, b) => compareLinearIssues(a, b, orderBy)) + if (groupBy === 'none') { + return [{ key: 'all', label: 'Issues', color: colors.accentBlue, issues: sorted }] + } + const sections = new Map< + string, + { key: string; label: string; color: string; issues: LinearIssue[] } + >() + for (const issue of sorted) { + const group = getLinearIssueGroup(issue, groupBy) + const section = sections.get(group.key) + if (section) { + section.issues.push(issue) + } else { + sections.set(group.key, { ...group, issues: [issue] }) + } + } + return [...sections.values()] +} + +function linearIssueSecondaryParts( + issue: LinearIssue, + displayProperties: ReadonlySet +): string[] { + const parts = [issue.identifier] + if (displayProperties.has('priority')) { + parts.push(getLinearPriorityLabel(issue.priority)) + } + if (displayProperties.has('assignee') && issue.assignee?.displayName) { + parts.push(issue.assignee.displayName) + } + if (displayProperties.has('team')) { + parts.push(issue.team.name) + } + if (displayProperties.has('labels') && issue.labels.length > 0) { + parts.push(issue.labels.slice(0, 2).join(', ')) + } + if (displayProperties.has('updated')) { + parts.push(formatUpdatedAt(issue.updatedAt)) + } + return parts +} + +function reconcileTeamSelection( + teams: LinearTeam[], + saved: string[] | null | undefined +): Set { + if (!saved) { + return new Set(teams.map((team) => team.id)) + } + const available = new Set(teams.map((team) => team.id)) + const next = new Set(saved.filter((id) => available.has(id))) + return next.size === 0 ? new Set(teams.map((team) => team.id)) : next +} + +function splitCommaList(value: string): string[] { + return value + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean) +} + +function splitReviewerList(value: string): string[] { + return value + .split(/[\s,]+/) + .map((entry) => entry.trim()) + .filter(Boolean) +} + +function editableProjectFields(table: GitHubProjectTable | null): GitHubProjectField[] { + return ( + table?.selectedView.fields?.filter((field) => + ['TEXT', 'NUMBER', 'DATE', 'SINGLE_SELECT', 'ITERATION'].includes(field.dataType) + ) ?? [] + ) +} + +function projectFieldValueLabel(row: GitHubProjectRow, field: GitHubProjectField): string { + const value = row.fieldValuesByFieldId?.[field.id] + if (!value) { + return 'Empty' + } + if (value.kind === 'single-select') { + return value.name + } + if (value.kind === 'iteration') { + return value.title + } + if (value.kind === 'text') { + return value.text || 'Empty' + } + if (value.kind === 'number') { + return String(value.number) + } + if (value.kind === 'date') { + return value.date + } + if (value.kind === 'labels') { + return value.labels.map((label) => label.name).join(', ') || 'Empty' + } + if (value.kind === 'users') { + return value.users.map((user) => user.login).join(', ') || 'Empty' + } + return 'Empty' +} + +function projectFieldDisplayLabel(row: GitHubProjectRow, field: GitHubProjectField): string { + if (field.dataType === 'ASSIGNEES') { + return row.content.assignees.map((user) => user.login).join(', ') || 'Empty' + } + if (field.dataType === 'LABELS') { + return row.content.labels.map((label) => label.name).join(', ') || 'Empty' + } + if (field.dataType === 'REPOSITORY') { + return row.content.repository ?? 'Empty' + } + if (field.dataType === 'PARENT_ISSUE') { + return row.content.parentIssue ? `#${row.content.parentIssue.number}` : 'Empty' + } + if (field.dataType === 'ISSUE_TYPE') { + return row.content.issueType?.name ?? 'Empty' + } + if (field.dataType === 'TITLE') { + return row.content.title + } + return projectFieldValueLabel(row, field) +} + +function projectSummaryFields(table: GitHubProjectTable | null): GitHubProjectField[] { + return ( + table?.selectedView.fields?.filter( + (field) => field.dataType !== 'TITLE' && field.dataType !== 'REPOSITORY' + ) ?? [] + ) +} + +function projectFieldVisibilityKey(table: GitHubProjectTable | null): string | null { + if (!table) { + return null + } + // Why: desktop scopes column visibility to project + view; matching that + // avoids hiding fields across unrelated Project views with colliding IDs. + return `${table.project.id}:${table.selectedView.id}` +} + +function projectFieldDraftValue(row: GitHubProjectRow, field: GitHubProjectField): string { + const value = row.fieldValuesByFieldId?.[field.id] + if (!value) { + return '' + } + if (value.kind === 'text') { + return value.text + } + if (value.kind === 'number') { + return String(value.number) + } + if (value.kind === 'date') { + return value.date + } + return '' +} + +function normalizeProjectTableForMobileSort( + table: GitHubProjectTable, + rows: GitHubProjectRow[], + sortOverride: ProjectSortOverride | null +): SharedGitHubProjectTable { + const fields = table.selectedView.fields ?? [] + const overrideField = sortOverride + ? fields.find((field) => field.id === sortOverride.fieldId) + : undefined + const normalizedRows = rows.map((row, index) => ({ + ...row, + content: { + ...row.content, + stateReason: row.content.stateReason ?? null, + parentIssue: row.content.parentIssue ?? null, + issueType: row.content.issueType ?? null + }, + fieldValuesByFieldId: row.fieldValuesByFieldId ?? {}, + position: row.position ?? index + })) + + return { + ...table, + selectedView: { + ...table.selectedView, + fields, + groupByFields: table.selectedView.groupByFields ?? [], + sortByFields: + sortOverride && overrideField + ? [{ field: overrideField, direction: sortOverride.direction }] + : (table.selectedView.sortByFields ?? []) + }, + rows: normalizedRows, + parentFieldDropped: table.parentFieldDropped === true + } as unknown as SharedGitHubProjectTable +} + +function projectGroupMeta(group: ProjectGroup): string { + const parts = [`${group.rows.length}`] + if (group.iteration) { + const endDate = new Date(`${group.iteration.startDate}T00:00:00Z`) + if (!Number.isNaN(endDate.getTime())) { + endDate.setUTCDate(endDate.getUTCDate() + group.iteration.duration - 1) + parts.push(`${group.iteration.startDate} - ${endDate.toISOString().slice(0, 10)}`) + } + if (isIterationCurrent(group.iteration)) { + parts.push('Current') + } + } + return parts.join(' · ') +} + +function optimisticProjectFieldValue( + field: GitHubProjectField, + value: GitHubProjectFieldMutationValue +): GitHubProjectFieldValue { + if (value.kind === 'single-select' && field.kind === 'single-select') { + const option = field.options.find((entry) => entry.id === value.optionId) + return { + kind: 'single-select', + fieldId: field.id, + optionId: value.optionId, + name: option?.name ?? 'Selected', + color: option?.color ?? 'GRAY' + } + } + if (value.kind === 'iteration' && field.kind === 'iteration') { + const iteration = field.iterations.find((entry) => entry.id === value.iterationId) + return { + kind: 'iteration', + fieldId: field.id, + iterationId: value.iterationId, + title: iteration?.title ?? 'Iteration', + startDate: iteration?.startDate ?? '', + duration: iteration?.duration ?? 0 + } + } + if (value.kind === 'number') { + return { kind: 'number', fieldId: field.id, number: value.number } + } + if (value.kind === 'date') { + return { kind: 'date', fieldId: field.id, date: value.date } + } + return { kind: 'text', fieldId: field.id, text: value.kind === 'text' ? value.text : '' } +} + +function taskKindLabel(item: TaskItem): string { + if (item.provider === 'github') { + return item.source.type === 'pr' ? 'Pull request' : 'Issue' + } + if (item.provider === 'gitlab') { + return item.source.type === 'mr' ? 'Merge request' : 'Issue' + } + if (item.provider === 'gitlabTodo') { + return `${gitLabTodoTargetLabel(item.source)} todo` + } + return 'Linear ticket' +} + +function taskExternalOpenLabel(item: TaskItem): string { + if (item.provider === 'github') { + return 'Open in GitHub' + } + if (item.provider === 'gitlab' || item.provider === 'gitlabTodo') { + return 'Open in GitLab' + } + return 'Open in Linear' +} + +function taskStatusActionLabel(item: TaskItem): string { + const verb = + item.provider === 'github' || item.provider === 'gitlab' + ? item.source.state === 'closed' + ? 'Reopen' + : 'Close' + : '' + return verb ? `${verb} ${taskKindLabel(item).toLowerCase()}` : '' +} + +function isGitHubPrMergeBlocked(item: Extract): boolean { + return item.source.type === 'pr' && item.source.mergeable === 'CONFLICTING' +} + +function commentAuthor(comment: DetailComment): string { + return comment.author ?? comment.user?.displayName ?? 'unknown' +} + +function commentDate(value: string | undefined): string { + if (!value) { + return '' + } + const time = Date.parse(value) + return Number.isFinite(time) ? new Date(time).toLocaleDateString() : '' +} + +function formatDurationSeconds(value: number | null | undefined): string { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return '' + } + const seconds = Math.max(0, Math.floor(value)) + if (seconds >= 60) { + return `${Math.floor(seconds / 60)}m ${seconds % 60}s` + } + return `${seconds}s` +} + +function commentSourceLabel(comment: DetailComment): string { + if (comment.path) { + const line = + typeof comment.line === 'number' + ? typeof comment.startLine === 'number' && comment.startLine !== comment.line + ? `${comment.startLine}-${comment.line}` + : String(comment.line) + : '' + const location = line ? `${comment.path}:${line}` : comment.path + return `${comment.isResolved ? 'Resolved review' : 'Review'} · ${location}` + } + if (comment.threadId) { + return comment.isResolved ? 'Resolved review thread' : 'Review thread' + } + return 'Top-level comment' +} + +function groupDetailComments(comments: DetailComment[]): DetailCommentGroup[] { + const threads = new Map() + const groups: DetailCommentGroup[] = [] + const emittedThreads = new Set() + + for (const comment of comments) { + if (!comment.threadId) { + continue + } + const existing = threads.get(comment.threadId) + if (existing) { + existing.replies.push(comment) + } else { + threads.set(comment.threadId, { root: comment, replies: [] }) + } + } + + for (const comment of comments) { + if (!comment.threadId) { + groups.push({ kind: 'standalone', comment }) + continue + } + if (emittedThreads.has(comment.threadId)) { + continue + } + emittedThreads.add(comment.threadId) + const thread = threads.get(comment.threadId) + if (thread) { + groups.push({ kind: 'thread', threadId: comment.threadId, ...thread }) + } + } + + return groups +} + +function detailCommentGroupId(group: DetailCommentGroup): string { + return group.kind === 'thread' ? `thread:${group.threadId}` : `comment:${group.comment.id}` +} + +function detailCommentGroupRoot(group: DetailCommentGroup): DetailComment { + return group.kind === 'thread' ? group.root : group.comment +} + +function detailCommentGroupCount(group: DetailCommentGroup): number { + return group.kind === 'thread' ? 1 + group.replies.length : 1 +} + +function isResolvedDetailCommentGroup(group: DetailCommentGroup): boolean { + return detailCommentGroupRoot(group).isResolved === true +} + +function discussionSummary(count: number): string { + if (count === 0) { + return 'No comments yet' + } + return `${count} ${count === 1 ? 'comment' : 'comments'}` +} + +function renderCommentReactions(comment: DetailComment): ReactNode { + const reactions = (comment.reactions ?? []).filter((reaction) => reaction.count > 0) + if (reactions.length === 0) { + return null + } + return ( + + {reactions.map((reaction) => ( + + + {COMMENT_REACTION_EMOJI[reaction.content]} {reaction.count} + + + ))} + + ) +} + +function formatDiffLineNumber(value: number | undefined): string { + return value === undefined ? ' ' : value.toString().padStart(4, ' ') +} + +function diffLinePrefix(kind: GitHubPrFileDiffLine['kind']): string { + if (kind === 'added') { + return '+' + } + if (kind === 'removed') { + return '-' + } + return ' ' +} + +function GitHubPrFileDiff({ + filePath, + contents, + commentDrafts, + disabled, + onCommentDraftChange, + onSubmitComment +}: { + filePath: string + contents: GitHubPRFileContents + commentDrafts: Record + disabled: boolean + onCommentDraftChange: (key: string, value: string) => void + onSubmitComment: (line: number) => void +}): ReactNode { + const diffPreview = useMemo( + () => + buildGitHubPrFileDiffPreview( + contents.original, + contents.modified, + MAX_RENDERED_PR_DIFF_LINES + ), + [contents.modified, contents.original] + ) + const syntaxLanguage = useMemo(() => resolveMobileSyntaxLanguage(filePath), [filePath]) + const visibleDiffLines = useMemo( + () => highlightMobileDiffLines(diffPreview.lines, syntaxLanguage), + [diffPreview.lines, syntaxLanguage] + ) + const hiddenDiffLineCount = Math.max(0, diffPreview.totalLineCount - visibleDiffLines.length) + + if (diffPreview.totalLineCount === 0) { + return No text changes found. + } + + return ( + + {hiddenDiffLineCount > 0 ? ( + + Showing first {MAX_RENDERED_PR_DIFF_LINES} of {diffPreview.totalLineCount} diff lines. + + ) : null} + {visibleDiffLines.map((line) => { + const commentLine = line.kind === 'removed' ? undefined : line.newLineNumber + const draftKey = commentLine === undefined ? '' : `${filePath}:${commentLine}` + return ( + + + + {formatDiffLineNumber(line.oldLineNumber)}{' '} + {formatDiffLineNumber(line.newLineNumber)} + + + {diffLinePrefix(line.kind)} + + {line.text ? null : ' '} + + + {commentLine !== undefined ? ( + <> + onCommentDraftChange(draftKey, next)} + placeholder="Add review comment" + placeholderTextColor={colors.textMuted} + multiline + textAlignVertical="top" + /> + onSubmitComment(commentLine)} + > + Comment on line {commentLine} + + + ) : null} + + ) + })} + + ) +} + +function isFailedGitHubCheck(check: { conclusion?: string | null }): boolean { + return ['failure', 'cancelled', 'timed_out'].includes(check.conclusion ?? '') +} + +function repositoryCount(count: number): string { + return `${count} ${count === 1 ? 'repository' : 'repositories'}` +} + +function buildPartialRepositoryNotice(failedCount: number, totalCount: number): string { + return `${failedCount} of ${repositoryCount(totalCount)} failed to load.` +} + +function repoColor(name: string): string { + const palette = ['#f97316', '#8b5cf6', '#06b6d4', '#ec4899', '#84cc16', '#f59e0b', '#6366f1'] + let hash = 0 + for (let i = 0; i < name.length; i += 1) { + hash = (hash * 31 + name.charCodeAt(i)) | 0 + } + return palette[Math.abs(hash) % palette.length]! +} + +function getRepoBadgeColor(repo: RepoSummary | undefined, fallbackName: string): string { + return repo?.badgeColor || repoColor(repo?.displayName ?? fallbackName) +} + +function setupSourceLabel(source: string | null): string { + if (source === 'orca.yaml') { + return 'orca.yaml' + } + if (source === 'legacy') { + return 'local hooks' + } + return 'repository hooks' +} + +function taskRepositoryMeta( + item: TaskItem, + reposById: Map +): { key: string; label: string; color: string } { + if (item.provider === 'github' || item.provider === 'gitlab') { + const repo = reposById.get(item.source.repoId) + return { + key: item.source.repoId, + label: repo?.displayName ?? item.source.repoName, + color: getRepoBadgeColor(repo, item.source.repoName) + } + } + if (item.provider === 'gitlabTodo') { + return { + key: item.source.projectPath, + label: item.source.projectPath, + color: repoColor(item.source.projectPath) + } + } + return { + key: item.source.team.id, + label: item.source.team.name, + color: item.source.state.color || colors.accentBlue + } +} + +function compareTasksByUpdated(a: TaskItem, b: TaskItem): number { + return taskTime(b.updatedAt) - taskTime(a.updatedAt) +} + +function compareTasksByRepository( + a: TaskItem, + b: TaskItem, + reposById: Map +): number { + const aRepo = taskRepositoryMeta(a, reposById) + const bRepo = taskRepositoryMeta(b, reposById) + const repoComparison = aRepo.label.localeCompare(bRepo.label, undefined, { sensitivity: 'base' }) + return repoComparison || compareTasksByUpdated(a, b) +} export default function MobileTasksScreen() { - const stage1 = useMobileTasksRouteAndItemState() - const stage2 = useMobileTasksWorkspaceAndProjectState(stage1) - const stage3 = useMobileTasksProjectProjection(stage2) - const stage4 = useMobileTasksProjectRepositoryResolution(stage3) - const stage5 = useMobileTasksClientSettingsActions(stage4) - const stage6 = useMobileTasksRuntimeHydration(stage5) - const stage7 = useMobileTasksProviderLoadActions(stage6) - const stage8 = useMobileTasksTaskListLoading(stage7) - const stage9 = useMobileTasksTaskPaginationActions(stage8) - const stage10 = useMobileTasksProjectLoadingActions(stage9) - const stage11 = useMobileTasksListAndDetailEffects(stage10) - const stage12 = useMobileTasksItemDetailMetadataEffects(stage11) - const stage13 = useMobileTasksItemDetailLoading(stage12) - const stage14 = useMobileTasksProjectDetailLoading(stage13) - const stage15 = useMobileTasksProjectMetadataLoading(stage14) - const stage16 = useMobileTasksWorkspaceCreateProjection(stage15) - const stage17 = useMobileTasksWorkspaceSourceEffects(stage16) - const stage18 = useMobileTasksWorkspaceSparseActions(stage17) - const stage19 = useMobileTasksWorkspaceSshState(stage18) - const stage20 = useMobileTasksWorkspaceCreateActions(stage19) - const stage21 = useMobileTasksProjectWorkspaceCommentActions(stage20) - const stage22 = useMobileTasksProjectThreadReplyActions(stage21) - const stage23 = useMobileTasksProjectMetadataActions(stage22) - const stage24 = useMobileTasksProjectReviewCheckActions(stage23) - const stage25 = useMobileTasksProjectFileMergeActions(stage24) - const stage26 = useMobileTasksGitlabGithubStatusActions(stage25) - const stage27 = useMobileTasksHostedMetadataActions(stage26) - const stage28 = useMobileTasksHostedCommentReviewActions(stage27) - const stage29 = useMobileTasksGithubCheckFileActions(stage28) - const stage30 = useMobileTasksGithubReplyMergeActions(stage29) - const stage31 = useMobileTasksLinearItemActions(stage30) - const stage32 = useMobileTasksTaskCreateActions(stage31) - const stage33 = useMobileTasksDetailCommentRenderers(stage32) - const stage34 = useMobileTasksPickerProjection(stage33) - const stage35 = useMobileTasksProviderViewProjection(stage34) - const stage36 = useMobileTasksConnectionPresentation(stage35) - return MobileTasksLegacySurface({ model: stage36 }) + const { hostId, taskSource } = useLocalSearchParams<{ hostId: string; taskSource?: string }>() + const router = useRouter() + const insets = useSafeAreaInsets() + const { client, state: connState } = useHostClient(hostId) + const reconnectAttempts = useReconnectAttempt(hostId) + const lastConnectedAt = useLastConnectedAt(hostId) + const relayRecovery = useRelayRecoveryStatus(hostId) + const clientRef = useRef(null) + const loadGenerationRef = useRef(0) + const taskResumeRef = useRef({}) + const repoList = useHostRepoList( + client, + client && connState === 'connected' + ? async () => { + const response = await client.sendRequest('repo.list') + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + return (response.result as { repos: RepoSummary[] }).repos + } + : null + ) + const repos = repoList.state.repos + const { ensureLoaded: repoListEnsureLoaded, reload: repoListReload } = repoList + const [provider, setProvider] = useState('github') + const [visibleProviders, setVisibleProviders] = useState(() => + normalizeVisibleTaskProviders(undefined) + ) + const [linearConnected, setLinearConnected] = useState(false) + const [githubMode, setGithubMode] = useState<'items' | 'project'>('items') + const [githubKind, setGithubKind] = useState('issues') + const [githubPreset, setGithubPreset] = useState('issues') + const [defaultGitHubPreset, setDefaultGitHubPreset] = useState('issues') + const [gitlabView, setGitlabView] = useState('project') + const [gitlabFilter, setGitlabFilter] = useState('opened') + const [linearFilter, setLinearFilter] = useState('all') + const [linearViewMode, setLinearViewMode] = useState('list') + const [linearGroupBy, setLinearGroupBy] = useState('none') + const [linearOrderBy, setLinearOrderBy] = useState('priority') + const [linearDisplayProperties, setLinearDisplayProperties] = useState< + ReadonlySet + >(() => new Set(DEFAULT_LINEAR_DISPLAY_PROPERTIES)) + const [linearTeamPropertyTouched, setLinearTeamPropertyTouched] = useState(false) + const [linearWorkspaces, setLinearWorkspaces] = useState([]) + const [selectedLinearWorkspaceId, setSelectedLinearWorkspaceId] = useState( + null + ) + const [selectedLinearTeamIds, setSelectedLinearTeamIds] = useState>(new Set()) + const defaultRepoSelectionRef = useRef(null) + const repoSelectionHydratedRef = useRef(false) + const defaultLinearTeamSelectionRef = useRef(null) + const [showLinearWorkspacePicker, setShowLinearWorkspacePicker] = useState(false) + const [showLinearTeamPicker, setShowLinearTeamPicker] = useState(false) + const [showLinearViewPicker, setShowLinearViewPicker] = useState(false) + const [showLinearGroupPicker, setShowLinearGroupPicker] = useState(false) + const [showLinearOrderPicker, setShowLinearOrderPicker] = useState(false) + const [showLinearDisplayPicker, setShowLinearDisplayPicker] = useState(false) + const [showLinearConnect, setShowLinearConnect] = useState(false) + const [linearApiKeyDraft, setLinearApiKeyDraft] = useState('') + const [linearConnectState, setLinearConnectState] = useState<'idle' | 'connecting' | 'error'>( + 'idle' + ) + const [linearConnectError, setLinearConnectError] = useState('') + const [taskSort, setTaskSort] = useState('updated') + const [selectedRepoIds, setSelectedRepoIds] = useState>(new Set()) + const [items, setItems] = useState([]) + const [githubPages, setGithubPages] = useState< + Array[]> + >([]) + const [githubCurrentPage, setGithubCurrentPage] = useState(0) + const [githubTotalCount, setGithubTotalCount] = useState(null) + const [githubPaginationLoading, setGithubPaginationLoading] = useState(false) + const [githubLoadingTargetPage, setGithubLoadingTargetPage] = useState(null) + const [githubRepoSources, setGithubRepoSources] = useState>({}) + const [githubSourceErrors, setGithubSourceErrors] = useState([]) + const [githubSourceFallbacks, setGithubSourceFallbacks] = useState( + [] + ) + const [retryingGithubSourceRepoPaths, setRetryingGithubSourceRepoPaths] = useState>( + new Set() + ) + const [githubRepoSlugCache, setGithubRepoSlugCache] = useState< + Record + >({}) + const [query, setQuery] = useState(getTaskPresetQuery('issues')) + const [appliedQuery, setAppliedQuery] = useState(getTaskPresetQuery('issues')) + const [showProviderPicker, setShowProviderPicker] = useState(false) + const [showGitHubKindPicker, setShowGitHubKindPicker] = useState(false) + const [showGitHubPresetPicker, setShowGitHubPresetPicker] = useState(false) + const [showGitLabViewPicker, setShowGitLabViewPicker] = useState(false) + const [showGitLabFilterPicker, setShowGitLabFilterPicker] = useState(false) + const [showLinearFilterPicker, setShowLinearFilterPicker] = useState(false) + const [showSortPicker, setShowSortPicker] = useState(false) + const [showRepoPicker, setShowRepoPicker] = useState(false) + const [showGitHubIssueSourcePicker, setShowGitHubIssueSourcePicker] = useState(false) + const [showGitHubPagePicker, setShowGitHubPagePicker] = useState(false) + const [showCreateTask, setShowCreateTask] = useState(false) + const [showCreateTargetPicker, setShowCreateTargetPicker] = useState(false) + const [createTitle, setCreateTitle] = useState('') + const [createBody, setCreateBody] = useState('') + const [createRepoId, setCreateRepoId] = useState(null) + const [createTeamId, setCreateTeamId] = useState(null) + const [linearTeams, setLinearTeams] = useState([]) + const [creatingTask, setCreatingTask] = useState(false) + const [loading, setLoading] = useState(false) + const [refreshing, setRefreshing] = useState(false) + const [tasksSupportState, setTasksSupportState] = useState({ + kind: 'unknown', + client: null + }) + const [error, setError] = useState('') + const [actionItem, setActionItem] = useState(null) + const [mergeMethodTaskItem, setMergeMethodTaskItem] = useState< + Extract | Extract | null + >(null) + const [mergeMethodProjectRow, setMergeMethodProjectRow] = useState(null) + const [pendingHostedMerge, setPendingHostedMerge] = useState(null) + const [pendingProjectGitHubMerge, setPendingProjectGitHubMerge] = + useState(null) + const [pendingHostedStateChange, setPendingHostedStateChange] = + useState(null) + const [detailPayload, setDetailPayload] = useState(null) + const [detailLoading, setDetailLoading] = useState(false) + const [detailError, setDetailError] = useState('') + const [detailRefreshSeq, setDetailRefreshSeq] = useState(0) + const [itemTitleDraft, setItemTitleDraft] = useState('') + const [itemBodyDraft, setItemBodyDraft] = useState('') + const [itemCommentDraft, setItemCommentDraft] = useState('') + const [itemAddLabelsDraft, setItemAddLabelsDraft] = useState('') + const [itemRemoveLabelsDraft, setItemRemoveLabelsDraft] = useState('') + const [itemAddAssigneesDraft, setItemAddAssigneesDraft] = useState('') + const [itemRemoveAssigneesDraft, setItemRemoveAssigneesDraft] = useState('') + const [itemAvailableLabels, setItemAvailableLabels] = useState([]) + const [itemLabelsLoading, setItemLabelsLoading] = useState(false) + const [itemLabelsError, setItemLabelsError] = useState('') + const [itemAssignableUsers, setItemAssignableUsers] = useState([]) + const [itemAssignableUsersLoading, setItemAssignableUsersLoading] = useState(false) + const [itemAssignableUsersError, setItemAssignableUsersError] = useState('') + const [itemReviewersDraft, setItemReviewersDraft] = useState('') + const [itemReplyDrafts, setItemReplyDrafts] = useState>({}) + const [expandedPrFilePath, setExpandedPrFilePath] = useState(null) + const [prFileContents, setPrFileContents] = useState>({}) + const [prFileLoadingPath, setPrFileLoadingPath] = useState(null) + const [prFileCommentDrafts, setPrFileCommentDrafts] = useState>({}) + const [copiedLinkKey, setCopiedLinkKey] = useState(null) + const copiedLinkResetTimerRef = useRef | null>(null) + const [expandedResolvedCommentGroups, setExpandedResolvedCommentGroups] = useState>( + () => new Set() + ) + const detailCommentGroups = useMemo( + () => groupDetailComments(detailPayload?.comments ?? []), + [detailPayload?.comments] + ) + const [workspaceRepoPickerItem, setWorkspaceRepoPickerItem] = useState | null>(null) + const [workspaceCreateDraft, setWorkspaceCreateDraft] = useState( + null + ) + const [workspaceNameDraft, setWorkspaceNameDraft] = useState('') + const [workspaceLastAutoName, setWorkspaceLastAutoName] = useState('') + const [workspaceBranchAutoName, setWorkspaceBranchAutoName] = useState('') + const [workspaceBranchNameOverride, setWorkspaceBranchNameOverride] = useState< + string | undefined + >(undefined) + const [workspaceBaseBranch, setWorkspaceBaseBranch] = useState(null) + const [workspaceBaseBranchQuery, setWorkspaceBaseBranchQuery] = useState('') + const [workspaceBaseBranchResults, setWorkspaceBaseBranchResults] = useState< + BaseRefSearchResult[] + >([]) + const [workspaceBaseBranchLoading, setWorkspaceBaseBranchLoading] = useState(false) + const [workspaceBaseBranchError, setWorkspaceBaseBranchError] = useState('') + const [workspaceSparsePresets, setWorkspaceSparsePresets] = useState([]) + const [workspaceSparsePresetsLoading, setWorkspaceSparsePresetsLoading] = useState(false) + const [workspaceSparsePresetsLoaded, setWorkspaceSparsePresetsLoaded] = useState(false) + const [_workspaceSparsePresetsError, setWorkspaceSparsePresetsError] = useState('') + const [workspaceSparseReloadKey, setWorkspaceSparseReloadKey] = useState(0) + const [workspaceSparsePresetId, setWorkspaceSparsePresetId] = useState(null) + const [workspaceSparseDraft, setWorkspaceSparseDraft] = useState( + null + ) + const [workspaceSparseSaving, setWorkspaceSparseSaving] = useState(false) + const [workspaceAgent, setWorkspaceAgent] = useState(null) + const [workspaceAgentOverridden, setWorkspaceAgentOverridden] = useState(false) + const [workspaceDetectedAgentIds, setWorkspaceDetectedAgentIds] = useState | null>( + null + ) + const [workspaceSshState, setWorkspaceSshState] = useState(null) + const [workspaceSshConnecting, setWorkspaceSshConnecting] = useState(false) + const [showWorkspaceAgentPicker, setShowWorkspaceAgentPicker] = useState(false) + const [showWorkspaceCreateRepoPicker, setShowWorkspaceCreateRepoPicker] = useState(false) + const [showWorkspaceAdvanced, setShowWorkspaceAdvanced] = useState(false) + const [showWorkspaceBaseBranchPicker, setShowWorkspaceBaseBranchPicker] = useState(false) + const [showWorkspaceSparsePicker, setShowWorkspaceSparsePicker] = useState(false) + const [linearStatusPickerItem, setLinearStatusPickerItem] = useState | null>(null) + const [setupPrompt, setSetupPrompt] = useState(null) + const [creatingKey, setCreatingKey] = useState(null) + const [mutatingStatus, setMutatingStatus] = useState(false) + const [linearStates, setLinearStates] = useState([]) + const [linearStatesLoading, setLinearStatesLoading] = useState(false) + const [linearCommentDraft, setLinearCommentDraft] = useState('') + const [linearSubIssueTitle, setLinearSubIssueTitle] = useState('') + const [taskStateHydrated, setTaskStateHydrated] = useState(false) + const [runtimeTaskSettings, setRuntimeTaskSettings] = useState({}) + const [trustedOrcaHooks, setTrustedOrcaHooks] = useState({}) + const [orcaYamlTrustPrompt, setOrcaYamlTrustPrompt] = useState(null) + const [githubProjectSettings, setGithubProjectSettings] = useState( + EMPTY_GITHUB_PROJECT_SETTINGS + ) + const [githubProjects, setGithubProjects] = useState([]) + const [githubProjectViews, setGithubProjectViews] = useState([]) + const [githubProjectTable, setGithubProjectTable] = useState(null) + const [githubProjectLoading, setGithubProjectLoading] = useState(false) + const [githubProjectError, setGithubProjectError] = useState('') + const [githubProjectPartialFailures, setGithubProjectPartialFailures] = useState< + GitHubProjectPartialFailure[] + >([]) + const [githubProjectSearch, setGithubProjectSearch] = useState('') + const [githubProjectPickerSearch, setGithubProjectPickerSearch] = useState('') + const [githubProjectPasteInput, setGithubProjectPasteInput] = useState('') + const [githubProjectPasteError, setGithubProjectPasteError] = useState('') + const [githubProjectPasteBusy, setGithubProjectPasteBusy] = useState(false) + const [appliedGithubProjectSearch, setAppliedGithubProjectSearch] = useState( + undefined + ) + const [githubProjectSortOverride, setGithubProjectSortOverride] = + useState(null) + const [githubProjectHiddenFieldIdsByView, setGithubProjectHiddenFieldIdsByView] = useState< + Record + >({}) + const [collapsedGitHubProjectGroups, setCollapsedGitHubProjectGroups] = useState>( + () => new Set() + ) + const [showGitHubProjectPicker, setShowGitHubProjectPicker] = useState(false) + const [showGitHubProjectViewPicker, setShowGitHubProjectViewPicker] = useState(false) + const [showGitHubProjectSortPicker, setShowGitHubProjectSortPicker] = useState(false) + const [showGitHubProjectFieldsPicker, setShowGitHubProjectFieldsPicker] = useState(false) + const [pendingGitHubProjectViewSelection, setPendingGitHubProjectViewSelection] = + useState(null) + const [projectRowItem, setProjectRowItem] = useState(null) + const [projectRowDetail, setProjectRowDetail] = useState(null) + const [projectRowDetailLoading, setProjectRowDetailLoading] = useState(false) + const [projectRowDetailError, setProjectRowDetailError] = useState('') + const [projectRowDetailRefreshSeq, setProjectRowDetailRefreshSeq] = useState(0) + const [projectTitleDraft, setProjectTitleDraft] = useState('') + const [projectBodyDraft, setProjectBodyDraft] = useState('') + const [projectCommentDraft, setProjectCommentDraft] = useState('') + const [projectEditingCommentId, setProjectEditingCommentId] = useState(null) + const [projectEditingCommentDraft, setProjectEditingCommentDraft] = useState('') + const [projectReviewersDraft, setProjectReviewersDraft] = useState('') + const [projectFieldDrafts, setProjectFieldDrafts] = useState>({}) + const [projectAvailableLabels, setProjectAvailableLabels] = useState([]) + const [projectLabelsLoading, setProjectLabelsLoading] = useState(false) + const [projectLabelsError, setProjectLabelsError] = useState('') + const [projectAssignableUsers, setProjectAssignableUsers] = useState([]) + const [projectAssignableUsersLoading, setProjectAssignableUsersLoading] = useState(false) + const [projectAssignableUsersError, setProjectAssignableUsersError] = useState('') + const [projectIssueTypes, setProjectIssueTypes] = useState([]) + const [projectIssueTypesLoading, setProjectIssueTypesLoading] = useState(false) + const [projectIssueTypesError, setProjectIssueTypesError] = useState('') + const [projectMutating, setProjectMutating] = useState(false) + const [projectRepoNotInOrca, setProjectRepoNotInOrca] = + useState(null) + // Why: project detail text inputs rerender this screen while comments stay + // unchanged; keep grouping out of the typing path. + const projectDetailCommentGroups = useMemo( + () => + groupDetailComments(projectRowDetail?.provider === 'github' ? projectRowDetail.comments : []), + [projectRowDetail] + ) + const requestedTaskSource = useMemo( + () => (isTaskProvider(taskSource) ? taskSource : undefined), + [taskSource] + ) + const linearMetadataItem = actionItem?.provider === 'linear' ? actionItem : linearStatusPickerItem + const tasksSupported = + connState === 'connected' && + client != null && + tasksSupportState.kind === 'supported' && + tasksSupportState.client === client + const tasksUnsupported = + connState === 'connected' && + client != null && + tasksSupportState.kind === 'unsupported' && + tasksSupportState.client === client + const taskUiReady = tasksSupported && taskStateHydrated + const activeGitHubProject = githubProjectSettings.activeProject + const activeGitHubProjectHost = githubProjectHost( + githubProjectTable?.project.host ?? activeGitHubProject?.host + ) + const hostedRepos = useMemo(() => repos.filter(isHostedTaskRepo), [repos]) + const workspaceRepos = useMemo(() => repos.filter((repo) => repo.kind !== 'folder'), [repos]) + const reposById = useMemo(() => new Map(repos.map((repo) => [repo.id, repo])), [repos]) + const selectedHostedRepos = useMemo( + () => + selectedRepoIds.size === 0 + ? hostedRepos + : hostedRepos.filter((repo) => selectedRepoIds.has(repo.id)), + [hostedRepos, selectedRepoIds] + ) + const findProjectRowRepo = useCallback( + (row: GitHubProjectRow): RepoSummary | null => + findRepoForGitHubProjectRepository( + row.content.repository, + hostedRepos, + githubRepoSlugCache, + activeGitHubProjectHost + ) as RepoSummary | null, + [activeGitHubProjectHost, githubRepoSlugCache, hostedRepos] + ) + // Why: `every` is vacuously true on an empty repo list, so readiness has to ask + // the resource whether that list is real yet. Otherwise the board renders + // "No project items" for a board whose repos simply have not arrived. + const githubProjectRepoSlugReady = useMemo( + () => + hasSettledHostRepoList(repoList.state) && + hostedRepos.every((repo) => { + const cached = githubRepoSlugCache[repo.id] + return cached !== undefined && cached.path === repo.path + }), + [githubRepoSlugCache, hostedRepos, repoList.state] + ) + const visibleGitHubProjectRows = useMemo( + () => + githubProjectTable + ? (filterGitHubProjectRowsForRepos( + githubProjectTable.rows, + hostedRepos, + githubRepoSlugCache, + activeGitHubProjectHost + ) as GitHubProjectRow[]) + : [], + [activeGitHubProjectHost, githubProjectTable, githubRepoSlugCache, hostedRepos] + ) + const visibleGitHubProjectGroups = useMemo(() => { + if (!githubProjectTable) { + return [] + } + const normalizedTable = normalizeProjectTableForMobileSort( + githubProjectTable, + visibleGitHubProjectRows, + githubProjectSortOverride + ) + const sorted = sortRows(normalizedTable, normalizedTable.rows) + return groupRows(normalizedTable, sorted) + }, [githubProjectSortOverride, githubProjectTable, visibleGitHubProjectRows]) + const githubProjectListEntries = useMemo(() => { + const grouped = githubProjectTable?.selectedView.groupByFields?.[0] != null + if (!grouped) { + return visibleGitHubProjectGroups.flatMap((group) => + group.rows.map((row) => ({ + type: 'row' as const, + row: row as unknown as GitHubProjectRow + })) + ) + } + return visibleGitHubProjectGroups.flatMap((group) => { + const collapsed = collapsedGitHubProjectGroups.has(group.key) + const header: ProjectListEntry = { type: 'group', group, collapsed } + if (collapsed) { + return [header] + } + return [ + header, + ...group.rows.map((row) => ({ + type: 'row' as const, + row: row as unknown as GitHubProjectRow + })) + ] + }) + }, [collapsedGitHubProjectGroups, githubProjectTable, visibleGitHubProjectGroups]) + const githubProjectAvailableSummaryFields = useMemo( + () => projectSummaryFields(githubProjectTable), + [githubProjectTable] + ) + const githubProjectFieldVisibilityScope = projectFieldVisibilityKey(githubProjectTable) + const githubProjectHiddenFieldIds = useMemo( + () => + new Set( + githubProjectFieldVisibilityScope + ? (githubProjectHiddenFieldIdsByView[githubProjectFieldVisibilityScope] ?? []) + : [] + ), + [githubProjectFieldVisibilityScope, githubProjectHiddenFieldIdsByView] + ) + const githubProjectSummaryFields = useMemo( + () => + githubProjectAvailableSummaryFields.filter( + (field) => !githubProjectHiddenFieldIds.has(field.id) + ), + [githubProjectAvailableSummaryFields, githubProjectHiddenFieldIds] + ) + + useEffect(() => { + if ( + !client || + connState !== 'connected' || + !tasksSupported || + !taskStateHydrated || + provider !== 'github' || + githubMode !== 'project' + ) { + return + } + const missing = hostedRepos.filter((repo) => { + const cached = githubRepoSlugCache[repo.id] + return !cached || cached.path !== repo.path + }) + if (missing.length === 0) { + return + } + + let cancelled = false + void mapWithConcurrency(missing, GITHUB_REPO_CONCURRENCY, async (repo) => { + try { + const response = await client.sendRequest( + 'github.repoSlug', + { repo: `id:${repo.id}` }, + { timeoutMs: 30_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as GitHubOwnerRepo | null + return { repoId: repo.id, entry: { path: repo.path, repository: result } } + } catch { + // Cached so readiness settles; `failed` marks it for retry on refresh. + return { repoId: repo.id, entry: { path: repo.path, repository: null, failed: true } } + } + }).then((entries) => { + if (cancelled) { + return + } + setGithubRepoSlugCache((current) => { + const next = { ...current } + for (const entry of entries) { + next[entry.repoId] = entry.entry + } + return next + }) + }) + + return () => { + cancelled = true + } + }, [ + client, + connState, + githubMode, + githubRepoSlugCache, + hostedRepos, + provider, + taskStateHydrated, + tasksSupported + ]) + const activeGitHubProjectKey = activeGitHubProject ? githubProjectKey(activeGitHubProject) : null + const activeGitHubProjectViewId = activeGitHubProjectKey + ? githubProjectSettings.lastViewByProject[activeGitHubProjectKey]?.viewId + : undefined + const activeGitHubProjectView = + githubProjectViews.find((view) => view.id === activeGitHubProjectViewId) ?? + (githubProjectTable + ? { + id: githubProjectTable.selectedView.id, + number: githubProjectTable.selectedView.number, + name: githubProjectTable.selectedView.name, + layout: githubProjectTable.selectedView.layout + } + : null) + const projectIssueTypeRepository = + projectRowItem?.itemType === 'ISSUE' ? projectRowItem.content.repository : null + const projectMetadataRepository = + projectRowItem && projectRowType(projectRowItem) ? projectRowItem.content.repository : null + const projectRowHostedRepo = useMemo( + () => (projectRowItem ? findProjectRowRepo(projectRowItem) : null), + [findProjectRowRepo, projectRowItem] + ) + const itemReviewerCandidates = useMemo(() => { + if (!actionItem || actionItem.provider !== 'github' || actionItem.source.type !== 'pr') { + return [] + } + const reviewerSeedUsers = getGitHubReviewerSeedUsers({ + reviewRequests: + detailPayload?.provider === 'github' + ? detailPayload.reviewRequests + : actionItem.source.reviewRequests, + latestReviews: + detailPayload?.provider === 'github' + ? detailPayload.latestReviews + : actionItem.source.latestReviews, + author: actionItem.source.author + }) + const authorLogin = actionItem.source.author?.trim().toLowerCase() ?? null + return mergeGitHubAssignableUsers(itemAssignableUsers, reviewerSeedUsers).filter( + (user) => user.login.trim().toLowerCase() !== authorLogin + ) + }, [actionItem, detailPayload, itemAssignableUsers]) + const itemSelectedReviewerLogins = useMemo(() => { + if (!actionItem || actionItem.provider !== 'github' || actionItem.source.type !== 'pr') { + return new Set() + } + const reviewRequests = + detailPayload?.provider === 'github' + ? detailPayload.reviewRequests + : (actionItem.source.reviewRequests ?? []) + return new Set( + reviewRequests.map((reviewer) => reviewer.login.trim().toLowerCase()).filter(Boolean) + ) + }, [actionItem, detailPayload]) + const projectReviewerCandidates = useMemo(() => { + if ( + !projectRowItem || + projectRowItem.itemType !== 'PULL_REQUEST' || + projectRowDetail?.provider !== 'github' + ) { + return [] + } + const reviewerSeedUsers = getGitHubReviewerSeedUsers({ + reviewRequests: projectRowDetail.reviewRequests, + latestReviews: projectRowDetail.latestReviews + }) + return mergeGitHubAssignableUsers(projectAssignableUsers, reviewerSeedUsers) + }, [projectAssignableUsers, projectRowDetail, projectRowItem]) + const projectSelectedReviewerLogins = useMemo(() => { + if (projectRowDetail?.provider !== 'github') { + return new Set() + } + return new Set( + projectRowDetail.reviewRequests + .map((reviewer) => reviewer.login.trim().toLowerCase()) + .filter(Boolean) + ) + }, [projectRowDetail]) + const projectMetadataSeedLogins = useMemo(() => { + const logins = new Set() + for (const assignee of projectRowItem?.content.assignees ?? []) { + const login = assignee.login.trim() + if (login) { + logins.add(login) + } + } + for (const reviewer of projectRowDetail?.provider === 'github' + ? getGitHubReviewerSeedUsers(projectRowDetail) + : []) { + const login = reviewer.login.trim() + if (login) { + logins.add(login) + } + } + return [...logins].sort().join(',') + }, [projectRowDetail, projectRowItem?.content.assignees]) + + // Why: task-loading effects use this as a stale-client guard, so the ref + // must be current before those passive effects can run after commit. + const resetGitHubItemsState = useCallback(() => { + setGithubRepoSources({}) + setGithubPages([]) + setGithubCurrentPage(0) + setGithubTotalCount(null) + setGithubSourceErrors([]) + setGithubSourceFallbacks([]) + }, []) + + // Why: Expo reuses this screen for the next host, so an effect reset runs a + // render too late and the previous host's rows show under the new one. The + // repo list resets itself; these are the other client-scoped caches. + const [boundClient, setBoundClient] = useState(client) + if (boundClient !== client) { + setBoundClient(client) + setItems([]) + setGithubRepoSlugCache({}) + resetGitHubItemsState() + } + + useLayoutEffect(() => { + clientRef.current = client + // Why: ref writes belong in the commit phase. Doing this during render would + // leak out of a concurrent render React later abandons. + repoSelectionHydratedRef.current = false + }, [client]) + + const persistTaskResumeState = useCallback( + (updates: Partial) => { + if (!client || !taskUiReady) { + return + } + const next = { ...taskResumeRef.current, ...updates } + taskResumeRef.current = next + void client.sendRequest('ui.set', { taskResumeState: next }).catch(() => { + // Best-effort: desktop treats task resume as a convenience preference. + }) + }, + [client, taskUiReady] + ) + + const toggleGitHubProjectFieldVisibility = useCallback( + (fieldId: string) => { + if (!githubProjectFieldVisibilityScope) { + return + } + setGithubProjectHiddenFieldIdsByView((current) => { + const hidden = new Set(current[githubProjectFieldVisibilityScope] ?? []) + if (hidden.has(fieldId)) { + hidden.delete(fieldId) + } else { + hidden.add(fieldId) + } + const next = { ...current } + if (hidden.size === 0) { + delete next[githubProjectFieldVisibilityScope] + } else { + next[githubProjectFieldVisibilityScope] = [...hidden] + } + persistTaskResumeState({ githubProjectHiddenFieldIdsByView: next }) + return next + }) + }, + [githubProjectFieldVisibilityScope, persistTaskResumeState] + ) + + const persistTaskSource = useCallback( + (nextProvider: TaskProvider) => { + if (!client || !taskUiReady) { + return + } + void client.sendRequest('settings.update', { defaultTaskSource: nextProvider }).catch(() => { + // Best-effort: a failed settings write should not block switching views. + }) + }, + [client, taskUiReady] + ) + + const persistRepoSelection = useCallback( + (selection: Set, allRepos: RepoSummary[]) => { + if (!client || !taskUiReady) { + return + } + const nextSelection = + selection.size === 0 || selection.size === allRepos.length ? null : [...selection] + defaultRepoSelectionRef.current = nextSelection + void client + .sendRequest('settings.update', { defaultRepoSelection: nextSelection }) + .catch(() => { + // Best-effort: the in-memory repo picker already reflects the change. + }) + }, + [client, taskUiReady] + ) + + const persistDefaultGitHubPreset = useCallback( + (preset: GitHubPreset) => { + setDefaultGitHubPreset(preset) + if (!client || !taskUiReady) { + return + } + void client.sendRequest('settings.update', { defaultTaskViewPreset: preset }).catch(() => { + // Best-effort: the current session still uses the selected preset. + }) + }, + [client, taskUiReady] + ) + + const persistGitHubProjectSettings = useCallback( + (nextSettings: GitHubProjectSettings) => { + setGithubProjectSettings(nextSettings) + if (!client || !taskUiReady) { + return + } + void client.sendRequest('settings.update', { githubProjects: nextSettings }).catch(() => { + // Best-effort: project selection can still work for the current session. + }) + }, + [client, taskUiReady] + ) + + const persistSetupHookTrust = useCallback( + async (repoId: string, contentHash: string, alwaysTrust: boolean): Promise => { + if (!client) { + return + } + const next = trustedOrcaHooksWithSetupApproval({ + trust: trustedOrcaHooks, + repoId, + contentHash, + alwaysTrust + }) + const response = await client.sendRequest('ui.set', { trustedOrcaHooks: next }) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + setTrustedOrcaHooks(next) + }, + [client, trustedOrcaHooks] + ) + + const resetWorkspaceCreateState = useCallback((): void => { + setWorkspaceRepoPickerItem(null) + setWorkspaceCreateDraft(null) + setWorkspaceNameDraft('') + setWorkspaceLastAutoName('') + setWorkspaceBranchAutoName('') + setWorkspaceBranchNameOverride(undefined) + setWorkspaceBaseBranch(null) + setWorkspaceBaseBranchQuery('') + setWorkspaceBaseBranchResults([]) + setWorkspaceBaseBranchLoading(false) + setWorkspaceBaseBranchError('') + setWorkspaceSparsePresets([]) + setWorkspaceSparsePresetsLoading(false) + setWorkspaceSparsePresetsLoaded(false) + setWorkspaceSparsePresetsError('') + setWorkspaceSparseReloadKey(0) + setWorkspaceSparsePresetId(null) + setWorkspaceSparseDraft(null) + setWorkspaceSparseSaving(false) + setWorkspaceAgent(null) + setWorkspaceAgentOverridden(false) + setWorkspaceDetectedAgentIds(null) + setWorkspaceSshState(null) + setWorkspaceSshConnecting(false) + setShowWorkspaceAgentPicker(false) + setShowWorkspaceCreateRepoPicker(false) + setShowWorkspaceAdvanced(false) + setShowWorkspaceBaseBranchPicker(false) + setShowWorkspaceSparsePicker(false) + setSetupPrompt(null) + setOrcaYamlTrustPrompt(null) + }, []) + + useEffect(() => { + if (!client || connState !== 'connected') { + taskResumeRef.current = {} + defaultRepoSelectionRef.current = null + repoSelectionHydratedRef.current = false + setRuntimeTaskSettings({}) + setTrustedOrcaHooks({}) + setOrcaYamlTrustPrompt(null) + setGithubProjectHiddenFieldIdsByView({}) + setTaskStateHydrated(false) + setTasksSupportState({ kind: 'unknown', client: null }) + setShowLinearWorkspacePicker(false) + setShowLinearTeamPicker(false) + setShowLinearViewPicker(false) + setShowLinearGroupPicker(false) + setShowLinearOrderPicker(false) + setShowLinearDisplayPicker(false) + setShowLinearConnect(false) + setShowProviderPicker(false) + setShowGitHubKindPicker(false) + setShowGitHubPresetPicker(false) + setShowGitLabViewPicker(false) + setShowGitLabFilterPicker(false) + setShowLinearFilterPicker(false) + setShowSortPicker(false) + setShowRepoPicker(false) + setShowGitHubIssueSourcePicker(false) + setShowGitHubPagePicker(false) + setShowGitHubProjectPicker(false) + setShowGitHubProjectViewPicker(false) + setShowGitHubProjectSortPicker(false) + setShowGitHubProjectFieldsPicker(false) + setPendingGitHubProjectViewSelection(null) + setActionItem(null) + setProjectRowItem(null) + setProjectRepoNotInOrca(null) + setDetailPayload(null) + setProjectRowDetail(null) + setShowCreateTask(false) + setShowCreateTargetPicker(false) + setLinearStatusPickerItem(null) + setPendingHostedMerge(null) + setPendingProjectGitHubMerge(null) + setPendingHostedStateChange(null) + setMergeMethodTaskItem(null) + setMergeMethodProjectRow(null) + resetWorkspaceCreateState() + return + } + + let stale = false + setTaskStateHydrated(false) + setTasksSupportState({ kind: 'unknown', client }) + setShowLinearWorkspacePicker(false) + setShowLinearTeamPicker(false) + setShowLinearViewPicker(false) + setShowLinearGroupPicker(false) + setShowLinearOrderPicker(false) + setShowLinearDisplayPicker(false) + setShowLinearConnect(false) + setShowProviderPicker(false) + setShowGitHubKindPicker(false) + setShowGitHubPresetPicker(false) + setShowGitLabViewPicker(false) + setShowGitLabFilterPicker(false) + setShowLinearFilterPicker(false) + setShowSortPicker(false) + setShowRepoPicker(false) + setShowGitHubIssueSourcePicker(false) + setShowGitHubPagePicker(false) + setShowGitHubProjectPicker(false) + setShowGitHubProjectViewPicker(false) + setShowGitHubProjectSortPicker(false) + setShowGitHubProjectFieldsPicker(false) + setPendingGitHubProjectViewSelection(null) + setActionItem(null) + setProjectRowItem(null) + setProjectRepoNotInOrca(null) + setDetailPayload(null) + setProjectRowDetail(null) + setShowCreateTask(false) + setShowCreateTargetPicker(false) + setLinearStatusPickerItem(null) + setPendingHostedMerge(null) + setPendingProjectGitHubMerge(null) + setPendingHostedStateChange(null) + setMergeMethodTaskItem(null) + setMergeMethodProjectRow(null) + resetWorkspaceCreateState() + + const hydrateTaskState = async (): Promise => { + const statusResponse = await client.sendRequest('status.get') + if (stale) { + return + } + if (!isSuccess(statusResponse)) { + throw new Error(statusResponse.error.message) + } + const status = statusResponse.result as TaskRuntimeStatus + if (!status.capabilities?.includes(MOBILE_TASKS_CAPABILITY)) { + // Why: Tasks is additive RPC surface, so old desktop builds can still + // pair but must not receive the newer task-specific method calls. + setTasksSupportState({ kind: 'unsupported', client }) + setItems([]) + resetGitHubItemsState() + setGithubProjectTable(null) + setShowLinearWorkspacePicker(false) + setShowLinearTeamPicker(false) + setShowLinearViewPicker(false) + setShowLinearGroupPicker(false) + setShowLinearOrderPicker(false) + setShowLinearDisplayPicker(false) + setShowLinearConnect(false) + setShowProviderPicker(false) + setShowGitHubKindPicker(false) + setShowGitHubPresetPicker(false) + setShowGitLabViewPicker(false) + setShowGitLabFilterPicker(false) + setShowLinearFilterPicker(false) + setShowSortPicker(false) + setShowRepoPicker(false) + setShowGitHubIssueSourcePicker(false) + setShowGitHubPagePicker(false) + setShowGitHubProjectPicker(false) + setShowGitHubProjectViewPicker(false) + setShowGitHubProjectSortPicker(false) + setShowGitHubProjectFieldsPicker(false) + setPendingGitHubProjectViewSelection(null) + setActionItem(null) + setProjectRowItem(null) + setProjectRepoNotInOrca(null) + setDetailPayload(null) + setProjectRowDetail(null) + setShowCreateTask(false) + setShowCreateTargetPicker(false) + setLinearStatusPickerItem(null) + setPendingHostedMerge(null) + setPendingProjectGitHubMerge(null) + setPendingHostedStateChange(null) + setMergeMethodTaskItem(null) + setMergeMethodProjectRow(null) + resetWorkspaceCreateState() + setError('Update Orca desktop to use Tasks on mobile.') + setTaskStateHydrated(false) + return + } + setTasksSupportState({ kind: 'supported', client }) + setError('') + const [settingsResponse, uiResponse, preflightResponse, linearStatusResponse] = + await Promise.all([ + // Tasks read settings only; agentCatalogSync owns the 512 KiB catalog. + client.sendRequest('settings.get', { includeAgentCatalog: false }), + client.sendRequest('ui.get'), + client.sendRequest('preflight.check'), + client.sendRequest('linear.status') + ]) + if (stale) { + return + } + + const settings = isSuccess(settingsResponse) + ? (((settingsResponse.result as { settings?: RuntimeTaskSettings }).settings ?? + {}) as RuntimeTaskSettings) + : {} + setRuntimeTaskSettings(settings) + const uiState = isSuccess(uiResponse) + ? ( + uiResponse.result as { + ui?: { + taskResumeState?: TaskResumeState + trustedOrcaHooks?: PersistedTrustedOrcaHooks + } + } + ).ui + : null + setTrustedOrcaHooks(uiState?.trustedOrcaHooks ?? {}) + const resume = uiState?.taskResumeState ?? {} + taskResumeRef.current = resume + setGithubProjectHiddenFieldIdsByView(resume.githubProjectHiddenFieldIdsByView ?? {}) + + const preflight = isSuccess(preflightResponse) + ? (preflightResponse.result as { glab?: { installed?: boolean } }) + : null + const linearStatus = isSuccess(linearStatusResponse) + ? (linearStatusResponse.result as LinearStatusResponse) + : null + const preferredProviders = normalizeVisibleTaskProviders(settings.visibleTaskProviders) + const linearIsConnected = linearStatus?.connected === true + const availableProviders = filterAvailableTaskProviders(preferredProviders, { + gitlabInstalled: preflight?.glab?.installed === true, + linearConnected: linearIsConnected + }) + const nextVisibleProviders = + preferredProviders.includes('linear') && !availableProviders.includes('linear') + ? [...availableProviders, 'linear' as const] + : availableProviders + setLinearConnected(linearIsConnected) + if (!linearIsConnected) { + setLinearWorkspaces([]) + setLinearTeams([]) + setSelectedLinearTeamIds(new Set()) + setSelectedLinearWorkspaceId(null) + } + const nextProvider = + requestedTaskSource && nextVisibleProviders.includes(requestedTaskSource) + ? requestedTaskSource + : resolveVisibleTaskProvider( + isTaskProvider(settings.defaultTaskSource) ? settings.defaultTaskSource : undefined, + nextVisibleProviders + ) + const preset = + resume.githubItemsPreset === null + ? normalizeGitHubPreset(settings.defaultTaskViewPreset) + : normalizeGitHubPreset(resume.githubItemsPreset ?? settings.defaultTaskViewPreset) + const defaultPreset = normalizeGitHubPreset(settings.defaultTaskViewPreset) + const githubQuery = + resume.githubItemsPreset === null + ? (resume.githubItemsQuery ?? '') + : getTaskPresetQuery(preset) + const nextLinearFilter = normalizeLinearFilter(resume.linearPreset) + const nextLinearQuery = resume.linearQuery ?? '' + defaultRepoSelectionRef.current = settings.defaultRepoSelection ?? null + defaultLinearTeamSelectionRef.current = settings.defaultLinearTeamSelection ?? null + const nextQuery = + nextProvider === 'github' ? githubQuery : nextProvider === 'linear' ? nextLinearQuery : '' + const nextAppliedQuery = + nextProvider === 'github' + ? scopeGitHubTaskSearch(githubQuery, githubKindFromQuery(githubQuery, preset)) + : nextQuery + + setVisibleProviders(nextVisibleProviders) + setProvider(nextProvider) + setGithubMode(resume.githubMode === 'project' ? 'project' : 'items') + setDefaultGitHubPreset(defaultPreset) + setGithubPreset(preset) + setGithubKind(githubKindFromQuery(githubQuery, preset)) + setLinearFilter(nextLinearFilter) + setGithubProjectSettings(settings.githubProjects ?? EMPTY_GITHUB_PROJECT_SETTINGS) + setQuery(nextQuery) + setAppliedQuery(nextAppliedQuery) + setTaskStateHydrated(true) + } + + void hydrateTaskState().catch((err) => { + if (stale) { + return + } + setError(err instanceof Error ? err.message : 'Failed to load Tasks settings') + setTaskStateHydrated(false) + }) + + return () => { + stale = true + } + }, [client, connState, requestedTaskSource, resetWorkspaceCreateState]) + + useEffect(() => { + if (visibleProviders.includes(provider)) { + return + } + setProvider(resolveVisibleTaskProvider(provider, visibleProviders)) + }, [provider, visibleProviders]) + + // Selection follows the list rather than the fetch, so it reconciles the same + // way no matter which caller triggered the load. + useEffect(() => { + if (repoList.state.status !== 'loaded') { + return + } + if (!repoSelectionHydratedRef.current) { + repoSelectionHydratedRef.current = true + setSelectedRepoIds(reconcileRepoSelection(repos, defaultRepoSelectionRef.current)) + return + } + setSelectedRepoIds((current) => { + if (current.size === 0) { + return current + } + const availableIds = new Set(repos.filter(isHostedTaskRepo).map((repo) => repo.id)) + const next = new Set([...current].filter((id) => availableIds.has(id))) + return next.size === current.size ? current : next + }) + }, [repoList.state.status, repos]) + + const loadLinearContext = useCallback(async (): Promise => { + if (!client || connState !== 'connected' || !tasksSupported) { + return + } + const statusResponse = await client.sendRequest('linear.status') + if (!isSuccess(statusResponse)) { + throw new Error(statusResponse.error.message) + } + const status = statusResponse.result as LinearStatusResponse + setLinearConnected(status.connected === true) + if (status.connected !== true) { + setLinearWorkspaces([]) + setLinearTeams([]) + setSelectedLinearTeamIds(new Set()) + setSelectedLinearWorkspaceId(null) + return + } + const workspaces = status.workspaces ?? [] + const workspaceId = + status.selectedWorkspaceId ?? status.activeWorkspaceId ?? workspaces[0]?.id ?? null + setLinearWorkspaces(workspaces) + setSelectedLinearWorkspaceId(workspaceId) + + const teamsResponse = await client.sendRequest('linear.listTeams', { + workspaceId: workspaceId ?? undefined + }) + if (!isSuccess(teamsResponse)) { + throw new Error(teamsResponse.error.message) + } + const teams = teamsResponse.result as LinearTeam[] + setLinearTeams(teams) + setSelectedLinearTeamIds(reconcileTeamSelection(teams, defaultLinearTeamSelectionRef.current)) + }, [client, connState, tasksSupported]) + + const persistLinearTeamSelection = useCallback( + (teamIds: Set, allTeams: LinearTeam[]) => { + if (!client || !taskUiReady) { + return + } + const selection = teamIds.size === allTeams.length ? null : [...teamIds] + defaultLinearTeamSelectionRef.current = selection + void client + .sendRequest('settings.update', { defaultLinearTeamSelection: selection }) + .catch(() => { + // Best-effort preference persistence; the local picker state already changed. + }) + }, + [client, taskUiReady] + ) + + const fetchGitHubItemsPage = useCallback( + async ( + requestClient: RpcClient, + queriedRepos: RepoSummary[], + before?: string + ): Promise<{ + items: Array> + failedCount: number + sourcesByRepoId: Record + sourceErrors: GitHubIssueSourceError[] + sourceFallbacks: GitHubIssueSourceFallback[] + }> => { + const results = await mapWithConcurrency( + queriedRepos, + GITHUB_REPO_CONCURRENCY, + async (repo) => { + try { + const response = await requestClient.sendRequest('github.listWorkItems', { + repo: `id:${repo.id}`, + limit: PER_REPO_FETCH_LIMIT, + query: scopeGitHubTaskSearch(appliedQuery, githubKind), + before + }) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const envelope = response.result as { + items: Array> + sources?: GitHubRepoSources + errors?: { issues?: { message: string } } + issueSourceFellBack?: true + } + return { + items: envelope.items.map((item) => createGitHubTask(repo, item)), + sources: envelope.sources, + sourceError: extractGitHubIssueSourceError(repo, envelope), + sourceFallback: extractGitHubIssueSourceFallback(repo, envelope), + repoId: repo.id + } + } catch (err) { + const isExpectedSshSkip = isGitHubWorkItemsSshRemoteRequiredError(err) + const logWorkItemFetchFailure = isExpectedSshSkip ? console.log : console.warn + logWorkItemFetchFailure( + '[mobile tasks] failed to fetch github work items', + repo.id, + isExpectedSshSkip && err instanceof Error ? err.message : err + ) + return { + items: [] as Array>, + repoId: repo.id, + error: err instanceof Error ? err.message : 'Failed to load GitHub tasks' + } + } + } + ) + + const sourcesByRepoId: Record = {} + const sourceErrors: GitHubIssueSourceError[] = [] + const sourceFallbacks: GitHubIssueSourceFallback[] = [] + for (const result of results) { + if (result.sources) { + sourcesByRepoId[result.repoId] = result.sources + } + if (result.sourceError) { + sourceErrors.push(result.sourceError) + } + if (result.sourceFallback) { + sourceFallbacks.push(result.sourceFallback) + } + } + + return { + items: results + .flatMap((result) => result.items) + .sort((a, b) => taskTime(b.updatedAt) - taskTime(a.updatedAt)) + .slice(0, CROSS_REPO_DISPLAY_LIMIT), + failedCount: results.filter((result) => result.error).length, + sourcesByRepoId, + sourceErrors, + sourceFallbacks + } + }, + [appliedQuery, githubKind] + ) + + const countGitHubItems = useCallback( + async (requestClient: RpcClient, queriedRepos: RepoSummary[]): Promise => { + const counts = await mapWithConcurrency( + queriedRepos, + GITHUB_REPO_CONCURRENCY, + async (repo) => { + try { + const response = await requestClient.sendRequest( + 'github.countWorkItems', + { + repo: `id:${repo.id}`, + query: scopeGitHubTaskSearch(appliedQuery, githubKind) + }, + { timeoutMs: 30_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + return typeof response.result === 'number' ? response.result : 0 + } catch (err) { + const isExpectedSshSkip = isGitHubWorkItemsSshRemoteRequiredError(err) + const logWorkItemCountFailure = isExpectedSshSkip ? console.log : console.warn + logWorkItemCountFailure( + '[mobile tasks] failed to count github work items', + repo.id, + isExpectedSshSkip && err instanceof Error ? err.message : err + ) + return 0 + } + } + ) + return counts.reduce((sum, count) => sum + count, 0) + }, + [appliedQuery, githubKind] + ) + + const loadTasks = useCallback( + async (options: { silent?: boolean } = {}): Promise => { + if (!client || connState !== 'connected' || !tasksSupported || !taskStateHydrated) { + return + } + const generation = loadGenerationRef.current + 1 + loadGenerationRef.current = generation + const requestClient = client + const isCurrent = () => + loadGenerationRef.current === generation && clientRef.current === requestClient + setError('') + if (options.silent) { + setRefreshing(true) + } else { + setLoading(true) + } + try { + if (provider !== 'github' || githubMode !== 'items') { + resetGitHubItemsState() + } + if (provider === 'linear' && !linearConnected) { + setItems([]) + return + } + // Why: Linear issues do not need the repo list, only the composer does, so + // start the fetch either way but never make Linear wait on it. + const repoListRequest = repoListEnsureLoaded() + void repoListRequest.catch(() => {}) + const currentRepos = provider === 'linear' ? [] : await repoListRequest + if (!isCurrent()) { + return + } + // Why: project mode fetches no work items, but its rows are matched against + // the repo list, so it must not return before loadRepos() has run. + if (provider === 'github' && githubMode === 'project') { + setItems([]) + return + } + if (provider === 'github' || provider === 'gitlab') { + const supportedRepos = currentRepos.filter(isHostedTaskRepo) + const queriedRepos = + selectedRepoIds.size === 0 + ? supportedRepos + : supportedRepos.filter((repo) => selectedRepoIds.has(repo.id)) + if (queriedRepos.length === 0) { + if (!isCurrent()) { + return + } + setItems([]) + resetGitHubItemsState() + return + } + if (provider === 'github') { + const page = await fetchGitHubItemsPage(requestClient, queriedRepos) + if (!isCurrent()) { + return + } + setGithubRepoSources((current) => ({ ...current, ...page.sourcesByRepoId })) + setGithubSourceErrors(page.sourceErrors) + setGithubSourceFallbacks(page.sourceFallbacks) + if (page.failedCount === queriedRepos.length) { + throw new Error('Failed to load GitHub tasks') + } + setGithubPages([page.items]) + setGithubCurrentPage(0) + setItems(page.items) + if (selectedRepoIds.size > 0) { + void countGitHubItems(requestClient, queriedRepos).then((count) => { + if (isCurrent()) { + setGithubTotalCount(count) + } + }) + } else { + // Why: the default all-repos view must not spend GitHub Search + // quota on totals before the user narrows the repository scope. + setGithubTotalCount(null) + } + if (page.failedCount > 0) { + setError(buildPartialRepositoryNotice(page.failedCount, queriedRepos.length)) + } else { + setError('') + } + return + } + if (provider === 'gitlab' && gitlabView === 'todos') { + const response = await requestClient.sendRequest('gitlab.todos', { + repo: `id:${queriedRepos[0]!.id}` + }) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + if (!isCurrent()) { + return + } + setItems( + ((response.result as GitLabTodo[]) ?? []) + .map(createGitLabTodoTask) + .sort((a, b) => taskTime(b.updatedAt) - taskTime(a.updatedAt)) + ) + return + } + const results = await mapWithConcurrency( + queriedRepos, + GITHUB_REPO_CONCURRENCY, + async (repo) => { + try { + const response = await requestClient.sendRequest('gitlab.listWorkItems', { + repo: `id:${repo.id}`, + state: gitlabFilter, + page: 1, + perPage: GITLAB_PER_PAGE, + query: appliedQuery.trim() || undefined + }) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const envelope = response.result as { + items: Array> + error?: { type?: string; message: string } + } + if (envelope.error?.type && envelope.error.type !== 'not_found') { + return { items: [], error: envelope.error.message } + } + return { items: envelope.items.map((item) => createGitLabTask(repo, item)) } + } catch (err) { + console.warn(`[mobile tasks] failed to fetch ${provider} work items`, repo.id, err) + return { + items: [] as TaskItem[], + error: err instanceof Error ? err.message : 'Failed to load GitLab tasks' + } + } + } + ) + if (!isCurrent()) { + return + } + const failedCount = results.filter((result) => result.error).length + if (failedCount === queriedRepos.length) { + throw new Error( + results.find((result) => result.error)?.error ?? 'Failed to load GitLab tasks' + ) + } + setItems( + results + .flatMap((result) => result.items) + .sort((a, b) => taskTime(b.updatedAt) - taskTime(a.updatedAt)) + ) + if (failedCount > 0) { + setError(buildPartialRepositoryNotice(failedCount, queriedRepos.length)) + } else { + setError('') + } + } else { + const normalizedQuery = appliedQuery.trim() + const response = normalizedQuery + ? await requestClient.sendRequest('linear.searchIssues', { + query: normalizedQuery, + limit: LINEAR_LIMIT, + workspaceId: selectedLinearWorkspaceId ?? undefined + }) + : await requestClient.sendRequest('linear.listIssues', { + filter: linearFilter, + limit: LINEAR_LIMIT, + workspaceId: selectedLinearWorkspaceId ?? undefined + }) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const issues = extractLinearIssueReadItems(response.result) + const filtered = + selectedLinearTeamIds.size > 0 + ? issues.filter((issue) => selectedLinearTeamIds.has(issue.team.id)) + : issues + const sorted = [...filtered].sort((a, b) => compareLinearIssues(a, b, linearOrderBy)) + if (!isCurrent()) { + return + } + setItems(sorted.map(createLinearTask)) + } + } catch (err) { + if (!isCurrent()) { + return + } + setItems([]) + resetGitHubItemsState() + setError(err instanceof Error ? err.message : 'Failed to load tasks') + } finally { + if (isCurrent()) { + setLoading(false) + setRefreshing(false) + } + } + }, + [ + appliedQuery, + client, + connState, + countGitHubItems, + fetchGitHubItemsPage, + gitlabFilter, + gitlabView, + githubMode, + linearConnected, + linearFilter, + linearOrderBy, + // resetGitHubItemsState is useCallback([]), so its identity never changes + // and listing it here would only cost a line against the max-lines budget. + repoListEnsureLoaded, + provider, + selectedLinearTeamIds, + selectedLinearWorkspaceId, + selectedRepoIds, + taskStateHydrated, + tasksSupported + ] + ) + + const connectLinearAccount = useCallback(async (): Promise => { + if (!client || connState !== 'connected' || !taskUiReady) { + return + } + const apiKey = linearApiKeyDraft.trim() + if (!apiKey || linearConnectState === 'connecting') { + return + } + setLinearConnectState('connecting') + setLinearConnectError('') + try { + const response = await client.sendRequest('linear.connect', { apiKey }) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as { ok?: boolean; error?: string } + if (result.ok === false) { + throw new Error(result.error ?? 'Failed to connect Linear') + } + setLinearApiKeyDraft('') + setLinearConnectState('idle') + setShowLinearConnect(false) + setLinearConnected(true) + setVisibleProviders((current) => + current.includes('linear') ? current : [...current, 'linear'] + ) + setProvider('linear') + await loadLinearContext() + } catch (err) { + setLinearConnectState('error') + setLinearConnectError(err instanceof Error ? err.message : 'Connection failed') + } + }, [client, connState, linearApiKeyDraft, linearConnectState, loadLinearContext, taskUiReady]) + + const retryGitHubIssueSourceFetch = useCallback( + async (repoPath: string): Promise => { + setRetryingGithubSourceRepoPaths((current) => { + const next = new Set(current) + next.add(repoPath) + return next + }) + try { + // Why: desktop retries through the shared list refresh path so source + // errors, rows, counts, and pagination reset from one authoritative fetch. + await loadTasks({ silent: true }) + } finally { + setRetryingGithubSourceRepoPaths((current) => { + const next = new Set(current) + next.delete(repoPath) + return next + }) + } + }, + [loadTasks] + ) + + const githubTotalPages = useMemo(() => { + const selectedRepoCount = Math.max(1, selectedHostedRepos.length) + const pageCapacity = Math.max( + 1, + Math.min(CROSS_REPO_DISPLAY_LIMIT, selectedRepoCount * PER_REPO_FETCH_LIMIT) + ) + if (githubTotalCount !== null) { + return Math.max(githubPages.length, Math.ceil(githubTotalCount / pageCapacity)) + } + return githubPages.length + }, [githubPages.length, githubTotalCount, selectedHostedRepos.length]) + const githubPageCapacity = useMemo(() => { + const selectedRepoCount = Math.max(1, selectedHostedRepos.length) + return Math.max(1, Math.min(CROSS_REPO_DISPLAY_LIMIT, selectedRepoCount * PER_REPO_FETCH_LIMIT)) + }, [selectedHostedRepos.length]) + const githubCanLoadUncountedNextPage = + githubTotalCount === null && (githubPages.at(-1)?.length ?? 0) >= githubPageCapacity + const githubCanShowPagination = + githubTotalPages > 1 || (githubPages.length > 0 && githubCanLoadUncountedNextPage) + const githubPagePickerPages = useMemo(() => { + const visible = new Set() + const availablePages = Math.min( + githubTotalPages + (githubCanLoadUncountedNextPage ? 1 : 0), + githubPages.length + (githubCanLoadUncountedNextPage ? 1 : 0) + ) + for (let index = 0; index < availablePages; index += 1) { + visible.add(index) + } + for ( + let index = Math.max(0, githubCurrentPage - 2); + index <= Math.min(availablePages - 1, githubCurrentPage + 2); + index += 1 + ) { + visible.add(index) + } + return [...visible].sort((a, b) => a - b) + }, [githubCanLoadUncountedNextPage, githubCurrentPage, githubTotalPages]) + + const handleGitHubPageChange = useCallback( + async (targetPage: number): Promise => { + if ( + !client || + !tasksSupported || + targetPage < 0 || + githubPaginationLoading || + selectedHostedRepos.length === 0 + ) { + return + } + if (targetPage > githubPages.length) { + return + } + if (targetPage < githubPages.length) { + setGithubCurrentPage(targetPage) + setItems(githubPages[targetPage] ?? []) + return + } + const lastPage = githubPages[githubPages.length - 1] + const oldestItem = lastPage?.[lastPage.length - 1] + if (!oldestItem?.updatedAt) { + return + } + + setGithubPaginationLoading(true) + setGithubLoadingTargetPage(targetPage) + try { + let cursor = oldestItem.updatedAt + let loadedPages = githubPages.length + const nextPages: Array[]> = [] + while (loadedPages <= targetPage) { + const page = await fetchGitHubItemsPage(client, selectedHostedRepos, cursor) + if (page.items.length === 0) { + break + } + if (page.failedCount > 0) { + setError(buildPartialRepositoryNotice(page.failedCount, selectedHostedRepos.length)) + } + nextPages.push(page.items) + cursor = page.items[page.items.length - 1]!.updatedAt + loadedPages += 1 + } + if (nextPages.length === 0) { + return + } + const allPages = [...githubPages, ...nextPages] + const nextPage = targetPage < loadedPages ? targetPage : loadedPages - 1 + setGithubPages(allPages) + setGithubCurrentPage(nextPage) + setItems(allPages[nextPage] ?? []) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load more GitHub tasks') + } finally { + setGithubPaginationLoading(false) + setGithubLoadingTargetPage(null) + } + }, + [ + client, + fetchGitHubItemsPage, + githubPages, + githubPaginationLoading, + selectedHostedRepos, + tasksSupported + ] + ) + + const loadGitHubProjects = useCallback(async (): Promise => { + if (!client || connState !== 'connected' || !tasksSupported) { + return + } + setGithubProjectError('') + setGithubProjectPartialFailures([]) + const response = await client.sendRequest('github.project.listAccessible', { + host: 'github.com' + }) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as + | { + ok: true + projects: GitHubProjectSummary[] + partialFailures?: GitHubProjectPartialFailure[] + } + | { ok: false; error: { message: string } } + if (!result.ok) { + throw new Error(result.error.message) + } + setGithubProjects(result.projects) + setGithubProjectPartialFailures(result.partialFailures ?? []) + }, [client, connState, tasksSupported]) + + const loadGitHubProjectViews = useCallback( + async (project: GitHubProjectRef): Promise => { + if (!client || connState !== 'connected' || !tasksSupported || !taskStateHydrated) { + return [] + } + const response = await client.sendRequest('github.project.listViews', { + owner: project.owner, + host: githubProjectHost(project.host), + ownerType: project.ownerType, + projectNumber: project.number + }) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as + | { ok: true; views: GitHubProjectViewSummary[] } + | { ok: false; error: { message: string } } + if (!result.ok) { + throw new Error(result.error.message) + } + setGithubProjectViews(result.views) + return result.views + }, + [client, connState, taskStateHydrated, tasksSupported] + ) + + const loadGitHubProjectTable = useCallback( + async (options: { force?: boolean; queryOverride?: string } = {}): Promise => { + if ( + !client || + connState !== 'connected' || + !tasksSupported || + !activeGitHubProject || + !activeGitHubProjectViewId + ) { + setGithubProjectTable(null) + return + } + setGithubProjectLoading(true) + setGithubProjectError('') + try { + const response = await client.sendRequest( + 'github.project.viewTable', + { + owner: activeGitHubProject.owner, + host: activeGitHubProjectHost, + ownerType: activeGitHubProject.ownerType, + projectNumber: activeGitHubProject.number, + viewId: activeGitHubProjectViewId, + ...(options.queryOverride !== undefined ? { queryOverride: options.queryOverride } : {}) + }, + { timeoutMs: 60_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as + | { ok: true; data: GitHubProjectTable } + | { ok: false; error: { message: string }; totalCount?: number } + if (!result.ok) { + throw new Error(result.error.message) + } + setGithubProjectTable(result.data) + setGithubProjectSearch(options.queryOverride ?? result.data.selectedView.filter ?? '') + setGithubProjectViews((current) => + current.some((view) => view.id === result.data.selectedView.id) + ? current + : [ + ...current, + { + id: result.data.selectedView.id, + number: result.data.selectedView.number, + name: result.data.selectedView.name, + layout: result.data.selectedView.layout + } + ] + ) + } catch (err) { + setGithubProjectTable(null) + setGithubProjectError(err instanceof Error ? err.message : 'Failed to load project view') + } finally { + setGithubProjectLoading(false) + } + }, + [ + activeGitHubProject, + activeGitHubProjectHost, + activeGitHubProjectViewId, + client, + connState, + tasksSupported + ] + ) + + const commitGitHubProjectView = useCallback( + (project: GitHubProjectRef, viewId: string): void => { + const projectKey = githubProjectKey(project) + const nextSettings: GitHubProjectSettings = { + ...githubProjectSettings, + recent: [ + { ...project, lastOpenedAt: new Date().toISOString() }, + ...githubProjectSettings.recent.filter((entry) => githubProjectKey(entry) !== projectKey) + ].slice(0, 10), + lastViewByProject: { + ...githubProjectSettings.lastViewByProject, + [projectKey]: { viewId } + }, + activeProject: project + } + persistGitHubProjectSettings(nextSettings) + setAppliedGithubProjectSearch(undefined) + setGithubProjectSearch('') + setGithubProjectTable(null) + }, + [githubProjectSettings, persistGitHubProjectSettings] + ) + + const selectGitHubProject = useCallback( + async (project: GitHubProjectRef, options: { viewNumber?: number } = {}): Promise => { + if (!tasksSupported || !taskStateHydrated) { + return + } + setGithubProjectLoading(true) + setGithubProjectError('') + try { + const views = await loadGitHubProjectViews(project) + const projectKey = githubProjectKey(project) + const rememberedView = githubProjectSettings.lastViewByProject[projectKey]?.viewId + const explicitView = + typeof options.viewNumber === 'number' + ? views.find((view) => view.number === options.viewNumber) + : undefined + if (options.viewNumber !== undefined && !explicitView) { + // Why: desktop treats stale /views/{n} URLs as a prompt to choose a + // replacement view, not as a failed project selection. + const supportedViews = views.filter((view) => view.layout === 'TABLE_LAYOUT') + if (supportedViews.length === 0) { + throw new Error('This project has no supported views.') + } + setPendingGitHubProjectViewSelection(project) + setShowGitHubProjectViewPicker(true) + return + } + if (explicitView && explicitView.layout !== 'TABLE_LAYOUT') { + throw new Error("Orca doesn't support this GitHub Project layout yet.") + } + if (!explicitView && !rememberedView) { + // Why: desktop asks which Project view to open the first time a project + // is selected. Mobile should not silently choose the first table view. + const supportedViews = views.filter((view) => view.layout === 'TABLE_LAYOUT') + if (supportedViews.length === 0) { + throw new Error('This project has no supported views.') + } + setPendingGitHubProjectViewSelection(project) + setShowGitHubProjectViewPicker(true) + return + } + const selectedView = + explicitView ?? + views.find((view) => view.id === rememberedView && view.layout === 'TABLE_LAYOUT') ?? + undefined + if (!selectedView) { + throw new Error('This project has no supported views.') + } + commitGitHubProjectView(project, selectedView.id) + } catch (err) { + setGithubProjectError(err instanceof Error ? err.message : 'Failed to select project') + } finally { + setGithubProjectLoading(false) + } + }, + [ + commitGitHubProjectView, + githubProjectSettings, + loadGitHubProjectViews, + taskStateHydrated, + tasksSupported + ] + ) + + const resolveGitHubProjectFromInput = useCallback(async (): Promise => { + if (!client || connState !== 'connected' || !tasksSupported || !taskStateHydrated) { + return + } + const input = githubProjectPasteInput.trim() + const parsed = parseProjectInput(input) + if (!parsed) { + setGithubProjectPasteError('Expected a project URL or owner/number.') + return + } + setGithubProjectPasteBusy(true) + setGithubProjectPasteError('') + setGithubProjectError('') + try { + const response = await client.sendRequest('github.project.resolveRef', { + input, + host: githubProjectHost(parsed.host) + }) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as + | { + ok: true + owner: string + ownerType: GitHubProjectOwnerType + number: number + title: string + host?: string + viewNumber?: number + } + | { ok: false; error: { message: string } } + if (!result.ok) { + setGithubProjectPasteError(result.error.message) + return + } + setGithubProjectPasteInput('') + setShowGitHubProjectPicker(false) + await selectGitHubProject( + { + owner: result.owner, + ownerType: result.ownerType, + number: result.number, + host: githubProjectHost(result.host ?? parsed.host) + }, + { viewNumber: result.viewNumber } + ) + } catch (err) { + setGithubProjectPasteError(err instanceof Error ? err.message : 'Failed to add project.') + } finally { + setGithubProjectPasteBusy(false) + } + }, [ + client, + connState, + githubProjectPasteInput, + selectGitHubProject, + taskStateHydrated, + tasksSupported + ]) + + // Why: a refresh must re-read the host, not replay the cached list, or a repo + // added since this screen mounted can never appear. + const refreshTasks = useCallback(() => { + void repoListReload().catch(() => {}) + void loadTasks({ silent: true }) + }, [loadTasks, repoListReload]) + + const refreshGitHubProject = useCallback(() => { + setGithubRepoSlugCache(dropFailedGitHubRepoSlugEntries) + refreshTasks() + void loadGitHubProjectTable({ queryOverride: appliedGithubProjectSearch }) + }, [appliedGithubProjectSearch, loadGitHubProjectTable, refreshTasks]) + + useEffect(() => { + if (!taskStateHydrated) { + return + } + const timer = setTimeout(() => { + setAppliedQuery( + provider === 'github' ? scopeGitHubTaskSearch(query, githubKind) : query.trim() + ) + }, 300) + return () => clearTimeout(timer) + }, [githubKind, provider, query, taskStateHydrated]) + + const setTaskCopyFeedbackRootRef = useCallback((node: View | null): void => { + if (node !== null) { + return + } + // Why: copied-link feedback is screen-local; clear the pending reset when + // the Tasks screen detaches without a passive cleanup-only Effect. + clearMobileTaskCopyFeedbackTimer(copiedLinkResetTimerRef) + }, []) + + useEffect(() => { + if (!taskUiReady || provider !== 'github' || githubMode !== 'items') { + return + } + const trimmed = appliedQuery.trim() + persistTaskResumeState({ + githubMode: 'items', + githubItemsPreset: trimmed === getTaskPresetQuery(githubPreset) ? githubPreset : null, + githubItemsQuery: trimmed + }) + }, [appliedQuery, githubMode, githubPreset, persistTaskResumeState, provider, taskUiReady]) + + useEffect(() => { + if (!taskUiReady || provider !== 'linear') { + return + } + persistTaskResumeState({ + linearPreset: linearFilter, + linearQuery: appliedQuery.trim() + }) + }, [appliedQuery, linearFilter, persistTaskResumeState, provider, taskUiReady]) + + useEffect(() => { + if (connState !== 'connected' || !taskStateHydrated) { + return + } + void loadTasks() + }, [connState, loadTasks, taskStateHydrated]) + + useEffect(() => { + if (!taskStateHydrated || provider !== 'linear' || !linearConnected) { + return + } + void loadLinearContext().catch((err) => { + setError(err instanceof Error ? err.message : 'Failed to load Linear context') + }) + }, [linearConnected, loadLinearContext, provider, taskStateHydrated]) + + useEffect(() => { + if (!taskUiReady || provider !== 'github' || githubMode !== 'project') { + return + } + persistTaskResumeState({ githubMode: 'project' }) + if (activeGitHubProject && activeGitHubProjectViewId) { + void loadGitHubProjectTable({ queryOverride: appliedGithubProjectSearch }) + } else if (activeGitHubProject) { + void selectGitHubProject(activeGitHubProject) + } else { + void loadGitHubProjects().catch((err) => { + setGithubProjectError(err instanceof Error ? err.message : 'Failed to load projects') + }) + } + }, [ + activeGitHubProject, + activeGitHubProjectViewId, + appliedGithubProjectSearch, + githubMode, + loadGitHubProjectTable, + loadGitHubProjects, + persistTaskResumeState, + provider, + selectGitHubProject, + taskUiReady + ]) + + useEffect(() => { + if (!taskUiReady || !showGitHubProjectPicker) { + return + } + void loadGitHubProjects().catch((err) => { + setGithubProjectError(err instanceof Error ? err.message : 'Failed to load projects') + }) + }, [loadGitHubProjects, showGitHubProjectPicker, taskUiReady]) + + useEffect(() => { + if (!tasksSupported || !taskStateHydrated || !showCreateTask) { + return + } + setCreatingTask(false) + if (provider === 'github' || provider === 'gitlab') { + setCreateRepoId((current) => + current && hostedRepos.some((repo) => repo.id === current) + ? current + : (hostedRepos[0]?.id ?? null) + ) + return + } + if (!client) { + return + } + let stale = false + setCreateTeamId(null) + void client + .sendRequest('linear.listTeams') + .then((response) => { + if (stale) { + return + } + if (isSuccess(response)) { + const teams = response.result as LinearTeam[] + setLinearTeams(teams) + setCreateTeamId((current) => current ?? teams[0]?.id ?? null) + } else { + setLinearTeams([]) + setCreateTeamId(null) + } + }) + .catch(() => { + if (!stale) { + setLinearTeams([]) + setCreateTeamId(null) + } + }) + return () => { + stale = true + } + }, [client, hostedRepos, provider, showCreateTask, taskStateHydrated, tasksSupported]) + + useEffect(() => { + if (!tasksSupported || !linearMetadataItem || !client) { + setLinearStates([]) + setLinearCommentDraft('') + setLinearSubIssueTitle('') + return + } + let stale = false + setLinearStatesLoading(true) + setLinearCommentDraft('') + setLinearSubIssueTitle('') + const baseParams = { + teamId: linearMetadataItem.source.team.id, + workspaceId: linearMetadataItem.source.workspaceId + } + void client + .sendRequest('linear.teamStates', baseParams) + .then((statesResponse) => { + if (stale) { + return + } + if (isSuccess(statesResponse)) { + setLinearStates(statesResponse.result as LinearState[]) + } else { + setLinearStates([]) + } + }) + .catch(() => { + if (!stale) { + setLinearStates([]) + } + }) + .finally(() => { + if (!stale) { + setLinearStatesLoading(false) + } + }) + return () => { + stale = true + } + }, [client, linearMetadataItem, tasksSupported]) + + useEffect(() => { + if (!actionItem) { + setItemTitleDraft('') + setItemBodyDraft('') + setItemCommentDraft('') + setItemAddLabelsDraft('') + setItemRemoveLabelsDraft('') + setItemAddAssigneesDraft('') + setItemRemoveAssigneesDraft('') + setItemReviewersDraft('') + setItemReplyDrafts({}) + setExpandedPrFilePath(null) + setPrFileContents({}) + setPrFileLoadingPath(null) + setPrFileCommentDrafts({}) + setExpandedResolvedCommentGroups(new Set()) + return + } + setItemTitleDraft(actionItem.title) + setItemBodyDraft('') + setItemCommentDraft('') + setItemAddLabelsDraft('') + setItemRemoveLabelsDraft('') + setItemAddAssigneesDraft('') + setItemRemoveAssigneesDraft('') + setItemReviewersDraft('') + setItemReplyDrafts({}) + setExpandedPrFilePath(null) + setPrFileContents({}) + setPrFileLoadingPath(null) + setPrFileCommentDrafts({}) + setExpandedResolvedCommentGroups(new Set()) + }, [actionItem]) + + useEffect(() => { + if (!detailPayload) { + setItemBodyDraft('') + return + } + setItemBodyDraft( + detailPayload.provider === 'linear' ? detailPayload.description : detailPayload.body + ) + }, [detailPayload]) + + useEffect(() => { + if (!tasksSupported || !client || actionItem?.provider !== 'github') { + setItemAvailableLabels([]) + setItemLabelsLoading(false) + setItemLabelsError('') + setItemAssignableUsers([]) + setItemAssignableUsersLoading(false) + setItemAssignableUsersError('') + return + } + + let stale = false + if (actionItem.source.type === 'issue' || actionItem.source.type === 'pr') { + setItemAvailableLabels([]) + setItemLabelsError('') + setItemLabelsLoading(true) + void client + .sendRequest( + 'github.listLabels', + { repo: `id:${actionItem.source.repoId}` }, + { timeoutMs: 30_000 } + ) + .then((response) => { + if (stale) { + return + } + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + setItemAvailableLabels(response.result as string[]) + }) + .catch((err) => { + if (!stale) { + setItemLabelsError(err instanceof Error ? err.message : 'Failed to load labels') + } + }) + .finally(() => { + if (!stale) { + setItemLabelsLoading(false) + } + }) + } else { + setItemAvailableLabels([]) + setItemLabelsLoading(false) + setItemLabelsError('') + } + + setItemAssignableUsers([]) + setItemAssignableUsersError('') + setItemAssignableUsersLoading(true) + void client + .sendRequest( + 'github.listAssignableUsers', + { repo: `id:${actionItem.source.repoId}` }, + { timeoutMs: 30_000 } + ) + .then((response) => { + if (stale) { + return + } + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + setItemAssignableUsers(response.result as GitHubAssignableUser[]) + }) + .catch((err) => { + if (!stale) { + setItemAssignableUsersError( + err instanceof Error ? err.message : 'Failed to load assignees' + ) + } + }) + .finally(() => { + if (!stale) { + setItemAssignableUsersLoading(false) + } + }) + + return () => { + stale = true + } + }, [actionItem, client, tasksSupported]) + + useEffect(() => { + if (!tasksSupported || !actionItem || !client) { + setDetailPayload(null) + setDetailLoading(false) + setDetailError('') + return + } + + let stale = false + setDetailPayload(null) + setDetailError('') + setDetailLoading(true) + + const loadDetails = async (): Promise => { + if (actionItem.provider === 'github') { + const response = await client.sendRequest( + 'github.workItemDetails', + { + repo: `id:${actionItem.source.repoId}`, + number: actionItem.source.number, + type: actionItem.source.type + }, + { timeoutMs: 30_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const details = response.result as { + body?: string + comments?: DetailComment[] + item?: { + labels?: string[] + reviewDecision?: string | null + reviewRequests?: GitHubAssignableUser[] + latestReviews?: GitHubPRReviewSummary[] + } + assignees?: string[] + headSha?: string + baseSha?: string + pullRequestId?: string + checks?: GitHubDetailCheck[] + files?: Array<{ + path: string + oldPath?: string + status?: GitHubDetailFile['status'] + additions?: number + deletions?: number + isBinary?: boolean + viewerViewedState?: 'DISMISSED' | 'VIEWED' | 'UNVIEWED' + }> + } | null + if (!details) { + throw new Error('Details not found') + } + if (!stale) { + setDetailPayload({ + provider: 'github', + body: details.body ?? '', + comments: details.comments ?? [], + labels: details.item?.labels ?? actionItem.source.labels, + assignees: details.assignees ?? [], + reviewDecision: details.item?.reviewDecision ?? actionItem.source.reviewDecision, + reviewRequests: details.item?.reviewRequests ?? actionItem.source.reviewRequests ?? [], + latestReviews: details.item?.latestReviews ?? actionItem.source.latestReviews ?? [], + headSha: details.headSha, + baseSha: details.baseSha, + pullRequestId: details.pullRequestId, + checks: details.checks ?? [], + files: details.files ?? [] + }) + } + return + } + + if (actionItem.provider === 'gitlab') { + const response = await client.sendRequest( + 'gitlab.workItemDetails', + { + repo: `id:${actionItem.source.repoId}`, + iid: actionItem.source.number, + type: actionItem.source.type, + projectRef: actionItem.source.projectRef + }, + { timeoutMs: 30_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const details = response.result as { + body?: string + comments?: DetailComment[] + item?: { labels?: string[]; mergeable?: 'MERGEABLE' | 'CONFLICTING' | 'UNKNOWN' } + assignees?: string[] + pipelineJobs?: Array<{ + id?: number + name: string + stage: string + status: string + webUrl?: string | null + duration?: number | null + }> + reviewers?: unknown[] + approvalState?: { approvalsRequired: number | null; approvalsLeft: number | null } + } | null + if (!details) { + throw new Error('Details not found') + } + if (!stale) { + setDetailPayload({ + provider: 'gitlab', + body: details.body ?? '', + comments: details.comments ?? [], + labels: details.item?.labels ?? actionItem.source.labels, + assignees: details.assignees ?? [], + pipelineJobs: details.pipelineJobs ?? [] + }) + const checksSummary = buildGitLabCheckSummary(details.pipelineJobs ?? []) + const reviewDecision: Exclude | undefined = + details.approvalState?.approvalsRequired && details.approvalState.approvalsLeft === 0 + ? 'approved' + : details.approvalState?.approvalsLeft && details.approvalState.approvalsLeft > 0 + ? 'review_required' + : undefined + const hydratedStatus = { + ...(details.item?.mergeable !== undefined ? { mergeable: details.item.mergeable } : {}), + ...(reviewDecision !== undefined ? { reviewDecision } : {}), + ...(details.reviewers !== undefined ? { reviewerCount: details.reviewers.length } : {}) + } + setActionItem((current) => + current?.provider === 'gitlab' && current.source.id === actionItem.source.id + ? { + ...current, + source: { + ...current.source, + checksSummary, + ...hydratedStatus + } + } + : current + ) + setItems((current) => + current.map((candidate) => + candidate.provider === 'gitlab' && candidate.source.id === actionItem.source.id + ? { + ...candidate, + source: { + ...candidate.source, + checksSummary, + ...hydratedStatus + } + } + : candidate + ) + ) + } + return + } + + const [issueResponse, commentsResponse] = await Promise.all([ + client.sendRequest( + 'linear.getIssue', + { + id: actionItem.source.id, + workspaceId: actionItem.source.workspaceId + }, + { timeoutMs: 30_000 } + ), + client.sendRequest( + 'linear.issueComments', + { + issueId: actionItem.source.id, + workspaceId: actionItem.source.workspaceId + }, + { timeoutMs: 30_000 } + ) + ]) + if (!isSuccess(issueResponse)) { + throw new Error(issueResponse.error.message) + } + const issue = issueResponse.result as LinearIssue | null + const comments = isSuccess(commentsResponse) + ? ((commentsResponse.result as DetailComment[]) ?? []) + : [] + if (!issue) { + throw new Error('Details not found') + } + if (!stale) { + setDetailPayload({ + provider: 'linear', + description: issue.description ?? '', + comments, + labels: issue.labels ?? [], + assignee: issue.assignee?.displayName, + project: issue.project, + children: issue.subIssues ?? [] + }) + setActionItem((current) => { + if (current?.provider !== 'linear' || current.source.id !== issue.id) { + return current + } + const currentChildren = current.source.subIssues ?? [] + const nextChildren = issue.subIssues ?? [] + const alreadyHydrated = + current.source.project?.id === issue.project?.id && + currentChildren.length === nextChildren.length && + currentChildren.every((child, index) => child.id === nextChildren[index]?.id) + return alreadyHydrated + ? current + : (createLinearTask(issue) as Extract) + }) + } + } + + void loadDetails() + .catch((err) => { + if (!stale) { + setDetailError(err instanceof Error ? err.message : 'Failed to load details') + } + }) + .finally(() => { + if (!stale) { + setDetailLoading(false) + } + }) + + return () => { + stale = true + } + }, [actionItem, client, detailRefreshSeq, tasksSupported]) + + useEffect(() => { + if (!projectRowItem) { + setProjectRowDetail(null) + setProjectRowDetailLoading(false) + setProjectRowDetailError('') + setProjectTitleDraft('') + setProjectBodyDraft('') + setProjectCommentDraft('') + setProjectEditingCommentId(null) + setProjectEditingCommentDraft('') + setProjectReviewersDraft('') + setExpandedPrFilePath(null) + setPrFileContents({}) + setPrFileLoadingPath(null) + setPrFileCommentDrafts({}) + setProjectFieldDrafts({}) + return + } + + const type = projectRowType(projectRowItem) + const slug = splitRepositorySlug(projectRowItem.content.repository) + setProjectTitleDraft(projectRowItem.content.title) + setProjectBodyDraft(projectRowItem.content.body ?? '') + setProjectCommentDraft('') + setProjectEditingCommentId(null) + setProjectEditingCommentDraft('') + setProjectReviewersDraft('') + setExpandedPrFilePath(null) + setPrFileContents({}) + setPrFileLoadingPath(null) + setPrFileCommentDrafts({}) + setProjectFieldDrafts( + Object.fromEntries( + editableProjectFields(githubProjectTable).map((field) => [ + field.id, + projectFieldDraftValue(projectRowItem, field) + ]) + ) + ) + setProjectRowDetail(null) + setProjectRowDetailError('') + + if (!tasksSupported || !client || !type || !slug || !projectRowItem.content.number) { + setProjectRowDetailLoading(false) + return + } + + let stale = false + setProjectRowDetailLoading(true) + + void client + .sendRequest( + 'github.project.workItemDetailsBySlug', + { + owner: slug.owner, + repo: slug.repo, + host: activeGitHubProjectHost, + number: projectRowItem.content.number, + type + }, + { timeoutMs: 30_000 } + ) + .then((response) => { + if (stale) { + return + } + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as + | { + ok: true + details: { + body?: string + comments?: DetailComment[] + item?: { + labels?: string[] + reviewDecision?: string | null + reviewRequests?: GitHubAssignableUser[] + latestReviews?: GitHubPRReviewSummary[] + } + assignees?: string[] + headSha?: string + baseSha?: string + pullRequestId?: string + checks?: GitHubDetailCheck[] + files?: Array<{ + path: string + oldPath?: string + status?: GitHubDetailFile['status'] + additions?: number + deletions?: number + isBinary?: boolean + viewerViewedState?: 'DISMISSED' | 'VIEWED' | 'UNVIEWED' + }> + } + } + | { ok: false; error: { message: string } } + if (!result.ok) { + throw new Error(result.error.message) + } + setProjectRowDetail({ + provider: 'github', + body: result.details.body ?? '', + comments: result.details.comments ?? [], + labels: result.details.item?.labels ?? projectRowItem.content.labels.map((l) => l.name), + assignees: result.details.assignees ?? [], + reviewDecision: result.details.item?.reviewDecision, + reviewRequests: result.details.item?.reviewRequests ?? [], + latestReviews: result.details.item?.latestReviews ?? [], + headSha: result.details.headSha, + baseSha: result.details.baseSha, + pullRequestId: result.details.pullRequestId, + checks: result.details.checks ?? [], + files: result.details.files ?? [] + }) + }) + .catch((err) => { + if (!stale) { + setProjectRowDetailError(err instanceof Error ? err.message : 'Failed to load details') + } + }) + .finally(() => { + if (!stale) { + setProjectRowDetailLoading(false) + } + }) + + return () => { + stale = true + } + }, [ + activeGitHubProjectHost, + client, + githubProjectTable, + projectRowDetailRefreshSeq, + projectRowItem, + tasksSupported + ]) + + useEffect(() => { + const slug = splitRepositorySlug(projectMetadataRepository) + if (!tasksSupported || !client || !slug) { + setProjectAvailableLabels([]) + setProjectLabelsLoading(false) + setProjectLabelsError('') + return + } + + let stale = false + setProjectAvailableLabels([]) + setProjectLabelsError('') + setProjectLabelsLoading(true) + void client + .sendRequest( + 'github.project.listLabelsBySlug', + { owner: slug.owner, repo: slug.repo, host: activeGitHubProjectHost }, + { timeoutMs: 30_000 } + ) + .then((response) => { + if (stale) { + return + } + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as + | { ok: true; labels?: string[] } + | { ok: false; error?: { message?: string } } + if (!result.ok) { + throw new Error(result.error?.message ?? 'Failed to load labels') + } + setProjectAvailableLabels(result.labels ?? []) + }) + .catch((err) => { + if (!stale) { + setProjectLabelsError(err instanceof Error ? err.message : 'Failed to load labels') + } + }) + .finally(() => { + if (!stale) { + setProjectLabelsLoading(false) + } + }) + + return () => { + stale = true + } + }, [activeGitHubProjectHost, client, projectMetadataRepository, tasksSupported]) + + useEffect(() => { + const slug = splitRepositorySlug(projectMetadataRepository) + if (!tasksSupported || !client || !slug) { + setProjectAssignableUsers([]) + setProjectAssignableUsersLoading(false) + setProjectAssignableUsersError('') + return + } + + let stale = false + setProjectAssignableUsers([]) + setProjectAssignableUsersError('') + setProjectAssignableUsersLoading(true) + void client + .sendRequest( + 'github.project.listAssignableUsersBySlug', + { + owner: slug.owner, + repo: slug.repo, + host: activeGitHubProjectHost, + ...(projectMetadataSeedLogins ? { seedLogins: projectMetadataSeedLogins.split(',') } : {}) + }, + { timeoutMs: 30_000 } + ) + .then((response) => { + if (stale) { + return + } + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as + | { ok: true; users?: GitHubAssignableUser[] } + | { ok: false; error?: { message?: string } } + if (!result.ok) { + throw new Error(result.error?.message ?? 'Failed to load assignees') + } + setProjectAssignableUsers(result.users ?? []) + }) + .catch((err) => { + if (!stale) { + setProjectAssignableUsersError( + err instanceof Error ? err.message : 'Failed to load assignees' + ) + } + }) + .finally(() => { + if (!stale) { + setProjectAssignableUsersLoading(false) + } + }) + + return () => { + stale = true + } + }, [ + activeGitHubProjectHost, + client, + projectMetadataRepository, + projectMetadataSeedLogins, + tasksSupported + ]) + + useEffect(() => { + const slug = splitRepositorySlug(projectIssueTypeRepository) + if (!tasksSupported || !client || !slug) { + setProjectIssueTypes([]) + setProjectIssueTypesLoading(false) + setProjectIssueTypesError('') + return + } + + let stale = false + setProjectIssueTypes([]) + setProjectIssueTypesError('') + setProjectIssueTypesLoading(true) + void client + .sendRequest( + 'github.project.listIssueTypesBySlug', + { owner: slug.owner, repo: slug.repo, host: activeGitHubProjectHost }, + { timeoutMs: 30_000 } + ) + .then((response) => { + if (stale) { + return + } + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as + | { ok: true; types?: GitHubIssueType[] } + | { ok: false; error?: { message?: string } } + if (!result.ok) { + throw new Error(result.error?.message ?? 'Failed to load issue types') + } + setProjectIssueTypes(result.types ?? []) + }) + .catch((err) => { + if (!stale) { + setProjectIssueTypesError( + err instanceof Error ? err.message : 'Failed to load issue types' + ) + } + }) + .finally(() => { + if (!stale) { + setProjectIssueTypesLoading(false) + } + }) + + return () => { + stale = true + } + }, [activeGitHubProjectHost, client, projectIssueTypeRepository, tasksSupported]) + + const getWorkspaceTargetRepo = useCallback( + (item: ActionableTaskItem, repoIdOverride?: string): RepoSummary | null => { + if (item.provider === 'github' || item.provider === 'gitlab') { + return repos.find((entry) => entry.id === item.source.repoId) ?? null + } + if (repoIdOverride) { + return workspaceRepos.find((entry) => entry.id === repoIdOverride) ?? null + } + return workspaceRepos[0] ?? null + }, + [repos, workspaceRepos] + ) + + const workspaceCreateTargetRepo = useMemo( + () => + workspaceCreateDraft + ? getWorkspaceTargetRepo(workspaceCreateDraft.item, workspaceCreateDraft.repoIdOverride) + : null, + [getWorkspaceTargetRepo, workspaceCreateDraft] + ) + const workspaceCreateTargetConnectionId = workspaceCreateTargetRepo?.connectionId ?? null + const workspaceCreateSshGate = deriveWorkspaceSshGate({ + connectionId: workspaceCreateTargetConnectionId, + state: workspaceSshState, + connecting: workspaceSshConnecting + }) + const workspaceCreateSshStatus = workspaceCreateSshGate.status + const workspaceCreateRequiresSshConnection = workspaceCreateSshGate.requiresConnection + const workspaceCreateSshConnectInProgress = workspaceCreateSshGate.connectInProgress + const workspaceCreateSshError = workspaceCreateSshGate.error + const workspaceCreateCanPickRepo = + workspaceCreateDraft?.item.provider === 'linear' && workspaceRepos.length > 1 + const workspaceSparseCheckoutAvailable = + workspaceCreateTargetRepo != null && !workspaceCreateTargetRepo.connectionId + const workspaceSparseDraftParsed = useMemo( + () => + workspaceSparseDraft + ? parseSparsePresetDirectories(workspaceSparseDraft.directoriesText) + : null, + [workspaceSparseDraft] + ) + const workspaceSparseDraftName = workspaceSparseDraft?.name.trim() ?? '' + const workspaceSparseDraftNameCollision = + workspaceSparseDraft && workspaceSparseDraftName + ? (workspaceSparsePresets.find( + (preset) => + preset.id !== workspaceSparseDraft.presetId && + preset.name.toLowerCase() === workspaceSparseDraftName.toLowerCase() + ) ?? null) + : null + const workspaceSparseDraftError = + workspaceSparseDraft && workspaceSparseDraftName.length === 0 + ? 'Name is required.' + : workspaceSparseDraftName.length > 80 + ? 'Name must be 80 characters or fewer.' + : workspaceSparseDraftNameCollision + ? `"${workspaceSparseDraftNameCollision.name}" already exists.` + : (workspaceSparseDraftParsed?.error ?? null) + const canSaveWorkspaceSparseDraft = + workspaceSparseDraft !== null && + workspaceCreateTargetRepo !== null && + workspaceSparseCheckoutAvailable && + workspaceSparsePresetsLoaded && + !workspaceSparsePresetsLoading && + !workspaceSparseSaving && + !workspaceSparseDraftError && + workspaceSparseDraftParsed !== null + + const workspaceAgentOptions = useMemo[]>(() => { + const enabledAgents = filterWorkspaceAgents( + MOBILE_TUI_AGENT_AUTO_PICK_ORDER, + runtimeTaskSettings.disabledTuiAgents + ) + const availableAgents = + workspaceDetectedAgentIds === null + ? new Set(enabledAgents) + : new Set(enabledAgents.filter((agent) => workspaceDetectedAgentIds.has(agent))) + if ( + workspaceAgent && + workspaceAgent !== 'blank' && + isWorkspaceAgentEnabled(workspaceAgent, runtimeTaskSettings.disabledTuiAgents) && + (workspaceDetectedAgentIds === null || workspaceDetectedAgentIds.has(workspaceAgent)) + ) { + availableAgents.add(workspaceAgent) + } + const agents = MOBILE_TUI_AGENT_AUTO_PICK_ORDER.filter((agent) => availableAgents.has(agent)) + return [ + ...agents.map((agent) => ({ + value: agent, + label: workspaceAgentLabel(agent), + subtitle: agent, + renderIcon: () => + })), + { + value: 'blank' as const, + label: workspaceAgentLabel('blank'), + subtitle: 'Open a shell', + renderIcon: () => + } + ] + }, [runtimeTaskSettings.disabledTuiAgents, workspaceAgent, workspaceDetectedAgentIds]) + const openWorkspaceCreate = useCallback((item: ActionableTaskItem, repoIdOverride?: string) => { + const suggestedName = taskWorkspaceSuggestedName(item) + setWorkspaceCreateDraft({ item, ...(repoIdOverride ? { repoIdOverride } : {}) }) + setWorkspaceNameDraft(suggestedName) + setWorkspaceLastAutoName(suggestedName) + setWorkspaceBranchAutoName('') + setWorkspaceBranchNameOverride(undefined) + setWorkspaceBaseBranch(null) + setWorkspaceBaseBranchQuery('') + setWorkspaceBaseBranchResults([]) + setWorkspaceBaseBranchLoading(false) + setWorkspaceBaseBranchError('') + setWorkspaceSparsePresetId(null) + setWorkspaceSparsePresets([]) + setWorkspaceSparsePresetsLoaded(false) + setWorkspaceSparsePresetsError('') + setWorkspaceSparseReloadKey(0) + setWorkspaceSparseDraft(null) + setWorkspaceSparseSaving(false) + setWorkspaceAgentOverridden(false) + setWorkspaceAgent(null) + setOrcaYamlTrustPrompt(null) + setShowWorkspaceAgentPicker(false) + setShowWorkspaceCreateRepoPicker(false) + setShowWorkspaceAdvanced(false) + setShowWorkspaceBaseBranchPicker(false) + setShowWorkspaceSparsePicker(false) + setError('') + }, []) + + const handleWorkspaceNameDraftChange = useCallback( + (nextName: string): void => { + if (!nextName.trim()) { + setWorkspaceLastAutoName('') + } else if (workspaceNameDraft !== workspaceLastAutoName) { + setWorkspaceLastAutoName('') + } + if (workspaceBranchNameOverride && nextName !== workspaceBranchAutoName) { + setWorkspaceBranchNameOverride(undefined) + setWorkspaceBranchAutoName('') + } + setWorkspaceNameDraft(nextName) + }, + [ + workspaceBranchAutoName, + workspaceBranchNameOverride, + workspaceLastAutoName, + workspaceNameDraft + ] + ) + + const selectWorkspaceBaseBranch = useCallback( + (branch: BaseRefSearchResult): void => { + const selection = resolveComposerBranchSelection({ + refName: branch.refName, + localBranchName: branch.localBranchName, + currentName: workspaceNameDraft, + lastAutoName: workspaceLastAutoName + }) + setWorkspaceBaseBranch(branch) + setWorkspaceBranchAutoName(selection.branchAutoName) + setWorkspaceBranchNameOverride(selection.branchNameOverride) + if (selection.name !== undefined && selection.lastAutoName !== undefined) { + setWorkspaceNameDraft(selection.name) + setWorkspaceLastAutoName(selection.lastAutoName) + } + setShowWorkspaceBaseBranchPicker(false) + }, + [workspaceLastAutoName, workspaceNameDraft] + ) + + const clearWorkspaceBaseBranch = useCallback((): void => { + setWorkspaceBaseBranch(null) + setWorkspaceBranchAutoName('') + setWorkspaceBranchNameOverride(undefined) + setShowWorkspaceBaseBranchPicker(false) + }, []) + + useEffect(() => { + if (!tasksSupported || !client || !workspaceCreateDraft || !workspaceCreateTargetRepo) { + setWorkspaceSparsePresets([]) + setWorkspaceSparsePresetsLoading(false) + setWorkspaceSparsePresetsLoaded(false) + setWorkspaceSparsePresetsError('') + setWorkspaceSparsePresetId(null) + setWorkspaceSparseDraft(null) + return + } + if (workspaceCreateTargetRepo.connectionId) { + setWorkspaceSparsePresets([]) + setWorkspaceSparsePresetsLoading(false) + setWorkspaceSparsePresetsLoaded(false) + setWorkspaceSparsePresetsError('') + setWorkspaceSparsePresetId(null) + setWorkspaceSparseDraft(null) + return + } + + let stale = false + setWorkspaceSparsePresetsLoading(true) + setWorkspaceSparsePresetsLoaded(false) + setWorkspaceSparsePresetsError('') + void client + .sendRequest('repo.sparsePresets', { repo: `id:${workspaceCreateTargetRepo.id}` }) + .then((response) => { + if (stale) { + return + } + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const presets = (response.result as { presets?: SparsePreset[] }).presets ?? [] + setWorkspaceSparsePresets(presets) + setWorkspaceSparsePresetsLoaded(true) + setWorkspaceSparsePresetId((current) => + current && presets.some((preset) => preset.id === current) ? current : null + ) + }) + .catch((err) => { + if (!stale) { + setWorkspaceSparsePresets([]) + setWorkspaceSparsePresetsLoaded(false) + setWorkspaceSparsePresetId(null) + setWorkspaceSparsePresetsError( + err instanceof Error ? err.message : 'Failed to load sparse presets.' + ) + } + }) + .finally(() => { + if (!stale) { + setWorkspaceSparsePresetsLoading(false) + } + }) + + return () => { + stale = true + } + }, [ + client, + tasksSupported, + workspaceCreateDraft, + workspaceCreateTargetRepo, + workspaceSparseReloadKey + ]) + + useEffect(() => { + if ( + !client || + !tasksSupported || + !workspaceCreateDraft || + !workspaceCreateTargetRepo || + !showWorkspaceBaseBranchPicker + ) { + setWorkspaceBaseBranchResults([]) + setWorkspaceBaseBranchLoading(false) + setWorkspaceBaseBranchError('') + return + } + const query = workspaceBaseBranchQuery.trim() + if (!query) { + setWorkspaceBaseBranchResults([]) + setWorkspaceBaseBranchLoading(false) + setWorkspaceBaseBranchError('') + return + } + + let stale = false + setWorkspaceBaseBranchLoading(true) + setWorkspaceBaseBranchError('') + void client + .sendRequest( + 'repo.searchRefs', + { repo: `id:${workspaceCreateTargetRepo.id}`, query, limit: 20 }, + { timeoutMs: 30_000 } + ) + .then((response) => { + if (stale) { + return + } + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as { + refDetails?: BaseRefSearchResult[] + refs?: string[] + } + setWorkspaceBaseBranchResults( + result.refDetails ?? + (result.refs ?? []).map((refName) => ({ refName, localBranchName: refName })) + ) + }) + .catch((err) => { + if (!stale) { + setWorkspaceBaseBranchResults([]) + setWorkspaceBaseBranchError( + err instanceof Error ? err.message : 'Failed to search branches.' + ) + } + }) + .finally(() => { + if (!stale) { + setWorkspaceBaseBranchLoading(false) + } + }) + + return () => { + stale = true + } + }, [ + client, + tasksSupported, + showWorkspaceBaseBranchPicker, + workspaceBaseBranchQuery, + workspaceCreateDraft, + workspaceCreateTargetRepo + ]) + + const startNewWorkspaceSparsePreset = useCallback(() => { + if ( + !workspaceSparseCheckoutAvailable || + !workspaceSparsePresetsLoaded || + workspaceSparsePresetsLoading + ) { + return + } + setWorkspaceSparseDraft({ mode: 'new', name: '', directoriesText: '' }) + setShowWorkspaceSparsePicker(false) + }, [ + workspaceSparseCheckoutAvailable, + workspaceSparsePresetsLoaded, + workspaceSparsePresetsLoading + ]) + + const startEditWorkspaceSparsePreset = useCallback( + (preset: SparsePreset) => { + if ( + !workspaceSparseCheckoutAvailable || + !workspaceSparsePresetsLoaded || + workspaceSparsePresetsLoading + ) { + return + } + setWorkspaceSparseDraft({ + mode: 'edit', + presetId: preset.id, + name: preset.name, + directoriesText: preset.directories.join('\n') + }) + setShowWorkspaceSparsePicker(false) + }, + [workspaceSparseCheckoutAvailable, workspaceSparsePresetsLoaded, workspaceSparsePresetsLoading] + ) + + const saveWorkspaceSparsePreset = useCallback(async (): Promise => { + if ( + !client || + !tasksSupported || + !workspaceCreateTargetRepo || + !workspaceSparseDraft || + !workspaceSparseDraftParsed || + !canSaveWorkspaceSparseDraft + ) { + return + } + setWorkspaceSparseSaving(true) + setWorkspaceSparsePresetsError('') + try { + const response = await client.sendRequest('repo.saveSparsePreset', { + repo: `id:${workspaceCreateTargetRepo.id}`, + ...(workspaceSparseDraft.presetId ? { id: workspaceSparseDraft.presetId } : {}), + name: workspaceSparseDraftName, + directories: workspaceSparseDraftParsed.directories + }) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const saved = (response.result as { preset?: SparsePreset }).preset + if (!saved) { + throw new Error('Failed to save sparse preset.') + } + setWorkspaceSparsePresets((current) => { + const withoutSaved = current.filter((preset) => preset.id !== saved.id) + return sortSparsePresetsByName([...withoutSaved, saved]) + }) + setWorkspaceSparsePresetsLoaded(true) + if (workspaceSparseDraft.mode === 'new' || workspaceSparsePresetId === saved.id) { + setWorkspaceSparsePresetId(saved.id) + } + setWorkspaceSparseDraft(null) + } catch (err) { + setWorkspaceSparsePresetsError( + err instanceof Error ? err.message : 'Failed to save sparse preset.' + ) + } finally { + setWorkspaceSparseSaving(false) + } + }, [ + canSaveWorkspaceSparseDraft, + client, + tasksSupported, + workspaceCreateTargetRepo, + workspaceSparseDraft, + workspaceSparseDraftName, + workspaceSparseDraftParsed, + workspaceSparsePresetId + ]) + + useEffect(() => { + if (!tasksSupported || !client || !workspaceCreateDraft || !workspaceCreateTargetConnectionId) { + setWorkspaceSshState(null) + setWorkspaceSshConnecting(false) + return + } + + let stale = false + void client + .sendRequest('ssh.getState', { targetId: workspaceCreateTargetConnectionId }) + .then((response) => { + if (stale) { + return + } + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const state = (response.result as { state?: SshConnectionState | null }).state ?? null + setWorkspaceSshState( + state ?? { + targetId: workspaceCreateTargetConnectionId, + status: 'disconnected', + error: null, + reconnectAttempt: 0 + } + ) + }) + .catch((err) => { + if (!stale) { + setWorkspaceSshState({ + targetId: workspaceCreateTargetConnectionId, + status: 'error', + error: err instanceof Error ? err.message : 'Failed to read SSH connection state.', + reconnectAttempt: 0 + }) + } + }) + + return () => { + stale = true + } + }, [client, tasksSupported, workspaceCreateDraft, workspaceCreateTargetConnectionId]) + + const connectWorkspaceSshRepo = useCallback(async (): Promise => { + if (!client || !tasksSupported || !workspaceCreateTargetConnectionId) { + return + } + setWorkspaceSshConnecting(true) + setWorkspaceSshState({ + targetId: workspaceCreateTargetConnectionId, + status: 'connecting', + error: null, + reconnectAttempt: 0 + }) + try { + const response = await client.sendRequest( + 'ssh.connect', + { targetId: workspaceCreateTargetConnectionId }, + { timeoutMs: 120_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const state = (response.result as { state?: SshConnectionState | null }).state + setWorkspaceSshState( + state ?? { + targetId: workspaceCreateTargetConnectionId, + status: 'connected', + error: null, + reconnectAttempt: 0 + } + ) + } catch (err) { + setWorkspaceSshState({ + targetId: workspaceCreateTargetConnectionId, + status: 'error', + error: err instanceof Error ? err.message : 'Failed to connect to SSH repository.', + reconnectAttempt: 0 + }) + } finally { + setWorkspaceSshConnecting(false) + } + }, [client, tasksSupported, workspaceCreateTargetConnectionId]) + + const ensureWorkspaceSshReady = useCallback( + async (repo: RepoSummary): Promise => { + if (!repo.connectionId || !client || !tasksSupported) { + return + } + if ( + workspaceSshState?.targetId === repo.connectionId && + workspaceSshState.status === 'connected' + ) { + return + } + const response = await client.sendRequest('ssh.getState', { targetId: repo.connectionId }) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const state = (response.result as { state?: SshConnectionState | null }).state ?? null + if (state) { + setWorkspaceSshState(state) + } + if (state?.status !== 'connected') { + throw new Error(`Connect ${repo.displayName} before creating a workspace.`) + } + }, + [client, tasksSupported, workspaceSshState] + ) + + useEffect(() => { + if (!tasksSupported || !workspaceCreateDraft || !client || !workspaceCreateTargetRepo) { + setWorkspaceDetectedAgentIds(null) + return + } + if (workspaceCreateTargetRepo.connectionId && workspaceCreateSshStatus !== 'connected') { + // Why: remote agent detection runs on the SSH host through the relay; a + // disconnected repo would fail and cache an empty agent list. + setWorkspaceDetectedAgentIds(null) + return + } + let stale = false + setWorkspaceDetectedAgentIds(null) + const request = workspaceCreateTargetRepo.connectionId + ? client.sendRequest('preflight.detectRemoteAgents', { + connectionId: workspaceCreateTargetRepo.connectionId + }) + : client.sendRequest('preflight.detectAgents') + void request + .then((response) => { + if (stale) { + return + } + setWorkspaceDetectedAgentIds( + isSuccess(response) ? new Set(response.result as string[]) : new Set() + ) + }) + .catch(() => { + if (!stale) { + setWorkspaceDetectedAgentIds(new Set()) + } + }) + return () => { + stale = true + } + }, [ + client, + tasksSupported, + workspaceCreateDraft, + workspaceCreateSshStatus, + workspaceCreateTargetRepo + ]) + + const workspaceAgentSelection = resolveWorkspaceAgentSelection({ + selectionActive: tasksSupported && workspaceCreateDraft !== null, + settings: runtimeTaskSettings, + detectedAgentIds: workspaceDetectedAgentIds, + agent: workspaceAgent, + overridden: workspaceAgentOverridden + }) + if ( + workspaceAgentSelection.agent !== workspaceAgent || + workspaceAgentSelection.overridden !== workspaceAgentOverridden + ) { + // Why: the drawer can open before SSH/local detection settles. Resolve the + // visible agent before commit so users do not see an unavailable override. + setWorkspaceAgent(workspaceAgentSelection.agent) + setWorkspaceAgentOverridden(workspaceAgentSelection.overridden) + } + + const resolvedWorkspaceAgent = useMemo( + () => workspaceAgent ?? pickWorkspaceAgent(runtimeTaskSettings, workspaceDetectedAgentIds), + [runtimeTaskSettings, workspaceAgent, workspaceDetectedAgentIds] + ) + const workspaceAgentDetectionPending = + workspaceCreateDraft != null && + workspaceCreateTargetRepo != null && + !workspaceCreateRequiresSshConnection && + workspaceDetectedAgentIds === null + + const resolveCreateSetupDecision = useCallback( + async ( + repo: RepoSummary, + override?: Exclude + ): Promise< + | { kind: 'decision'; decision: SetupDecision; setupTrust?: RepoHooksResponse['setupTrust'] } + | { + kind: 'prompt' + command: string + source: string | null + setupTrust?: RepoHooksResponse['setupTrust'] + } + > => { + if (!client || !tasksSupported) { + return { kind: 'decision', decision: override ?? 'inherit' } + } + const response = await client.sendRequest('repo.hooks', { repo: `id:${repo.id}` }) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as RepoHooksResponse + const setupCommand = result.hooks?.scripts?.setup?.trim() + const setupTrust = normalizeSetupHookTrust(result.setupTrust) ?? undefined + if (!setupCommand) { + return { kind: 'decision', decision: 'inherit' } + } + if (override) { + return { kind: 'decision', decision: override, setupTrust } + } + const setupRunPolicy = result.setupRunPolicy ?? 'run-by-default' + if (setupRunPolicy === 'ask') { + return { kind: 'prompt', command: setupCommand, source: result.source, setupTrust } + } + return { + kind: 'decision', + decision: setupRunPolicy === 'run-by-default' ? 'run' : 'skip', + setupTrust + } + }, + [client, tasksSupported] + ) + + const createWorkspace = useCallback( + async ( + item: ActionableTaskItem, + repoIdOverride?: string, + setupOverride?: Exclude, + agentOverride?: WorkspaceAgentChoice, + workspaceNameOverride?: string, + noteOverride?: string, + baseBranchOverride?: string, + branchNameOverride?: string, + sparseCheckoutOverride?: { directories: string[]; presetId?: string }, + approvedSetupContentHash?: string + ): Promise => { + if (!client || !tasksSupported || !taskStateHydrated) { + return + } + setCreatingKey(item.key) + setError('') + try { + const targetRepo = getWorkspaceTargetRepo(item, repoIdOverride) + if (!targetRepo) { + throw new Error( + item.provider === 'linear' + ? 'Add a Git repository before creating a Linear workspace.' + : 'Repository not found.' + ) + } + await ensureWorkspaceSshReady(targetRepo) + let latestRuntimeTaskSettings = runtimeTaskSettings + try { + const settingsResponse = await client.sendRequest('settings.get', { + includeAgentCatalog: false + }) + if (isSuccess(settingsResponse)) { + latestRuntimeTaskSettings = (( + settingsResponse.result as { settings?: RuntimeTaskSettings } + ).settings ?? {}) as RuntimeTaskSettings + setRuntimeTaskSettings(latestRuntimeTaskSettings) + } + } catch { + // Best-effort refresh; the runtime still validates agent availability before spawning. + } + const selectedAgent = + agentOverride && + (agentOverride === 'blank' || + isWorkspaceAgentEnabled(agentOverride, latestRuntimeTaskSettings.disabledTuiAgents)) + ? agentOverride + : pickWorkspaceAgent(latestRuntimeTaskSettings, workspaceDetectedAgentIds) + if ( + agentOverride && + agentOverride !== 'blank' && + !isWorkspaceAgentEnabled(agentOverride, latestRuntimeTaskSettings.disabledTuiAgents) + ) { + setWorkspaceAgent(selectedAgent) + setWorkspaceAgentOverridden(false) + throw new Error('Selected agent is disabled. Choose an enabled agent before creating.') + } + const setupResolution = await resolveCreateSetupDecision(targetRepo, setupOverride) + const comment = noteOverride?.trim() + if (setupResolution.kind === 'prompt') { + // Why: desktop does not silently create when a repo policy says setup + // requires a per-workspace decision. Mobile must ask before create too. + setSetupPrompt({ + item, + ...(repoIdOverride ? { repoIdOverride } : {}), + ...(agentOverride ? { agentOverride } : {}), + ...(workspaceNameOverride ? { workspaceNameOverride } : {}), + ...(comment ? { noteOverride: comment } : {}), + ...(baseBranchOverride ? { baseBranchOverride } : {}), + ...(branchNameOverride ? { branchNameOverride } : {}), + ...(sparseCheckoutOverride ? { sparseCheckoutOverride } : {}), + repoName: targetRepo.displayName, + command: setupResolution.command, + source: setupResolution.source + }) + return + } + const setupDecision = setupResolution.decision + if ( + setupDecision === 'run' && + setupResolution.setupTrust && + setupResolution.setupTrust.contentHash !== approvedSetupContentHash && + !isSetupHookTrusted( + trustedOrcaHooks, + targetRepo.id, + setupResolution.setupTrust.contentHash + ) + ) { + // Why: desktop prompts before running repo-owned orca.yaml hooks. Mobile + // stores the same trust hash in persisted UI state so either surface can + // approve the script version for future workspace creates. + setSetupPrompt(null) + setOrcaYamlTrustPrompt({ + item, + ...(repoIdOverride ? { repoIdOverride } : {}), + setupOverride: 'run', + ...(agentOverride ? { agentOverride } : {}), + ...(workspaceNameOverride ? { workspaceNameOverride } : {}), + ...(comment ? { noteOverride: comment } : {}), + ...(baseBranchOverride ? { baseBranchOverride } : {}), + ...(branchNameOverride ? { branchNameOverride } : {}), + ...(sparseCheckoutOverride ? { sparseCheckoutOverride } : {}), + repoId: targetRepo.id, + repoName: targetRepo.displayName, + scriptContent: setupResolution.setupTrust.scriptContent, + contentHash: setupResolution.setupTrust.contentHash, + previouslyApproved: wasSetupHookPreviouslyApproved(trustedOrcaHooks, targetRepo.id) + }) + return + } + let params: Record + if (item.provider === 'github') { + const source = item.source + let prStartPoint: { baseBranch: string; pushTarget?: GitPushTarget } | undefined + if ( + shouldResolveHostedReviewStartPoint({ + type: source.type, + baseBranchOverride + }) + ) { + const response = await client.sendRequest( + 'worktree.resolvePrBase', + { + repo: `id:${source.repoId}`, + prNumber: source.number, + ...(source.branchName ? { headRefName: source.branchName } : {}), + ...(source.isCrossRepository !== undefined + ? { isCrossRepository: source.isCrossRepository } + : {}) + }, + { timeoutMs: 30_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as + | { baseBranch: string; pushTarget?: GitPushTarget } + | { error: string } + if ('error' in result) { + throw new Error(result.error) + } + prStartPoint = result + } + params = buildTaskWorkspaceCreateParams({ + item, + targetRepoId: targetRepo.id, + setupDecision, + agent: selectedAgent, + workspaceName: workspaceNameOverride, + note: comment, + baseBranch: baseBranchOverride, + branchNameOverride, + sparseCheckout: sparseCheckoutOverride, + hostedStartPoint: prStartPoint + }) + } else if (item.provider === 'gitlab') { + const source = item.source + let mrStartPoint: { baseBranch: string; pushTarget?: GitPushTarget } | undefined + if ( + shouldResolveHostedReviewStartPoint({ + type: source.type, + baseBranchOverride + }) + ) { + const response = await client.sendRequest( + 'worktree.resolveMrBase', + { + repo: `id:${source.repoId}`, + mrIid: source.number, + ...(source.branchName ? { sourceBranch: source.branchName } : {}), + ...(source.isCrossRepository !== undefined + ? { isCrossRepository: source.isCrossRepository } + : {}) + }, + { timeoutMs: 30_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as + | { baseBranch: string; pushTarget?: GitPushTarget } + | { error: string } + if ('error' in result) { + throw new Error(result.error) + } + mrStartPoint = result + } + params = buildTaskWorkspaceCreateParams({ + item, + targetRepoId: targetRepo.id, + setupDecision, + agent: selectedAgent, + workspaceName: workspaceNameOverride, + note: comment, + baseBranch: baseBranchOverride, + branchNameOverride, + sparseCheckout: sparseCheckoutOverride, + hostedStartPoint: mrStartPoint + }) + } else { + params = buildTaskWorkspaceCreateParams({ + item, + targetRepoId: targetRepo.id, + setupDecision, + agent: selectedAgent, + workspaceName: workspaceNameOverride, + note: comment, + baseBranch: baseBranchOverride, + branchNameOverride, + sparseCheckout: sparseCheckoutOverride + }) + } + const response = await client.sendRequest('worktree.create', params, { + timeoutMs: WORKTREE_CREATE_TIMEOUT_MS + }) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as { + worktree: { id: string; displayName?: string } + warning?: string + } + setActionItem(null) + setWorkspaceCreateDraft(null) + setSetupPrompt(null) + const name = result.worktree.displayName ?? item.title + const queryParams = new URLSearchParams({ name, created: '1' }) + if (result.warning) { + queryParams.set('warning', result.warning) + } + router.push( + `/h/${hostId}/session/${encodeURIComponent(result.worktree.id)}?${queryParams.toString()}` + ) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to create workspace') + } finally { + setCreatingKey(null) + } + }, + [ + client, + ensureWorkspaceSshReady, + getWorkspaceTargetRepo, + hostId, + resolveCreateSetupDecision, + router, + runtimeTaskSettings, + taskStateHydrated, + tasksSupported, + trustedOrcaHooks, + workspaceDetectedAgentIds + ] + ) + + const createWorkspaceFromProjectRow = useCallback( + async (row: GitHubProjectRow): Promise => { + if (!tasksSupported) { + return + } + const kind = projectRowType(row) + const repo = findProjectRowRepo(row) + if (!kind || !row.content.number || !row.content.url) { + setError('Add the project item repository to Orca before creating a workspace.') + return + } + if (!repo) { + const slug = splitRepositorySlug(row.content.repository) + setProjectRepoNotInOrca({ + owner: slug?.owner ?? 'Unknown', + repo: slug?.repo ?? row.content.repository ?? 'repository', + url: row.content.url ?? null + }) + return + } + const state: GitHubWorkItem['state'] = + row.content.state === 'MERGED' + ? 'merged' + : row.content.state === 'CLOSED' + ? 'closed' + : row.content.isDraft + ? 'draft' + : 'open' + const source: GitHubWorkItem = { + id: row.id, + type: kind, + number: row.content.number, + title: row.content.title, + state, + url: row.content.url, + labels: row.content.labels.map((label) => label.name), + updatedAt: row.updatedAt, + author: null, + repoId: repo.id, + repoName: repo.displayName + } + openWorkspaceCreate({ + key: `github-project:${row.id}`, + provider: 'github', + title: row.content.title, + subtitle: `${repo.displayName} #${row.content.number}`, + status: projectRowStatusLabel(row), + updatedAt: row.updatedAt, + source + }) + }, + [findProjectRowRepo, openWorkspaceCreate, tasksSupported] + ) + + const mutateProjectRowIssueOrPr = useCallback( + async ( + row: GitHubProjectRow, + updates: { title?: string; body?: string; state?: 'open' | 'closed' } + ): Promise => { + if (!client || projectMutating) { + return + } + const type = projectRowType(row) + const slug = splitRepositorySlug(row.content.repository) + if (!type || !slug || !row.content.number) { + setProjectRowDetailError('This project item cannot be edited from mobile.') + return + } + setProjectMutating(true) + try { + const response = await client.sendRequest( + type === 'issue' + ? 'github.project.updateIssueBySlug' + : 'github.project.updatePullRequestBySlug', + { + owner: slug.owner, + repo: slug.repo, + host: activeGitHubProjectHost, + number: row.content.number, + updates + }, + { timeoutMs: 30_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as { ok?: boolean; error?: { message?: string } } + if (result.ok === false) { + throw new Error(result.error?.message ?? 'Failed to update GitHub item') + } + setProjectRowItem((current) => { + if (!current || current.id !== row.id) { + return current + } + return { + ...current, + content: { + ...current.content, + ...(updates.title !== undefined ? { title: updates.title } : {}), + ...(updates.body !== undefined ? { body: updates.body } : {}), + ...(updates.state !== undefined + ? { state: updates.state === 'closed' ? 'CLOSED' : 'OPEN' } + : {}) + } + } + }) + setGithubProjectTable((table) => + table + ? { + ...table, + rows: table.rows.map((candidate) => + candidate.id === row.id + ? { + ...candidate, + content: { + ...candidate.content, + ...(updates.title !== undefined ? { title: updates.title } : {}), + ...(updates.body !== undefined ? { body: updates.body } : {}), + ...(updates.state !== undefined + ? { state: updates.state === 'closed' ? 'CLOSED' : 'OPEN' } + : {}) + } + } + : candidate + ) + } + : table + ) + if (updates.body !== undefined) { + setProjectRowDetail((current) => + current?.provider === 'github' ? { ...current, body: updates.body ?? '' } : current + ) + } + } catch (err) { + setProjectRowDetailError(err instanceof Error ? err.message : 'Failed to update item') + } finally { + setProjectMutating(false) + } + }, + [activeGitHubProjectHost, client, projectMutating] + ) + + const addProjectRowComment = useCallback( + async (row: GitHubProjectRow): Promise => { + if (!client || projectMutating) { + return + } + const slug = splitRepositorySlug(row.content.repository) + const body = projectCommentDraft.trim() + if (!slug || !row.content.number || !body) { + return + } + setProjectMutating(true) + try { + const response = await client.sendRequest( + 'github.project.addIssueCommentBySlug', + { + owner: slug.owner, + repo: slug.repo, + host: activeGitHubProjectHost, + number: row.content.number, + body + }, + { timeoutMs: 30_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as + | { ok: true; comment?: DetailComment } + | { ok: false; error?: { message?: string } } + if (!result.ok) { + throw new Error(result.error?.message ?? 'Failed to add comment') + } + setProjectCommentDraft('') + if (result.comment) { + setProjectRowDetail((current) => + current?.provider === 'github' + ? { ...current, comments: [...current.comments, result.comment as DetailComment] } + : current + ) + } + } catch (err) { + setProjectRowDetailError(err instanceof Error ? err.message : 'Failed to add comment') + } finally { + setProjectMutating(false) + } + }, + [activeGitHubProjectHost, client, projectCommentDraft, projectMutating] + ) + + const updateProjectRowComment = useCallback( + async (row: GitHubProjectRow, comment: DetailComment): Promise => { + if (!client || projectMutating) { + return + } + const slug = splitRepositorySlug(row.content.repository) + const commentId = Number(comment.id) + const body = projectEditingCommentDraft.trim() + if (!slug || !Number.isInteger(commentId) || commentId <= 0 || !body) { + setProjectRowDetailError('This project comment cannot be edited from mobile.') + return + } + setProjectMutating(true) + setProjectRowDetailError('') + try { + const response = await client.sendRequest( + 'github.project.updateIssueCommentBySlug', + { + owner: slug.owner, + repo: slug.repo, + host: activeGitHubProjectHost, + commentId, + body + }, + { timeoutMs: 30_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as { + ok?: boolean + error?: string | { message?: string } + } + if (result.ok === false) { + throw new Error( + typeof result.error === 'string' + ? result.error + : (result.error?.message ?? 'Failed to edit comment') + ) + } + setProjectRowDetail((current) => + current?.provider === 'github' + ? { + ...current, + comments: current.comments.map((candidate) => + Number(candidate.id) === commentId ? { ...candidate, body } : candidate + ) + } + : current + ) + setProjectEditingCommentId(null) + setProjectEditingCommentDraft('') + } catch (err) { + setProjectRowDetailError(err instanceof Error ? err.message : 'Failed to edit comment') + } finally { + setProjectMutating(false) + } + }, + [activeGitHubProjectHost, client, projectEditingCommentDraft, projectMutating] + ) + + const deleteProjectRowComment = useCallback( + async (row: GitHubProjectRow, comment: DetailComment): Promise => { + if (!client || projectMutating) { + return + } + const slug = splitRepositorySlug(row.content.repository) + const commentId = Number(comment.id) + if (!slug || !Number.isInteger(commentId) || commentId <= 0) { + setProjectRowDetailError('This project comment cannot be deleted from mobile.') + return + } + setProjectMutating(true) + setProjectRowDetailError('') + try { + const response = await client.sendRequest( + 'github.project.deleteIssueCommentBySlug', + { + owner: slug.owner, + repo: slug.repo, + host: activeGitHubProjectHost, + commentId + }, + { timeoutMs: 30_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as { + ok?: boolean + error?: string | { message?: string } + } + if (result.ok === false) { + throw new Error( + typeof result.error === 'string' + ? result.error + : (result.error?.message ?? 'Failed to delete comment') + ) + } + setProjectRowDetail((current) => + current?.provider === 'github' + ? { + ...current, + comments: current.comments.filter((candidate) => Number(candidate.id) !== commentId) + } + : current + ) + if (projectEditingCommentId === String(comment.id)) { + setProjectEditingCommentId(null) + setProjectEditingCommentDraft('') + } + } catch (err) { + setProjectRowDetailError(err instanceof Error ? err.message : 'Failed to delete comment') + } finally { + setProjectMutating(false) + } + }, + [activeGitHubProjectHost, client, projectEditingCommentId, projectMutating] + ) + + const toggleProjectGitHubReviewThread = useCallback( + async (row: GitHubProjectRow, comment: DetailComment): Promise => { + const repo = findProjectRowRepo(row) + if ( + !client || + projectMutating || + row.itemType !== 'PULL_REQUEST' || + !repo || + !comment.threadId + ) { + return + } + const resolve = !comment.isResolved + setProjectMutating(true) + setProjectRowDetailError('') + try { + const response = await client.sendRequest( + 'github.resolveReviewThread', + { + repo: `id:${repo.id}`, + prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost), + threadId: comment.threadId, + resolve + }, + { timeoutMs: 30_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + if (response.result !== true) { + throw new Error(resolve ? 'Failed to resolve thread' : 'Failed to reopen thread') + } + setProjectRowDetail((current) => + current?.provider === 'github' + ? { + ...current, + comments: current.comments.map((candidate) => + candidate.threadId === comment.threadId + ? { ...candidate, isResolved: resolve } + : candidate + ) + } + : current + ) + } catch (err) { + setProjectRowDetailError( + err instanceof Error ? err.message : 'Failed to update review thread' + ) + } finally { + setProjectMutating(false) + } + }, + [activeGitHubProjectHost, client, findProjectRowRepo, projectMutating] + ) + + const replyToProjectGitHubComment = useCallback( + async (row: GitHubProjectRow, comment: DetailComment): Promise => { + const repo = findProjectRowRepo(row) + if (!client || projectMutating || !repo || !row.content.number) { + return + } + const key = String(comment.id) + const body = (itemReplyDrafts[key] ?? '').trim() + if (!body) { + return + } + setProjectMutating(true) + setProjectRowDetailError('') + try { + const canUseReviewReply = + row.itemType === 'PULL_REQUEST' && + comment.path && + typeof comment.line === 'number' && + typeof comment.id === 'number' + const response = canUseReviewReply + ? await client.sendRequest( + 'github.addPRReviewCommentReply', + { + repo: `id:${repo.id}`, + prNumber: row.content.number, + prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost), + commentId: comment.id, + body, + threadId: comment.threadId, + path: comment.path, + line: comment.line + }, + { timeoutMs: 30_000 } + ) + : await client.sendRequest( + 'github.addIssueComment', + { + repo: `id:${repo.id}`, + number: row.content.number, + prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost), + body: `@${commentAuthor(comment)} ${body}`, + type: projectRowType(row) ?? 'issue' + }, + { timeoutMs: 30_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as { + ok?: boolean + error?: string + comment?: DetailComment + } + if (result.ok === false) { + throw new Error(result.error ?? 'Failed to reply') + } + const reply: DetailComment = result.comment ?? { + id: `local-${Date.now()}`, + body, + createdAt: new Date().toISOString(), + author: 'You', + path: comment.path, + line: comment.line, + threadId: comment.threadId + } + setItemReplyDrafts((current) => { + const next = { ...current } + delete next[key] + return next + }) + setProjectRowDetail((current) => + current?.provider === 'github' + ? { ...current, comments: [...current.comments, reply] } + : current + ) + } catch (err) { + setProjectRowDetailError(err instanceof Error ? err.message : 'Failed to reply') + } finally { + setProjectMutating(false) + } + }, + [activeGitHubProjectHost, client, findProjectRowRepo, itemReplyDrafts, projectMutating] + ) + + const mutateProjectRowMetadata = useCallback( + async ( + row: GitHubProjectRow, + updates: { + addLabels?: string[] + removeLabels?: string[] + addAssignees?: string[] + removeAssignees?: string[] + } + ): Promise => { + if (!client || projectMutating) { + return + } + const slug = splitRepositorySlug(row.content.repository) + if (!slug || !row.content.number) { + setProjectRowDetailError('This project item cannot be edited from mobile.') + return + } + setProjectMutating(true) + try { + const response = await client.sendRequest( + 'github.project.updateIssueBySlug', + { + owner: slug.owner, + repo: slug.repo, + host: activeGitHubProjectHost, + number: row.content.number, + updates + }, + { timeoutMs: 30_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as { ok?: boolean; error?: { message?: string } } + if (result.ok === false) { + throw new Error(result.error?.message ?? 'Failed to update GitHub item') + } + const applyContentUpdate = (candidate: GitHubProjectRow): GitHubProjectRow => { + const labels = new Map(candidate.content.labels.map((label) => [label.name, label])) + for (const label of updates.addLabels ?? []) { + if (!labels.has(label)) { + labels.set(label, { name: label, color: '808080' }) + } + } + for (const label of updates.removeLabels ?? []) { + labels.delete(label) + } + const assignees = new Map( + candidate.content.assignees.map((assignee) => [assignee.login, assignee]) + ) + for (const login of updates.addAssignees ?? []) { + if (!assignees.has(login)) { + assignees.set(login, { login, name: null }) + } + } + for (const login of updates.removeAssignees ?? []) { + assignees.delete(login) + } + return { + ...candidate, + content: { + ...candidate.content, + labels: [...labels.values()], + assignees: [...assignees.values()] + } + } + } + setProjectRowItem((current) => + current && current.id === row.id ? applyContentUpdate(current) : current + ) + setGithubProjectTable((table) => + table + ? { + ...table, + rows: table.rows.map((candidate) => + candidate.id === row.id ? applyContentUpdate(candidate) : candidate + ) + } + : table + ) + setProjectRowDetail((current) => + current?.provider === 'github' + ? { + ...current, + labels: [ + ...new Set([ + ...current.labels.filter( + (label) => !(updates.removeLabels ?? []).includes(label) + ), + ...(updates.addLabels ?? []) + ]) + ], + assignees: [ + ...new Set([ + ...current.assignees.filter( + (login) => !(updates.removeAssignees ?? []).includes(login) + ), + ...(updates.addAssignees ?? []) + ]) + ] + } + : current + ) + } catch (err) { + setProjectRowDetailError(err instanceof Error ? err.message : 'Failed to update item') + } finally { + setProjectMutating(false) + } + }, + [activeGitHubProjectHost, client, projectMutating] + ) + + const mutateProjectRowField = useCallback( + async ( + row: GitHubProjectRow, + field: GitHubProjectField, + value: GitHubProjectFieldMutationValue | null + ): Promise => { + if (!client || !githubProjectTable || projectMutating) { + return + } + setProjectMutating(true) + try { + const response = await client.sendRequest( + value === null ? 'github.project.clearItemField' : 'github.project.updateItemField', + value === null + ? { + projectId: githubProjectTable.project.id, + host: activeGitHubProjectHost, + itemId: row.id, + fieldId: field.id + } + : { + projectId: githubProjectTable.project.id, + host: activeGitHubProjectHost, + itemId: row.id, + fieldId: field.id, + value + }, + { timeoutMs: 30_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as { ok?: boolean; error?: { message?: string } } + if (result.ok === false) { + throw new Error(result.error?.message ?? 'Failed to update project field') + } + const patchRow = (candidate: GitHubProjectRow): GitHubProjectRow => { + const fieldValuesByFieldId = { ...candidate.fieldValuesByFieldId } + if (value === null) { + delete fieldValuesByFieldId[field.id] + } else { + fieldValuesByFieldId[field.id] = optimisticProjectFieldValue(field, value) + } + return { ...candidate, fieldValuesByFieldId } + } + setProjectRowItem((current) => + current && current.id === row.id ? patchRow(current) : current + ) + setGithubProjectTable((table) => + table + ? { + ...table, + rows: table.rows.map((candidate) => + candidate.id === row.id ? patchRow(candidate) : candidate + ) + } + : table + ) + if (value === null) { + setProjectFieldDrafts((current) => ({ ...current, [field.id]: '' })) + } + } catch (err) { + setProjectRowDetailError( + err instanceof Error ? err.message : 'Failed to update project field' + ) + } finally { + setProjectMutating(false) + } + }, + [activeGitHubProjectHost, client, githubProjectTable, projectMutating] + ) + + const mutateProjectRowIssueType = useCallback( + async (row: GitHubProjectRow, issueType: GitHubIssueType | null): Promise => { + if (!client || projectMutating) { + return + } + const slug = splitRepositorySlug(row.content.repository) + if (row.itemType !== 'ISSUE' || !slug || !row.content.number) { + setProjectRowDetailError('This project issue type cannot be edited from mobile.') + return + } + setProjectMutating(true) + try { + const response = await client.sendRequest( + 'github.project.updateIssueTypeBySlug', + { + owner: slug.owner, + repo: slug.repo, + host: activeGitHubProjectHost, + number: row.content.number, + issueTypeId: issueType?.id ?? null + }, + { timeoutMs: 30_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as { ok?: boolean; error?: { message?: string } } + if (result.ok === false) { + throw new Error(result.error?.message ?? 'Failed to update issue type') + } + const patchRow = (candidate: GitHubProjectRow): GitHubProjectRow => ({ + ...candidate, + content: { ...candidate.content, issueType } + }) + setProjectRowItem((current) => + current && current.id === row.id ? patchRow(current) : current + ) + setGithubProjectTable((table) => + table + ? { + ...table, + rows: table.rows.map((candidate) => + candidate.id === row.id ? patchRow(candidate) : candidate + ) + } + : table + ) + } catch (err) { + setProjectRowDetailError(err instanceof Error ? err.message : 'Failed to update issue type') + } finally { + setProjectMutating(false) + } + }, + [activeGitHubProjectHost, client, projectMutating] + ) + + const requestProjectGitHubReviewers = useCallback( + async (row: GitHubProjectRow, logins?: string[]): Promise => { + const repo = findProjectRowRepo(row) + if (!client || projectMutating || row.itemType !== 'PULL_REQUEST' || !repo) { + return + } + const reviewers = logins ?? splitReviewerList(projectReviewersDraft) + if (reviewers.length === 0 || !row.content.number) { + return + } + setProjectMutating(true) + setProjectRowDetailError('') + try { + const response = await client.sendRequest( + 'github.requestPRReviewers', + { + repo: `id:${repo.id}`, + prNumber: row.content.number, + prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost), + reviewers + }, + { timeoutMs: 30_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as { ok?: boolean; error?: string } + if (result.ok === false) { + throw new Error(result.error ?? 'Failed to request reviewers') + } + const nextReviewRequests = (() => { + const byLogin = new Map() + for (const reviewer of projectRowDetail?.provider === 'github' + ? projectRowDetail.reviewRequests + : []) { + const login = reviewer.login.trim() + if (login) { + byLogin.set(login.toLowerCase(), reviewer) + } + } + for (const login of reviewers) { + const normalized = login.trim().replace(/^@/, '') + if (normalized && !byLogin.has(normalized.toLowerCase())) { + byLogin.set(normalized.toLowerCase(), { + login: normalized, + name: null, + avatarUrl: null + }) + } + } + return Array.from(byLogin.values()) + })() + setProjectRowDetail((current) => + current?.provider === 'github' + ? { ...current, reviewRequests: nextReviewRequests } + : current + ) + if (!logins) { + setProjectReviewersDraft('') + } + } catch (err) { + setProjectRowDetailError(err instanceof Error ? err.message : 'Failed to request reviewers') + } finally { + setProjectMutating(false) + } + }, + [ + activeGitHubProjectHost, + client, + findProjectRowRepo, + projectMutating, + projectReviewersDraft, + projectRowDetail + ] + ) + + const refreshProjectGitHubChecks = useCallback( + async (row: GitHubProjectRow): Promise => { + const repo = findProjectRowRepo(row) + if ( + !client || + projectMutating || + row.itemType !== 'PULL_REQUEST' || + !repo || + !row.content.number + ) { + return + } + setProjectMutating(true) + setProjectRowDetailError('') + try { + const response = await client.sendRequest( + 'github.prChecks', + { + repo: `id:${repo.id}`, + prNumber: row.content.number, + prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost), + headSha: projectRowDetail?.provider === 'github' ? projectRowDetail.headSha : undefined, + noCache: true + }, + { timeoutMs: 30_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + if (!Array.isArray(response.result)) { + throw new Error('Invalid checks response') + } + const checks = response.result as GitHubDetailCheck[] + setProjectRowDetail((current) => + current?.provider === 'github' ? { ...current, checks } : current + ) + } catch (err) { + setProjectRowDetailError(err instanceof Error ? err.message : 'Failed to refresh checks') + } finally { + setProjectMutating(false) + } + }, + [activeGitHubProjectHost, client, findProjectRowRepo, projectMutating, projectRowDetail] + ) + + const rerunProjectGitHubChecks = useCallback( + async (row: GitHubProjectRow, failedOnly: boolean): Promise => { + const repo = findProjectRowRepo(row) + if ( + !client || + projectMutating || + row.itemType !== 'PULL_REQUEST' || + !repo || + !row.content.number + ) { + return + } + setProjectMutating(true) + setProjectRowDetailError('') + try { + const response = await client.sendRequest( + 'github.rerunPRChecks', + { + repo: `id:${repo.id}`, + prNumber: row.content.number, + prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost), + headSha: projectRowDetail?.provider === 'github' ? projectRowDetail.headSha : undefined, + failedOnly + }, + { timeoutMs: 60_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as { ok?: boolean; error?: string } + if (result.ok === false) { + throw new Error(result.error ?? 'Failed to rerun checks') + } + setProjectRowDetailRefreshSeq((current) => current + 1) + } catch (err) { + setProjectRowDetailError(err instanceof Error ? err.message : 'Failed to rerun checks') + } finally { + setProjectMutating(false) + } + }, + [activeGitHubProjectHost, client, findProjectRowRepo, projectMutating, projectRowDetail] + ) + + const toggleProjectGitHubFileViewed = useCallback( + async (row: GitHubProjectRow, file: GitHubDetailFile): Promise => { + const repo = findProjectRowRepo(row) + if (!client || projectMutating || row.itemType !== 'PULL_REQUEST' || !repo) { + return + } + if (projectRowDetail?.provider !== 'github' || !projectRowDetail.pullRequestId) { + setProjectRowDetailError('Unable to sync viewed state for this pull request.') + return + } + const viewed = file.viewerViewedState !== 'VIEWED' + setProjectMutating(true) + setProjectRowDetailError('') + try { + const response = await client.sendRequest( + 'github.setPRFileViewed', + { + repo: `id:${repo.id}`, + prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost), + pullRequestId: projectRowDetail.pullRequestId, + path: file.path, + viewed + }, + { timeoutMs: 30_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + if (response.result !== true) { + throw new Error('Failed to sync viewed state with GitHub.') + } + setProjectRowDetail((current) => + current?.provider === 'github' + ? { + ...current, + files: current.files.map((candidate) => + candidate.path === file.path + ? { ...candidate, viewerViewedState: viewed ? 'VIEWED' : 'UNVIEWED' } + : candidate + ) + } + : current + ) + } catch (err) { + setProjectRowDetailError( + err instanceof Error ? err.message : 'Failed to update viewed state' + ) + } finally { + setProjectMutating(false) + } + }, + [activeGitHubProjectHost, client, findProjectRowRepo, projectMutating, projectRowDetail] + ) + + const toggleProjectGitHubFileExpansion = useCallback( + async (row: GitHubProjectRow, file: GitHubDetailFile): Promise => { + if (expandedPrFilePath === file.path) { + setExpandedPrFilePath(null) + return + } + setExpandedPrFilePath(file.path) + if (prFileContents[file.path]) { + return + } + const repo = findProjectRowRepo(row) + if ( + !client || + row.itemType !== 'PULL_REQUEST' || + !repo || + !row.content.number || + projectRowDetail?.provider !== 'github' || + !projectRowDetail.headSha || + !projectRowDetail.baseSha + ) { + setProjectRowDetailError('Unable to load file contents for this pull request.') + return + } + setPrFileLoadingPath(file.path) + setProjectRowDetailError('') + try { + const response = await client.sendRequest( + 'github.prFileContents', + { + repo: `id:${repo.id}`, + prNumber: row.content.number, + prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost), + path: file.path, + oldPath: file.oldPath, + status: file.status ?? 'modified', + headSha: projectRowDetail.headSha, + baseSha: projectRowDetail.baseSha + }, + { timeoutMs: 30_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + setPrFileContents((current) => ({ + ...current, + [file.path]: response.result as GitHubPRFileContents + })) + } catch (err) { + setProjectRowDetailError( + err instanceof Error ? err.message : 'Failed to load file contents' + ) + } finally { + setPrFileLoadingPath(null) + } + }, + [ + activeGitHubProjectHost, + client, + expandedPrFilePath, + findProjectRowRepo, + prFileContents, + projectRowDetail + ] + ) + + const addProjectGitHubFileReviewComment = useCallback( + async (row: GitHubProjectRow, file: GitHubDetailFile, line: number): Promise => { + const repo = findProjectRowRepo(row) + if ( + !client || + projectMutating || + row.itemType !== 'PULL_REQUEST' || + !repo || + !row.content.number + ) { + return + } + if (projectRowDetail?.provider !== 'github' || !projectRowDetail.headSha) { + setProjectRowDetailError('Unable to comment without the PR head SHA.') + return + } + const draftKey = `${file.path}:${line}` + const body = (prFileCommentDrafts[draftKey] ?? '').trim() + if (!body) { + return + } + setProjectMutating(true) + setProjectRowDetailError('') + try { + const response = await client.sendRequest( + 'github.addPRReviewComment', + { + repo: `id:${repo.id}`, + prNumber: row.content.number, + prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost), + commitId: projectRowDetail.headSha, + path: file.path, + line, + body + }, + { timeoutMs: 30_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as { + ok?: boolean + error?: string + comment?: DetailComment + } + if (result.ok === false) { + throw new Error(result.error ?? 'Failed to add review comment') + } + const comment: DetailComment = result.comment ?? { + id: `local-${Date.now()}`, + author: 'You', + body, + createdAt: new Date().toISOString(), + path: file.path, + line + } + setPrFileCommentDrafts((current) => { + const next = { ...current } + delete next[draftKey] + return next + }) + setProjectRowDetail((current) => + current?.provider === 'github' + ? { ...current, comments: [...current.comments, comment] } + : current + ) + } catch (err) { + setProjectRowDetailError( + err instanceof Error ? err.message : 'Failed to add review comment' + ) + } finally { + setProjectMutating(false) + } + }, + [ + activeGitHubProjectHost, + client, + findProjectRowRepo, + prFileCommentDrafts, + projectMutating, + projectRowDetail + ] + ) + + const mergeProjectGitHubPullRequest = useCallback( + async (row: GitHubProjectRow, method: HostedReviewMergeMethod): Promise => { + const repo = findProjectRowRepo(row) + if ( + !client || + projectMutating || + row.itemType !== 'PULL_REQUEST' || + !repo || + !row.content.number + ) { + return + } + if (row.content.state === 'CLOSED' || row.content.state === 'MERGED') { + return + } + setProjectMutating(true) + setProjectRowDetailError('') + try { + const response = await client.sendRequest( + 'github.mergePR', + { + repo: `id:${repo.id}`, + prNumber: row.content.number, + prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost), + method + }, + { timeoutMs: 60_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as { ok?: boolean; error?: string } + if (result.ok === false) { + throw new Error(result.error ?? 'Failed to merge pull request') + } + setProjectRowItem((current) => + current?.id === row.id + ? { ...current, content: { ...current.content, state: 'MERGED' } } + : current + ) + setGithubProjectTable((table) => + table + ? { + ...table, + rows: table.rows.map((candidate) => + candidate.id === row.id + ? { ...candidate, content: { ...candidate.content, state: 'MERGED' } } + : candidate + ) + } + : table + ) + } catch (err) { + setProjectRowDetailError( + err instanceof Error ? err.message : 'Failed to merge pull request' + ) + } finally { + setProjectMutating(false) + } + }, + [activeGitHubProjectHost, client, findProjectRowRepo, projectMutating] + ) + + const toggleGitHubStatus = useCallback( + async (item: Extract): Promise => { + if (!client || mutatingStatus || item.source.state === 'merged') { + return + } + setMutatingStatus(true) + setError('') + const nextState = item.source.state === 'closed' ? 'open' : 'closed' + try { + const method = item.source.type === 'issue' ? 'github.updateIssue' : 'github.updatePRState' + const params = + item.source.type === 'issue' + ? { + repo: `id:${item.source.repoId}`, + number: item.source.number, + updates: { state: nextState } + } + : { + repo: `id:${item.source.repoId}`, + prNumber: item.source.number, + updates: { state: nextState } + } + const response = await client.sendRequest(method, params) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as { ok?: boolean; error?: string } + if (result.ok === false) { + throw new Error(result.error ?? 'Failed to update GitHub status') + } + setActionItem(null) + await loadTasks({ silent: true }) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to update status') + } finally { + setMutatingStatus(false) + } + }, + [client, loadTasks, mutatingStatus] + ) + + const toggleGitLabStatus = useCallback( + async (item: Extract): Promise => { + if (!client || mutatingStatus || item.source.state === 'merged') { + return + } + setMutatingStatus(true) + setError('') + const nextState = item.source.state === 'closed' ? 'opened' : 'closed' + try { + const response = + item.source.type === 'issue' + ? await client.sendRequest('gitlab.updateIssue', { + repo: `id:${item.source.repoId}`, + number: item.source.number, + updates: { state: nextState }, + projectRef: item.source.projectRef + }) + : await client.sendRequest('gitlab.updateMRState', { + repo: `id:${item.source.repoId}`, + iid: item.source.number, + state: nextState, + projectRef: item.source.projectRef + }) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as { ok?: boolean; error?: string } + if (result.ok === false) { + throw new Error(result.error ?? 'Failed to update GitLab item') + } + setActionItem(null) + await loadTasks({ silent: true }) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to update GitLab item') + } finally { + setMutatingStatus(false) + } + }, + [client, loadTasks, mutatingStatus] + ) + + const updateGitHubIssueMetadata = useCallback( + async ( + item: Extract, + updates: { + title?: string + body?: string + addLabels?: string[] + removeLabels?: string[] + addAssignees?: string[] + removeAssignees?: string[] + } + ): Promise => { + if (!client || mutatingStatus) { + return + } + setMutatingStatus(true) + setError('') + try { + const response = await client.sendRequest( + 'github.updateIssue', + { + repo: `id:${item.source.repoId}`, + number: item.source.number, + updates + }, + { timeoutMs: 30_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as { ok?: boolean; error?: string } + if (result.ok === false) { + throw new Error(result.error ?? 'Failed to update GitHub issue') + } + + const nextLabels = [ + ...new Set([ + ...(detailPayload?.provider === 'github' + ? detailPayload.labels.filter( + (label) => !(updates.removeLabels ?? []).includes(label) + ) + : item.source.labels.filter( + (label) => !(updates.removeLabels ?? []).includes(label) + )), + ...(updates.addLabels ?? []) + ]) + ] + const nextAssignees = + detailPayload?.provider === 'github' + ? [ + ...new Set([ + ...detailPayload.assignees.filter( + (login) => !(updates.removeAssignees ?? []).includes(login) + ), + ...(updates.addAssignees ?? []) + ]) + ] + : undefined + const nextTitle = updates.title?.trim() + setActionItem((current) => + current?.provider === 'github' && current.source.id === item.source.id + ? { + ...current, + ...(nextTitle ? { title: nextTitle } : {}), + source: { + ...current.source, + ...(nextTitle ? { title: nextTitle } : {}), + labels: nextLabels + } + } + : current + ) + setItems((current) => + current.map((candidate) => + candidate.provider === 'github' && candidate.source.id === item.source.id + ? { + ...candidate, + ...(nextTitle ? { title: nextTitle } : {}), + source: { + ...candidate.source, + ...(nextTitle ? { title: nextTitle } : {}), + labels: nextLabels + } + } + : candidate + ) + ) + setDetailPayload((current) => + current?.provider === 'github' + ? { + ...current, + labels: nextLabels, + ...(updates.body !== undefined ? { body: updates.body } : {}), + ...(nextAssignees ? { assignees: nextAssignees } : {}) + } + : current + ) + setItemAddLabelsDraft('') + setItemRemoveLabelsDraft('') + setItemAddAssigneesDraft('') + setItemRemoveAssigneesDraft('') + await loadTasks({ silent: true }) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to update GitHub issue') + } finally { + setMutatingStatus(false) + } + }, + [client, detailPayload, loadTasks, mutatingStatus] + ) + + const updateGitHubPullRequestMetadata = useCallback( + async ( + item: Extract, + updates: { title?: string; body?: string } + ): Promise => { + if (!client || mutatingStatus || item.source.type !== 'pr') { + return + } + const nextTitle = updates.title?.trim() + if (updates.title !== undefined && !nextTitle) { + return + } + setMutatingStatus(true) + setError('') + try { + const response = await client.sendRequest( + 'github.updatePR', + { + repo: `id:${item.source.repoId}`, + prNumber: item.source.number, + updates: { + ...(nextTitle !== undefined ? { title: nextTitle } : {}), + ...(updates.body !== undefined ? { body: updates.body } : {}) + } + }, + { timeoutMs: 30_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as { ok?: boolean; error?: string } + if (result.ok === false) { + throw new Error(result.error ?? 'Failed to update GitHub pull request') + } + if (nextTitle !== undefined) { + setActionItem((current) => + current?.provider === 'github' && current.source.id === item.source.id + ? { + ...current, + title: nextTitle, + source: { ...current.source, title: nextTitle } + } + : current + ) + setItems((current) => + current.map((candidate) => + candidate.provider === 'github' && candidate.source.id === item.source.id + ? { + ...candidate, + title: nextTitle, + source: { ...candidate.source, title: nextTitle } + } + : candidate + ) + ) + } + if (updates.body !== undefined) { + setDetailPayload((current) => + current?.provider === 'github' ? { ...current, body: updates.body ?? '' } : current + ) + } + await loadTasks({ silent: true }) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to update GitHub pull request') + } finally { + setMutatingStatus(false) + } + }, + [client, loadTasks, mutatingStatus] + ) + + const updateGitLabIssueMetadata = useCallback( + async ( + item: Extract, + updates: { + title?: string + body?: string + addLabels?: string[] + removeLabels?: string[] + addAssignees?: string[] + removeAssignees?: string[] + } + ): Promise => { + if (!client || mutatingStatus) { + return + } + setMutatingStatus(true) + setError('') + try { + const method = item.source.type === 'issue' ? 'gitlab.updateIssue' : 'gitlab.updateMR' + const params = + item.source.type === 'issue' + ? { + repo: `id:${item.source.repoId}`, + number: item.source.number, + updates, + projectRef: item.source.projectRef + } + : { + repo: `id:${item.source.repoId}`, + iid: item.source.number, + projectRef: item.source.projectRef, + updates: { + title: updates.title, + body: updates.body, + addLabels: updates.addLabels, + removeLabels: updates.removeLabels + } + } + const response = await client.sendRequest(method, params, { timeoutMs: 30_000 }) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as { ok?: boolean; error?: string } + if (result.ok === false) { + throw new Error(result.error ?? 'Failed to update GitLab item') + } + const nextLabels = [ + ...new Set([ + ...(detailPayload?.provider === 'gitlab' + ? detailPayload.labels.filter( + (label) => !(updates.removeLabels ?? []).includes(label) + ) + : item.source.labels.filter( + (label) => !(updates.removeLabels ?? []).includes(label) + )), + ...(updates.addLabels ?? []) + ]) + ] + const nextAssignees = + detailPayload?.provider === 'gitlab' + ? [ + ...new Set([ + ...detailPayload.assignees.filter( + (login) => !(updates.removeAssignees ?? []).includes(login) + ), + ...(updates.addAssignees ?? []) + ]) + ] + : undefined + const nextTitle = updates.title?.trim() + setActionItem((current) => + current?.provider === 'gitlab' && current.source.id === item.source.id + ? { + ...current, + ...(nextTitle ? { title: nextTitle } : {}), + source: { + ...current.source, + ...(nextTitle ? { title: nextTitle } : {}), + labels: nextLabels + } + } + : current + ) + setItems((current) => + current.map((candidate) => + candidate.provider === 'gitlab' && candidate.source.id === item.source.id + ? { + ...candidate, + ...(nextTitle ? { title: nextTitle } : {}), + source: { + ...candidate.source, + ...(nextTitle ? { title: nextTitle } : {}), + labels: nextLabels + } + } + : candidate + ) + ) + setDetailPayload((current) => + current?.provider === 'gitlab' + ? { + ...current, + labels: nextLabels, + ...(updates.body !== undefined ? { body: updates.body } : {}), + ...(nextAssignees ? { assignees: nextAssignees } : {}) + } + : current + ) + setItemAddLabelsDraft('') + setItemRemoveLabelsDraft('') + if (item.source.type === 'issue') { + setItemAddAssigneesDraft('') + setItemRemoveAssigneesDraft('') + } + await loadTasks({ silent: true }) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to update GitLab item') + } finally { + setMutatingStatus(false) + } + }, + [client, detailPayload, loadTasks, mutatingStatus] + ) + + const addHostedItemComment = useCallback( + async ( + item: Extract | Extract + ): Promise => { + if (!client || mutatingStatus) { + return + } + const body = itemCommentDraft.trim() + if (!body) { + return + } + setMutatingStatus(true) + setError('') + try { + const response = + item.provider === 'github' + ? await client.sendRequest( + 'github.addIssueComment', + { + repo: `id:${item.source.repoId}`, + number: item.source.number, + body, + type: item.source.type + }, + { timeoutMs: 30_000 } + ) + : await client.sendRequest( + item.source.type === 'mr' ? 'gitlab.addMRComment' : 'gitlab.addIssueComment', + item.source.type === 'mr' + ? { + repo: `id:${item.source.repoId}`, + iid: item.source.number, + body, + projectRef: item.source.projectRef + } + : { + repo: `id:${item.source.repoId}`, + number: item.source.number, + body, + projectRef: item.source.projectRef + }, + { timeoutMs: 30_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as { + ok?: boolean + error?: string + comment?: DetailComment + } + if (result.ok === false) { + throw new Error(result.error ?? 'Failed to add comment') + } + const comment: DetailComment = result.comment ?? { + id: `local-${Date.now()}`, + body, + createdAt: new Date().toISOString(), + author: 'You' + } + setItemCommentDraft('') + setDetailPayload((current) => + current && + ((item.provider === 'github' && current.provider === 'github') || + (item.provider === 'gitlab' && current.provider === 'gitlab')) + ? { ...current, comments: [...current.comments, comment] } + : current + ) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to add comment') + } finally { + setMutatingStatus(false) + } + }, + [client, itemCommentDraft, mutatingStatus] + ) + + const copyTaskLink = useCallback(async (key: string, url: string): Promise => { + try { + await Clipboard.setStringAsync(url) + setCopiedLinkKey(key) + scheduleMobileTaskCopyFeedbackReset(copiedLinkResetTimerRef, key, setCopiedLinkKey) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to copy link') + } + }, []) + + const copyTextToClipboard = useCallback(async (key: string, value: string): Promise => { + try { + await Clipboard.setStringAsync(value) + setCopiedLinkKey(key) + scheduleMobileTaskCopyFeedbackReset(copiedLinkResetTimerRef, key, setCopiedLinkKey) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to copy text') + } + }, []) + + const requestGitHubReviewers = useCallback( + async (item: Extract, logins?: string[]): Promise => { + if (!client || mutatingStatus || item.source.type !== 'pr') { + return + } + const reviewers = logins ?? splitReviewerList(itemReviewersDraft) + if (reviewers.length === 0) { + return + } + setMutatingStatus(true) + setError('') + try { + const response = await client.sendRequest( + 'github.requestPRReviewers', + { + repo: `id:${item.source.repoId}`, + prNumber: item.source.number, + reviewers + }, + { timeoutMs: 30_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as { ok?: boolean; error?: string } + if (result.ok === false) { + throw new Error(result.error ?? 'Failed to request reviewers') + } + const nextReviewRequests = (() => { + const byLogin = new Map() + for (const reviewer of detailPayload?.provider === 'github' + ? detailPayload.reviewRequests + : (item.source.reviewRequests ?? [])) { + const login = reviewer.login.trim() + if (login) { + byLogin.set(login.toLowerCase(), reviewer) + } + } + for (const login of reviewers) { + const normalized = login.trim().replace(/^@/, '') + if (normalized && !byLogin.has(normalized.toLowerCase())) { + byLogin.set(normalized.toLowerCase(), { + login: normalized, + name: null, + avatarUrl: null + }) + } + } + return Array.from(byLogin.values()) + })() + setActionItem((current) => + current?.provider === 'github' && current.source.id === item.source.id + ? { + ...current, + source: { ...current.source, reviewRequests: nextReviewRequests } + } + : current + ) + setItems((current) => + current.map((candidate) => + candidate.provider === 'github' && candidate.source.id === item.source.id + ? { + ...candidate, + source: { ...candidate.source, reviewRequests: nextReviewRequests } + } + : candidate + ) + ) + setDetailPayload((current) => + current?.provider === 'github' + ? { ...current, reviewRequests: nextReviewRequests } + : current + ) + if (!logins) { + setItemReviewersDraft('') + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to request reviewers') + } finally { + setMutatingStatus(false) + } + }, + [client, detailPayload, itemReviewersDraft, mutatingStatus] + ) + + const refreshGitHubChecks = useCallback( + async (item: Extract): Promise => { + if (!client || mutatingStatus || item.source.type !== 'pr') { + return + } + setMutatingStatus(true) + setError('') + try { + const response = await client.sendRequest( + 'github.prChecks', + { + repo: `id:${item.source.repoId}`, + prNumber: item.source.number, + headSha: detailPayload?.provider === 'github' ? detailPayload.headSha : undefined, + noCache: true + }, + { timeoutMs: 30_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + if (!Array.isArray(response.result)) { + throw new Error('Invalid checks response') + } + const checks = response.result as GitHubDetailCheck[] + const checksSummary = buildGitHubCheckSummary(checks) + setDetailPayload((current) => + current?.provider === 'github' ? { ...current, checks } : current + ) + setActionItem((current) => + current?.provider === 'github' && current.source.id === item.source.id + ? { + ...current, + source: { ...current.source, checksSummary } + } + : current + ) + setItems((current) => + current.map((candidate) => + candidate.provider === 'github' && candidate.source.id === item.source.id + ? { + ...candidate, + source: { ...candidate.source, checksSummary } + } + : candidate + ) + ) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to refresh checks') + } finally { + setMutatingStatus(false) + } + }, + [client, detailPayload, mutatingStatus] + ) + + const rerunGitHubChecks = useCallback( + async (item: Extract, failedOnly: boolean): Promise => { + if (!client || mutatingStatus || item.source.type !== 'pr') { + return + } + setMutatingStatus(true) + setError('') + try { + const response = await client.sendRequest( + 'github.rerunPRChecks', + { + repo: `id:${item.source.repoId}`, + prNumber: item.source.number, + headSha: detailPayload?.provider === 'github' ? detailPayload.headSha : undefined, + failedOnly + }, + { timeoutMs: 60_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as { ok?: boolean; error?: string } + if (result.ok === false) { + throw new Error(result.error ?? 'Failed to rerun checks') + } + setDetailRefreshSeq((current) => current + 1) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to rerun checks') + } finally { + setMutatingStatus(false) + } + }, + [client, detailPayload, mutatingStatus] + ) + + const toggleGitHubFileViewed = useCallback( + async ( + item: Extract, + file: NonNullable['files'][number]> + ): Promise => { + if (!client || mutatingStatus || item.source.type !== 'pr') { + return + } + if (detailPayload?.provider !== 'github' || !detailPayload.pullRequestId) { + setError('Unable to sync viewed state for this pull request.') + return + } + const viewed = file.viewerViewedState !== 'VIEWED' + setMutatingStatus(true) + setError('') + try { + const response = await client.sendRequest( + 'github.setPRFileViewed', + { + repo: `id:${item.source.repoId}`, + pullRequestId: detailPayload.pullRequestId, + path: file.path, + viewed + }, + { timeoutMs: 30_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + if (response.result !== true) { + throw new Error('Failed to sync viewed state with GitHub.') + } + setDetailPayload((current) => + current?.provider === 'github' + ? { + ...current, + files: current.files.map((candidate) => + candidate.path === file.path + ? { ...candidate, viewerViewedState: viewed ? 'VIEWED' : 'UNVIEWED' } + : candidate + ) + } + : current + ) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to update viewed state') + } finally { + setMutatingStatus(false) + } + }, + [client, detailPayload, mutatingStatus] + ) + + const toggleGitHubReviewThread = useCallback( + async ( + item: Extract, + comment: DetailComment + ): Promise => { + if (!client || mutatingStatus || item.source.type !== 'pr' || !comment.threadId) { + return + } + const resolve = !comment.isResolved + setMutatingStatus(true) + setError('') + try { + const response = await client.sendRequest( + 'github.resolveReviewThread', + { + repo: `id:${item.source.repoId}`, + threadId: comment.threadId, + resolve + }, + { timeoutMs: 30_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + if (response.result !== true) { + throw new Error(resolve ? 'Failed to resolve thread' : 'Failed to reopen thread') + } + setDetailPayload((current) => + current?.provider === 'github' + ? { + ...current, + comments: current.comments.map((candidate) => + candidate.threadId === comment.threadId + ? { ...candidate, isResolved: resolve } + : candidate + ) + } + : current + ) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to update review thread') + } finally { + setMutatingStatus(false) + } + }, + [client, mutatingStatus] + ) + + const toggleGitHubFileExpansion = useCallback( + async ( + item: Extract, + file: GitHubDetailFile + ): Promise => { + if (expandedPrFilePath === file.path) { + setExpandedPrFilePath(null) + return + } + setExpandedPrFilePath(file.path) + if (prFileContents[file.path]) { + return + } + if ( + !client || + item.source.type !== 'pr' || + detailPayload?.provider !== 'github' || + !detailPayload.headSha || + !detailPayload.baseSha + ) { + setError('Unable to load file contents for this pull request.') + return + } + setPrFileLoadingPath(file.path) + setError('') + try { + const response = await client.sendRequest( + 'github.prFileContents', + { + repo: `id:${item.source.repoId}`, + prNumber: item.source.number, + path: file.path, + oldPath: file.oldPath, + status: file.status ?? 'modified', + headSha: detailPayload.headSha, + baseSha: detailPayload.baseSha + }, + { timeoutMs: 30_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + setPrFileContents((current) => ({ + ...current, + [file.path]: response.result as GitHubPRFileContents + })) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load file contents') + } finally { + setPrFileLoadingPath(null) + } + }, + [client, detailPayload, expandedPrFilePath, prFileContents] + ) + + const addGitHubFileReviewComment = useCallback( + async ( + item: Extract, + file: GitHubDetailFile, + line: number + ): Promise => { + if (!client || mutatingStatus || item.source.type !== 'pr') { + return + } + if (detailPayload?.provider !== 'github' || !detailPayload.headSha) { + setError('Unable to comment without the PR head SHA.') + return + } + const draftKey = `${file.path}:${line}` + const body = (prFileCommentDrafts[draftKey] ?? '').trim() + if (!body) { + return + } + setMutatingStatus(true) + setError('') + try { + const response = await client.sendRequest( + 'github.addPRReviewComment', + { + repo: `id:${item.source.repoId}`, + prNumber: item.source.number, + commitId: detailPayload.headSha, + path: file.path, + line, + body + }, + { timeoutMs: 30_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as { + ok?: boolean + error?: string + comment?: DetailComment + } + if (result.ok === false) { + throw new Error(result.error ?? 'Failed to add review comment') + } + const comment: DetailComment = result.comment ?? { + id: `local-${Date.now()}`, + author: 'You', + body, + createdAt: new Date().toISOString(), + path: file.path, + line + } + setPrFileCommentDrafts((current) => { + const next = { ...current } + delete next[draftKey] + return next + }) + setDetailPayload((current) => + current?.provider === 'github' + ? { ...current, comments: [...current.comments, comment] } + : current + ) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to add review comment') + } finally { + setMutatingStatus(false) + } + }, + [client, detailPayload, mutatingStatus, prFileCommentDrafts] + ) + + const replyToGitHubComment = useCallback( + async ( + item: Extract, + comment: DetailComment + ): Promise => { + if (!client || mutatingStatus) { + return + } + const key = String(comment.id) + const body = (itemReplyDrafts[key] ?? '').trim() + if (!body) { + return + } + setMutatingStatus(true) + setError('') + try { + const canUseReviewReply = + item.source.type === 'pr' && + comment.path && + typeof comment.line === 'number' && + typeof comment.id === 'number' + const response = canUseReviewReply + ? await client.sendRequest( + 'github.addPRReviewCommentReply', + { + repo: `id:${item.source.repoId}`, + prNumber: item.source.number, + commentId: comment.id, + body, + threadId: comment.threadId, + path: comment.path, + line: comment.line + }, + { timeoutMs: 30_000 } + ) + : await client.sendRequest( + 'github.addIssueComment', + { + repo: `id:${item.source.repoId}`, + number: item.source.number, + body: `@${commentAuthor(comment)} ${body}`, + type: item.source.type + }, + { timeoutMs: 30_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as { + ok?: boolean + error?: string + comment?: DetailComment + } + if (result.ok === false) { + throw new Error(result.error ?? 'Failed to reply') + } + const reply: DetailComment = result.comment ?? { + id: `local-${Date.now()}`, + body, + createdAt: new Date().toISOString(), + author: 'You', + path: comment.path, + line: comment.line, + threadId: comment.threadId + } + setItemReplyDrafts((current) => { + const next = { ...current } + delete next[key] + return next + }) + setDetailPayload((current) => + current?.provider === 'github' + ? { ...current, comments: [...current.comments, reply] } + : current + ) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to reply') + } finally { + setMutatingStatus(false) + } + }, + [client, itemReplyDrafts, mutatingStatus] + ) + + const mergeHostedReview = useCallback( + async ( + item: Extract | Extract, + method: HostedReviewMergeMethod + ): Promise => { + if (!client || mutatingStatus) { + return + } + if (item.provider === 'github' && item.source.type !== 'pr') { + return + } + if (item.provider === 'gitlab' && item.source.type !== 'mr') { + return + } + if (item.provider === 'github' && isGitHubPrMergeBlocked(item)) { + setError('GitHub reports merge conflicts. Open in GitHub to continue.') + return + } + setMutatingStatus(true) + setError('') + try { + const response = + item.provider === 'github' + ? await client.sendRequest( + 'github.mergePR', + { + repo: `id:${item.source.repoId}`, + prNumber: item.source.number, + method + }, + { timeoutMs: 60_000 } + ) + : await client.sendRequest( + 'gitlab.mergeMR', + { + repo: `id:${item.source.repoId}`, + iid: item.source.number, + method, + projectRef: item.source.projectRef + }, + { timeoutMs: 60_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as { ok?: boolean; error?: string } + if (result.ok === false) { + throw new Error(result.error ?? 'Failed to merge') + } + setActionItem(null) + await loadTasks({ silent: true }) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to merge') + } finally { + setMutatingStatus(false) + } + }, + [client, loadTasks, mutatingStatus] + ) + + const setLinearStatus = useCallback( + async ( + item: Extract, + state: LinearState, + options: { closeDetail?: boolean } = {} + ): Promise => { + if (!client || !taskUiReady || mutatingStatus) { + return + } + setMutatingStatus(true) + setError('') + try { + const response = await client.sendRequest('linear.updateIssue', { + id: item.source.id, + workspaceId: item.source.workspaceId, + updates: { stateId: state.id } + }) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const nextState = { + name: state.name, + type: state.type, + color: state.color ?? item.source.state.color + } + setItems((current) => + current.map((entry) => + entry.provider === 'linear' && entry.source.id === item.source.id + ? createLinearTask({ ...entry.source, state: nextState }) + : entry + ) + ) + setActionItem((current) => { + if (!current || current.provider !== 'linear' || current.source.id !== item.source.id) { + return current + } + if (options.closeDetail !== false) { + return null + } + return createLinearTask({ + ...current.source, + state: nextState + }) as Extract + }) + await loadTasks({ silent: true }) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to update Linear issue') + } finally { + setMutatingStatus(false) + } + }, + [client, loadTasks, mutatingStatus, taskUiReady] + ) + + const addLinearComment = useCallback( + async (item: Extract): Promise => { + if (!client || mutatingStatus) { + return + } + const body = linearCommentDraft.trim() + if (!body) { + return + } + setMutatingStatus(true) + setError('') + try { + const response = await client.sendRequest( + 'linear.addIssueComment', + { + issueId: item.source.id, + workspaceId: item.source.workspaceId, + body + }, + { timeoutMs: 30_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as { ok?: boolean; id?: string; error?: string } + if (result.ok === false) { + throw new Error(result.error ?? 'Failed to add comment') + } + const comment: DetailComment = { + id: result.id ?? `local-${Date.now()}`, + body, + createdAt: new Date().toISOString(), + user: { displayName: 'You' } + } + setLinearCommentDraft('') + setDetailPayload((current) => + current?.provider === 'linear' + ? { ...current, comments: [...current.comments, comment] } + : current + ) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to add Linear comment') + } finally { + setMutatingStatus(false) + } + }, + [client, linearCommentDraft, mutatingStatus] + ) + + const openLinearSubIssue = useCallback( + async (child: LinearIssueChild, workspaceId?: string): Promise => { + if (!client || mutatingStatus) { + return + } + setMutatingStatus(true) + setError('') + try { + const response = await client.sendRequest( + 'linear.getIssue', + { id: child.id, workspaceId }, + { timeoutMs: 30_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const issue = response.result as LinearIssue | null + if (!issue) { + throw new Error('Sub-issue not found') + } + setActionItem(createLinearTask(issue) as Extract) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load Linear sub-issue') + } finally { + setMutatingStatus(false) + } + }, + [client, mutatingStatus] + ) + + const createLinearSubIssue = useCallback( + async (item: Extract): Promise => { + if (!client || mutatingStatus) { + return + } + const title = linearSubIssueTitle.trim() + if (!title) { + return + } + setMutatingStatus(true) + setError('') + try { + const response = await client.sendRequest( + 'linear.createIssue', + { + teamId: item.source.team.id, + title, + workspaceId: item.source.workspaceId, + parentIssueId: item.source.id, + projectId: item.source.project?.id ?? null + }, + { timeoutMs: 30_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as { + ok?: boolean + id?: string + identifier?: string + title?: string + url?: string + error?: string + } + if (result.ok === false || !result.id || !result.identifier) { + throw new Error(result.error ?? 'Failed to create sub-issue') + } + const child: LinearIssueChild = { + id: result.id, + identifier: result.identifier, + title: result.title ?? title, + url: result.url ?? '' + } + setLinearSubIssueTitle('') + setDetailPayload((current) => + current?.provider === 'linear' + ? { + ...current, + children: current.children.some((entry) => entry.id === child.id) + ? current.children + : [...current.children, child] + } + : current + ) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to create Linear sub-issue') + } finally { + setMutatingStatus(false) + } + }, + [client, linearSubIssueTitle, mutatingStatus] + ) + + const createTask = useCallback(async (): Promise => { + if (!client || !tasksSupported || !taskStateHydrated || creatingTask) { + return + } + const title = createTitle.trim() + if (!title) { + return + } + setCreatingTask(true) + setError('') + try { + if (provider === 'github' || provider === 'gitlab') { + const repo = hostedRepos.find((entry) => entry.id === createRepoId) ?? hostedRepos[0] + if (!repo) { + throw new Error( + `Add a Git repository before creating a ${provider === 'github' ? 'GitHub' : 'GitLab'} issue.` + ) + } + const response = await client.sendRequest( + provider === 'github' ? 'github.createIssue' : 'gitlab.createIssue', + { + repo: `id:${repo.id}`, + title, + body: createBody + } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as { + ok?: boolean + number?: number + url?: string + error?: string + } + if (result.ok === false) { + throw new Error( + result.error ?? `Failed to create ${provider === 'github' ? 'GitHub' : 'GitLab'} issue` + ) + } + if (typeof result.number === 'number') { + const createdAt = new Date().toISOString() + if (provider === 'github') { + setActionItem( + createGitHubTask(repo, { + id: `issue:${result.number}`, + type: 'issue', + number: result.number, + title, + state: 'open', + url: result.url ?? '', + labels: [], + updatedAt: createdAt, + author: null + }) + ) + } else { + setActionItem( + createGitLabTask(repo, { + id: `issue:${result.number}`, + type: 'issue', + number: result.number, + title, + state: 'opened', + url: result.url ?? '', + labels: [], + updatedAt: createdAt, + author: null + }) + ) + } + } + } else { + const team = linearTeams.find((entry) => entry.id === createTeamId) ?? linearTeams[0] + if (!team) { + throw new Error('Select a Linear team first.') + } + const response = await client.sendRequest('linear.createIssue', { + teamId: team.id, + title, + description: createBody.trim() || undefined, + workspaceId: team.workspaceId + }) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + const result = response.result as { + ok?: boolean + id?: string + identifier?: string + title?: string + url?: string + error?: string + } + if (result.ok === false || !result.id || !result.identifier) { + throw new Error(result.error ?? 'Failed to create Linear issue') + } + setActionItem( + createLinearTask({ + id: result.id, + workspaceId: team.workspaceId, + workspaceName: team.workspaceName, + identifier: result.identifier, + title: result.title ?? title, + description: createBody.trim(), + url: result.url ?? '', + state: { name: 'Open', type: 'unstarted', color: colors.accentBlue }, + team, + labels: [], + priority: 0, + updatedAt: new Date().toISOString() + }) as Extract + ) + } + setShowCreateTask(false) + setCreateTitle('') + setCreateBody('') + await loadTasks({ silent: true }) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to create task') + } finally { + setCreatingTask(false) + } + }, [ + client, + createBody, + createRepoId, + createTeamId, + createTitle, + creatingTask, + hostedRepos, + linearTeams, + loadTasks, + provider, + taskStateHydrated, + tasksSupported + ]) + + const setGitHubIssueSourcePreference = useCallback( + async (repo: RepoSummary, preference: 'upstream' | 'origin'): Promise => { + if (!client || !taskUiReady) { + return + } + setError('') + try { + const response = await client.sendRequest( + 'repo.update', + { + repo: `id:${repo.id}`, + updates: { issueSourcePreference: preference } + }, + { timeoutMs: 15_000 } + ) + if (!isSuccess(response)) { + throw new Error(response.error.message) + } + // Why: the host owns issueSourcePreference, so re-read the list instead of + // patching the cached copy and hoping the two stay in step. + await repoListReload().catch(() => {}) + await loadTasks({ silent: true }) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to update issue source') + } + }, + [client, loadTasks, repoListReload, taskUiReady] + ) + + const renderCommentComposer = (args: { + value: string + onChangeText: (next: string) => void + onSubmit: () => void + disabled?: boolean + }): ReactNode => { + const hasText = args.value.trim().length > 0 + return ( + + + {hasText ? ( + [ + styles.commentComposerSend, + pressed && !args.disabled && styles.commentComposerSendPressed, + args.disabled && styles.commentComposerSendDisabled + ]} + disabled={args.disabled} + onPress={args.onSubmit} + > + + + ) : null} + + ) + } + + const renderDetailComment = ( + comment: DetailComment, + options: { nested?: boolean } = {} + ): ReactNode => ( + + + {commentSourceLabel(comment)} + + + {commentAuthor(comment)} + {commentDate(comment.createdAt) ? ` · ${commentDate(comment.createdAt)}` : ''} + + + {renderCommentReactions(comment)} + {SHOW_MOBILE_COMMENT_THREAD_TOOLS && + actionItem?.provider === 'github' && + detailPayload?.provider === 'github' ? ( + + {SHOW_MOBILE_COMMENT_THREAD_TOOLS && + actionItem?.provider === 'github' && + detailPayload?.provider === 'github' ? ( + <> + {actionItem.source.type === 'pr' && comment.threadId ? ( + void toggleGitHubReviewThread(actionItem, comment)} + > + + {comment.isResolved ? 'Reopen thread' : 'Resolve thread'} + + + ) : null} + + setItemReplyDrafts((current) => ({ + ...current, + [String(comment.id)]: next + })) + } + placeholder="Reply" + placeholderTextColor={colors.textMuted} + multiline + textAlignVertical="top" + /> + void replyToGitHubComment(actionItem, comment)} + > + Reply + + + ) : null} + + ) : null} + + ) + + const renderDetailCommentGroup = (group: DetailCommentGroup): ReactNode => { + const id = detailCommentGroupId(group) + const root = detailCommentGroupRoot(group) + const count = detailCommentGroupCount(group) + const isCollapsedResolved = + isResolvedDetailCommentGroup(group) && !expandedResolvedCommentGroups.has(id) + + if (isCollapsedResolved) { + return ( + + setExpandedResolvedCommentGroups((current) => { + const next = new Set(current) + next.add(id) + return next + }) + } + > + + Resolved {group.kind === 'thread' ? 'thread' : 'comment'} by {commentAuthor(root)} + + {count > 1 ? `${count} comments` : 'Show'} + + ) + } + + return ( + + {group.kind === 'thread' + ? [ + renderDetailComment(group.root), + ...group.replies.map((reply) => renderDetailComment(reply, { nested: true })) + ] + : renderDetailComment(group.comment)} + + ) + } + + const createTargetOptions = useMemo[]>( + () => + provider === 'github' || provider === 'gitlab' + ? hostedRepos.map((repo) => ({ + value: repo.id, + label: repo.displayName, + subtitle: repo.path, + renderIcon: () => ( + + ) + })) + : linearTeams.map((team) => ({ + value: team.id, + label: team.name, + subtitle: team.workspaceName + })), + [hostedRepos, linearTeams, provider] + ) + const selectedCreateTarget = + provider === 'github' || provider === 'gitlab' + ? (hostedRepos.find((repo) => repo.id === createRepoId) ?? hostedRepos[0] ?? null) + : (linearTeams.find((team) => team.id === createTeamId) ?? linearTeams[0] ?? null) + const selectedCreateTargetLabel = + provider === 'github' || provider === 'gitlab' + ? ((selectedCreateTarget as RepoSummary | null)?.displayName ?? 'Select target') + : ((selectedCreateTarget as LinearTeam | null)?.name ?? 'Select target') + const providerLabel = + provider === 'github' ? 'GitHub' : provider === 'gitlab' ? 'GitLab' : 'Linear' + const showHeaderCreateTask = + provider === 'linear' || (provider === 'github' && githubMode === 'items') + const providerOptions = useMemo( + () => PROVIDER_OPTIONS.filter((option) => visibleProviders.includes(option.value)), + [visibleProviders] + ) + const selectedCreateRepo = + provider === 'github' || provider === 'gitlab' + ? (selectedCreateTarget as RepoSummary | null) + : null + const selectedCreateGitHubSources = + provider === 'github' && selectedCreateRepo + ? githubRepoSources[selectedCreateRepo.id] + : undefined + const selectedCreateIssuePreference = + selectedCreateRepo?.issueSourcePreference === 'origin' || + selectedCreateRepo?.issueSourcePreference === 'upstream' + ? selectedCreateRepo.issueSourcePreference + : 'upstream' + const githubIssueSourceRows = useMemo( + () => + selectedHostedRepos + .map((repo) => ({ repo, sources: githubRepoSources[repo.id] })) + .filter((entry): entry is { repo: RepoSummary; sources: GitHubRepoSources } => + hasGitHubIssueSourceChoice(entry.sources) + ), + [githubRepoSources, selectedHostedRepos] + ) + const githubIssueSourceLabel = + githubIssueSourceRows.length === 1 + ? issueSourceSlug( + githubIssueSourceRows[0]!.repo.issueSourcePreference === 'origin' + ? githubIssueSourceRows[0]!.sources.prs + : githubIssueSourceRows[0]!.sources.upstreamCandidate + ) + : `${githubIssueSourceRows.length} sources` + const repoPickerLabel = + selectedRepoIds.size === 0 || selectedHostedRepos.length === hostedRepos.length + ? 'All repos' + : selectedHostedRepos.length === 1 + ? selectedHostedRepos[0]!.displayName + : `${selectedHostedRepos.length} repos` + const repoPickerSelectedRepo = + selectedRepoIds.size > 0 && selectedHostedRepos.length === 1 ? selectedHostedRepos[0]! : null + const workspaceRepoOptions = useMemo[]>( + () => + workspaceRepos.map((repo) => ({ + value: repo.id, + label: repo.displayName, + subtitle: repo.path, + renderIcon: () => ( + + ) + })), + [workspaceRepos] + ) + const sortedItems = useMemo(() => { + const next = [...items] + if (taskSort === 'repository') { + next.sort((a, b) => compareTasksByRepository(a, b, reposById)) + } else { + next.sort(compareTasksByUpdated) + } + return next + }, [items, reposById, taskSort]) + const displayedEntries = useMemo(() => { + if (taskSort !== 'repository') { + return sortedItems.map((item) => ({ type: 'item', key: item.key, item })) + } + const entries: TaskListEntry[] = [] + let previousRepoKey = '' + for (const item of sortedItems) { + const repo = taskRepositoryMeta(item, reposById) + if (repo.key !== previousRepoKey) { + entries.push({ + type: 'section', + key: `section:${repo.key}`, + label: repo.label, + color: repo.color + }) + previousRepoKey = repo.key + } + entries.push({ type: 'item', key: item.key, item }) + } + return entries + }, [reposById, sortedItems, taskSort]) + const sortLabel = SORT_OPTIONS.find((option) => option.value === taskSort)?.label ?? 'Updated' + const githubProjectFields = githubProjectTable?.selectedView.fields ?? [] + const githubProjectViewSort = githubProjectTable?.selectedView.sortByFields?.[0] ?? null + const githubProjectSortField = githubProjectSortOverride + ? githubProjectFields.find((field) => field.id === githubProjectSortOverride.fieldId) + : githubProjectViewSort?.field + const githubProjectSortDirection = + githubProjectSortOverride?.direction ?? githubProjectViewSort?.direction ?? null + const githubProjectSortLabel = githubProjectSortField + ? `${githubProjectSortField.name} ${githubProjectSortDirection === 'DESC' ? 'desc' : 'asc'}` + : 'View order' + const githubProjectFieldsLabel = + githubProjectAvailableSummaryFields.length > 0 + ? `${githubProjectSummaryFields.length}/${githubProjectAvailableSummaryFields.length} fields` + : 'Fields' + const githubProjectSortOptions = useMemo[]>( + () => [ + { + value: PROJECT_VIEW_DEFAULT_SORT, + label: 'View order', + subtitle: githubProjectViewSort + ? `Uses ${githubProjectViewSort.field.name} ${githubProjectViewSort.direction.toLowerCase()}` + : 'Uses GitHub rank order' + }, + ...githubProjectFields.map((field) => { + const active = githubProjectSortOverride?.fieldId === field.id + const nextDirection = + !active || githubProjectSortOverride.direction === 'DESC' ? 'ascending' : 'descending' + return { + value: field.id, + label: field.name, + subtitle: active + ? `Currently ${githubProjectSortOverride.direction.toLowerCase()} · tap for ${nextDirection}` + : 'Sort ascending' + } + }) + ], + [githubProjectFields, githubProjectSortOverride, githubProjectViewSort] + ) + const githubProjectViewOptions = useMemo[]>( + () => + githubProjectViews.map((view) => ({ + value: view.id, + label: view.name, + subtitle: + view.layout === 'TABLE_LAYOUT' ? `View #${view.number}` : 'Unsupported layout on mobile', + disabled: view.layout !== 'TABLE_LAYOUT' + })), + [githubProjectViews] + ) + const githubPresetOptions = githubKind === 'prs' ? PR_PRESETS : ISSUE_PRESETS + const githubPresetPickerOptions = useMemo( + () => + githubPresetOptions.map((option) => + option.value === defaultGitHubPreset + ? { ...option, subtitle: option.subtitle ? `${option.subtitle} · Default` : 'Default' } + : option + ), + [defaultGitHubPreset, githubPresetOptions] + ) + const githubPresetLabel = + githubPresetOptions.find((preset) => preset.value === githubPreset)?.label ?? 'Open' + const gitlabFilterLabel = + GITLAB_FILTER_OPTIONS.find((filter) => filter.value === gitlabFilter)?.label ?? 'Open' + const linearFilterLabel = + LINEAR_FILTER_OPTIONS.find((filter) => filter.value === linearFilter)?.label ?? 'All' + const linearViewLabel = + LINEAR_VIEW_OPTIONS.find((option) => option.value === linearViewMode)?.label ?? 'List' + const linearGroupLabel = + LINEAR_GROUP_OPTIONS.find((option) => option.value === linearGroupBy)?.label ?? 'No grouping' + const linearOrderLabel = + LINEAR_ORDER_OPTIONS.find((option) => option.value === linearOrderBy)?.label ?? 'Priority' + const linearWorkspaceLabel = + selectedLinearWorkspaceId === 'all' + ? 'All workspaces' + : (linearWorkspaces.find((workspace) => workspace.id === selectedLinearWorkspaceId) + ?.organizationName ?? + linearWorkspaces.find((workspace) => workspace.id === selectedLinearWorkspaceId) + ?.displayName ?? + 'Workspace') + const linearWorkspaceOptions = useMemo[]>( + () => [ + { value: 'all', label: 'All workspaces' }, + ...linearWorkspaces.map((workspace) => ({ + value: workspace.id, + label: workspace.organizationName ?? workspace.displayName ?? workspace.id + })) + ], + [linearWorkspaces] + ) + const linearTeamLabel = + selectedLinearTeamIds.size === 0 || selectedLinearTeamIds.size === linearTeams.length + ? 'All teams' + : selectedLinearTeamIds.size === 1 + ? (linearTeams.find((team) => selectedLinearTeamIds.has(team.id))?.name ?? '1 team') + : `${selectedLinearTeamIds.size} teams` + const effectiveLinearDisplayProperties = useMemo(() => { + const next = new Set(linearDisplayProperties) + if (linearGroupBy === 'status') { + next.delete('state') + } + if (linearGroupBy === 'assignee') { + next.delete('assignee') + } + if (linearGroupBy === 'priority') { + next.delete('priority') + } + if (linearGroupBy === 'team') { + next.delete('team') + } + if (selectedLinearTeamIds.size <= 1 && !linearTeamPropertyTouched) { + next.delete('team') + } else if (selectedLinearTeamIds.size > 1 && !linearTeamPropertyTouched) { + next.add('team') + } + return next + }, [ + linearDisplayProperties, + linearGroupBy, + linearTeamPropertyTouched, + selectedLinearTeamIds.size + ]) + const linearIssuesForView = useMemo( + () => + items + .filter( + (item): item is Extract => item.provider === 'linear' + ) + .map((item) => item.source) + .sort((a, b) => compareLinearIssues(a, b, linearOrderBy)), + [items, linearOrderBy] + ) + const linearIssueSections = useMemo( + () => groupLinearIssues(linearIssuesForView, linearGroupBy, linearOrderBy), + [linearGroupBy, linearIssuesForView, linearOrderBy] + ) + // Why: FlatList treats data identity as meaningful; unrelated renders should + // not rebuild the section/item wrapper array. + const linearListEntries = useMemo( + () => + linearIssueSections.flatMap((section) => + linearGroupBy === 'none' + ? section.issues.map((issue) => ({ type: 'issue' as const, issue })) + : [ + { type: 'section' as const, section }, + ...section.issues.map((issue) => ({ type: 'issue' as const, issue })) + ] + ), + [linearGroupBy, linearIssueSections] + ) + const linearBoardSections = useMemo( + () => + groupLinearIssues( + linearIssuesForView, + linearGroupBy === 'none' ? 'status' : linearGroupBy, + linearOrderBy + ), + [linearGroupBy, linearIssuesForView, linearOrderBy] + ) + const githubModeLabel = + githubMode === 'project' ? 'Projects' : githubKind === 'prs' ? 'PRs' : 'Issues' + const activeProjectLabel = githubProjectTable + ? githubProjectTable.project.title + : activeGitHubProject + ? `${activeGitHubProject.owner} #${activeGitHubProject.number}` + : 'Choose project' + const selectedGitHubProjectViewUrl = githubProjectTable + ? `${githubProjectTable.project.url}/views/${githubProjectTable.selectedView.number}` + : null + const githubProjectsByKey = useMemo( + () => new Map(githubProjects.map((project) => [githubProjectKey(project), project])), + [githubProjects] + ) + const pinnedGitHubProjects = useMemo( + () => + githubProjectSettings.pinned.map((project) => ({ + ...project, + summary: githubProjectsByKey.get(githubProjectKey(project)) + })), + [githubProjectSettings.pinned, githubProjectsByKey] + ) + const recentGitHubProjects = useMemo( + () => + githubProjectSettings.recent + .filter( + (recent) => + !githubProjectSettings.pinned.some( + (pinned) => githubProjectKey(pinned) === githubProjectKey(recent) + ) + ) + .map((project) => ({ + ...project, + summary: githubProjectsByKey.get(githubProjectKey(project)) + })), + [githubProjectSettings.pinned, githubProjectSettings.recent, githubProjectsByKey] + ) + const browseGitHubProjects = useMemo(() => { + const queryText = githubProjectPickerSearch.trim().toLowerCase() + const pinnedOrRecentKeys = new Set([ + ...githubProjectSettings.pinned.map(githubProjectKey), + ...githubProjectSettings.recent.map(githubProjectKey) + ]) + return githubProjects.filter((project) => { + if (pinnedOrRecentKeys.has(githubProjectKey(project))) { + return false + } + if (!queryText) { + return true + } + return ( + project.title.toLowerCase().includes(queryText) || + project.owner.toLowerCase().includes(queryText) || + String(project.number).includes(queryText) + ) + }) + }, [ + githubProjectPickerSearch, + githubProjectSettings.pinned, + githubProjectSettings.recent, + githubProjects + ]) + + const toggleRepoSelection = useCallback( + (repoId: string) => { + setSelectedRepoIds((current) => { + const next = new Set(current) + if (next.has(repoId)) { + next.delete(repoId) + } else { + next.add(repoId) + } + const normalized = + next.size === 0 || next.size === hostedRepos.length ? new Set() : next + persistRepoSelection(normalized, hostedRepos) + return normalized + }) + }, + [hostedRepos, persistRepoSelection] + ) + + const applyGitHubProjectSearch = useCallback(() => { + const viewFilter = githubProjectTable?.selectedView.filter ?? '' + const next = githubProjectSearch + setAppliedGithubProjectSearch(next === viewFilter ? undefined : next) + }, [githubProjectSearch, githubProjectTable?.selectedView.filter]) + + const headerVerdict = classifyConnection({ + state: connState, + reconnectAttempts, + lastConnectedAt, + ...relayRecovery + }) + const emptyLabel = + connState !== 'connected' + ? 'Connect to a host to load tasks' + : query + ? 'No matching tasks' + : provider === 'github' + ? 'No GitHub tasks' + : provider === 'gitlab' + ? 'No GitLab tasks' + : 'No Linear tasks' + const isGithubProjectSearch = provider === 'github' && githubMode === 'project' + + return ( + + + + router.back()}> + + + + + Tasks + + { + if (!taskUiReady) { + return + } + if (provider === 'github' && githubMode === 'project') { + refreshGitHubProject() + return + } + refreshTasks() + }} + > + + + {showHeaderCreateTask ? ( + { + if (!taskUiReady) { + return + } + if (provider === 'linear' && !linearConnected) { + setLinearApiKeyDraft('') + setLinearConnectState('idle') + setLinearConnectError('') + setShowLinearConnect(true) + return + } + setCreateTitle('') + setCreateBody('') + setShowCreateTask(true) + }} + > + + + ) : null} + + + + { + if (!taskUiReady) { + return + } + setShowProviderPicker(true) + }} + > + + {providerLabel} + + + {provider === 'gitlab' || (provider === 'github' && githubMode !== 'project') ? ( + { + if (!taskUiReady) { + return + } + setShowRepoPicker(true) + }} + > + {repoPickerSelectedRepo ? ( + + ) : null} + {repoPickerLabel} + + ) : null} + + {provider === 'github' && ( + <> + { + if (!taskUiReady) { + return + } + setShowGitHubKindPicker(true) + }} + > + {githubModeLabel} + + {githubMode === 'items' ? ( + <> + { + if (!taskUiReady) { + return + } + setShowGitHubPresetPicker(true) + }} + > + {githubPresetLabel} + + {githubIssueSourceRows.length > 0 ? ( + { + if (!taskUiReady) { + return + } + setShowGitHubIssueSourcePicker(true) + }} + > + + Source: {githubIssueSourceLabel} + + + ) : null} + + ) : ( + <> + { + if (!taskUiReady) { + return + } + setShowGitHubProjectPicker(true) + }} + > + {activeProjectLabel} + + {activeGitHubProjectView ? ( + { + if (!taskUiReady) { + return + } + setShowGitHubProjectViewPicker(true) + }} + > + + {activeGitHubProjectView.name} + + + ) : null} + {githubProjectTable ? ( + { + if (!taskUiReady) { + return + } + setShowGitHubProjectSortPicker(true) + }} + > + + Sort: {githubProjectSortLabel} + + + ) : null} + {githubProjectAvailableSummaryFields.length > 0 ? ( + { + if (!taskUiReady) { + return + } + setShowGitHubProjectFieldsPicker(true) + }} + > + + Fields: {githubProjectFieldsLabel} + + + ) : null} + {githubProjectTable ? ( + + + {visibleGitHubProjectRows.length} + + + ) : null} + {selectedGitHubProjectViewUrl ? ( + { + if (!taskUiReady) { + return + } + void Linking.openURL(selectedGitHubProjectViewUrl) + }} + > + + + ) : null} + + )} + + )} + + {provider === 'gitlab' && ( + <> + { + if (!taskUiReady) { + return + } + setShowGitLabViewPicker(true) + }} + > + + {gitlabView === 'project' ? 'Project MRs' : 'My Todos'} + + + {gitlabView === 'project' && ( + { + if (!taskUiReady) { + return + } + setShowGitLabFilterPicker(true) + }} + > + {gitlabFilterLabel} + + )} + + )} + + {provider === 'linear' && linearConnected && ( + <> + {linearWorkspaces.length > 1 ? ( + { + if (!taskUiReady) { + return + } + setShowLinearWorkspacePicker(true) + }} + > + {linearWorkspaceLabel} + + ) : null} + { + if (!taskUiReady) { + return + } + setShowLinearTeamPicker(true) + }} + > + {linearTeamLabel} + + { + if (!taskUiReady) { + return + } + setShowLinearFilterPicker(true) + }} + > + {linearFilterLabel} + + { + if (!taskUiReady) { + return + } + setShowLinearViewPicker(true) + }} + > + {linearViewLabel} + + { + if (!taskUiReady) { + return + } + setShowLinearGroupPicker(true) + }} + > + Group: {linearGroupLabel} + + { + if (!taskUiReady) { + return + } + setShowLinearOrderPicker(true) + }} + > + Order: {linearOrderLabel} + + { + if (!taskUiReady) { + return + } + setShowLinearDisplayPicker(true) + }} + > + Display + + + )} + + {provider !== 'linear' && !(provider === 'github' && githubMode === 'project') ? ( + { + if (!taskUiReady) { + return + } + setShowSortPicker(true) + }} + > + + Sort: {sortLabel} + + ) : null} + + + {provider === 'gitlab' && gitlabView === 'todos' ? null : provider === 'linear' && + !linearConnected ? null : ( + + 0 || + (appliedGithubProjectSearch !== undefined && + appliedGithubProjectSearch.length > 0) + : provider === 'github' + ? query.trim() !== getTaskPresetQuery(githubPreset).trim() + : undefined + } + editable={taskUiReady} + onSubmitEditing={() => { + if (!taskUiReady) { + return + } + if (isGithubProjectSearch) { + applyGitHubProjectSearch() + return + } + const nextQuery = + provider === 'github' ? scopeGitHubTaskSearch(query, githubKind) : query.trim() + setQuery(nextQuery) + setAppliedQuery(nextQuery) + if (provider === 'github') { + persistTaskResumeState({ + githubItemsPreset: + nextQuery.trim() === getTaskPresetQuery(githubPreset) ? githubPreset : null, + githubItemsQuery: nextQuery.trim() + }) + } else if (provider === 'linear') { + persistTaskResumeState({ linearQuery: nextQuery.trim() }) + } + }} + onBlur={() => { + if (isGithubProjectSearch) { + applyGitHubProjectSearch() + } + }} + // Why: Project clear means unfiltered results ('' override when the view + // has a default filter), not restore view default. GitHub items clear + // restores the preset query. Linear clears and persists empty resume. + onClear={() => { + if (isGithubProjectSearch) { + const viewFilter = githubProjectTable?.selectedView.filter ?? '' + setGithubProjectSearch('') + // Why: undefined = use view default; '' = explicit unfiltered override. + setAppliedGithubProjectSearch(viewFilter ? '' : undefined) + return + } + if (provider === 'github') { + const nextQuery = getTaskPresetQuery(githubPreset) + setQuery(nextQuery) + setAppliedQuery(nextQuery) + persistTaskResumeState({ + githubItemsPreset: githubPreset, + githubItemsQuery: nextQuery + }) + return + } + setQuery('') + setAppliedQuery('') + if (provider === 'linear') { + persistTaskResumeState({ linearQuery: '' }) + } + }} + /> + + )} + + + {error ? ( + + {error} + + ) : null} + + {!error && provider === 'github' && githubMode === 'items' + ? githubSourceFallbacks.map((fallback) => ( + + + Preferred issue source upstream is unavailable for {fallback.repoLabel}. Using + origin. + + + )) + : null} + + {!error && provider === 'github' && githubMode === 'items' + ? githubSourceErrors.map((sourceError) => { + const isRetrying = retryingGithubSourceRepoPaths.has(sourceError.repoPath) + return ( + + + + Couldn't load issues from{' '} + + {sourceError.source.owner}/{sourceError.source.repo} + + . + + + {sourceError.message} + + + void retryGitHubIssueSourceFetch(sourceError.repoPath)} + > + + {isRetrying ? 'Retrying...' : 'Retry'} + + + + ) + }) + : null} + + {!error && + provider === 'github' && + githubMode === 'project' && + githubProjectTable?.parentFieldDropped === true ? ( + + + + Sub-issue data is unavailable for your token. + + + ) : null} + + {!tasksSupported ? ( + tasksUnsupported ? ( + + Update Orca desktop + + This mobile Tasks view needs a newer desktop runtime. + + + ) : ( + + + + ) + ) : provider === 'linear' && !linearConnected ? ( + + + Connect your Linear account + + Browse and start work on your assigned Linear issues directly from Tasks. + + { + if (!taskUiReady) { + return + } + setLinearApiKeyDraft('') + setLinearConnectState('idle') + setLinearConnectError('') + setShowLinearConnect(true) + }} + > + Connect Linear + + + ) : provider === 'github' && githubMode === 'project' ? ( + githubProjectLoading ? ( + + + + ) : !activeGitHubProject ? ( + + Choose a GitHub project + { + if (!taskUiReady) { + return + } + setShowGitHubProjectPicker(true) + }} + > + Browse projects + + + ) : githubProjectError ? ( + + {githubProjectError} + + ) : githubProjectTable && !githubProjectRepoSlugReady ? ( + + + + ) : !githubProjectTable || visibleGitHubProjectRows.length === 0 ? ( + + No project items + + ) : ( + + entry.type === 'group' ? `group:${entry.group.key}` : entry.row.id + } + ItemSeparatorComponent={() => } + contentContainerStyle={[styles.list, { paddingBottom: spacing.lg + insets.bottom }]} + refreshing={githubProjectLoading} + onRefresh={refreshGitHubProject} + renderItem={({ item: entry }) => { + if (entry.type === 'group') { + return ( + + setCollapsedGitHubProjectGroups((current) => { + const next = new Set(current) + if (next.has(entry.group.key)) { + next.delete(entry.group.key) + } else { + next.add(entry.group.key) + } + return next + }) + } + > + + + {entry.group.label || 'Items'} + + {projectGroupMeta(entry.group)} + + ) + } + const row = entry.row + const repo = findProjectRowRepo(row) + return ( + [styles.taskRow, pressed && styles.taskRowPressed]} + onPress={() => { + triggerMediumImpact() + setProjectRowItem(row) + }} + > + + + + + + + {row.content.title} + + {formatUpdatedAt(row.updatedAt)} + + + + + {row.itemType === 'PULL_REQUEST' + ? 'Pull request' + : row.itemType === 'ISSUE' + ? 'Issue' + : 'Project item'}{' '} + · {row.content.repository ?? githubProjectTable.project.title} + {row.content.number ? ` #${row.content.number}` : ''} + + + {githubProjectSummaryFields.length > 0 ? ( + + {githubProjectSummaryFields.slice(0, 4).map((field) => { + const value = projectFieldDisplayLabel(row, field) + const isEmpty = value === 'Empty' + return ( + + + {field.name}:{' '} + + {value} + + + + ) + })} + {githubProjectSummaryFields.length > 4 ? ( + + + +{githubProjectSummaryFields.length - 4} fields + + + ) : null} + + ) : null} + + + + {projectRowStatusLabel(row)} + + + + ) + }} + /> + ) + ) : provider === 'linear' ? ( + loading ? ( + + + + ) : linearIssuesForView.length === 0 ? ( + + {emptyLabel} + + ) : linearViewMode === 'board' ? ( + + {linearBoardSections.map((section) => ( + + + + + {section.label} + + {section.issues.length} + + + {section.issues.map((issue) => ( + [styles.boardCard, pressed && styles.taskRowPressed]} + onPress={() => { + triggerMediumImpact() + setActionItem( + createLinearTask(issue) as Extract + ) + }} + > + + {issue.title} + + + {linearIssueSecondaryParts(issue, effectiveLinearDisplayProperties).join( + ' · ' + )} + + {effectiveLinearDisplayProperties.has('state') ? ( + { + event.stopPropagation() + triggerMediumImpact() + setLinearStatusPickerItem( + createLinearTask(issue) as Extract + ) + }} + > + + + {issue.state.name} + + + + ) : null} + + ))} + + + ))} + + ) : ( + + entry.type === 'section' ? `linear-section:${entry.section.key}` : entry.issue.id + } + ItemSeparatorComponent={({ leadingItem, trailingItem }) => + leadingItem?.type === 'section' || trailingItem?.type === 'section' ? null : ( + + ) + } + contentContainerStyle={[styles.list, { paddingBottom: spacing.lg + insets.bottom }]} + refreshing={refreshing} + onRefresh={refreshTasks} + renderItem={({ item: entry }) => { + if (entry.type === 'section') { + return ( + + + + {entry.section.label} + + {entry.section.issues.length} + + ) + } + const issue = entry.issue + const linearTask = createLinearTask(issue) as Extract< + TaskItem, + { provider: 'linear' } + > + return ( + [styles.taskRow, pressed && styles.taskRowPressed]} + onPress={() => { + triggerMediumImpact() + setActionItem(linearTask) + }} + > + + + + + + + {issue.title} + + {effectiveLinearDisplayProperties.has('updated') ? ( + {formatUpdatedAt(issue.updatedAt)} + ) : null} + + + + + {linearIssueSecondaryParts(issue, effectiveLinearDisplayProperties).join( + ' · ' + )} + + + + + {effectiveLinearDisplayProperties.has('state') ? ( + { + event.stopPropagation() + triggerMediumImpact() + setLinearStatusPickerItem(linearTask) + }} + > + + + {issue.state.name} + + + + ) : null} + + + ) + }} + /> + ) + ) : loading ? ( + + + + ) : sortedItems.length === 0 ? ( + + {emptyLabel} + + ) : ( + entry.key} + ItemSeparatorComponent={({ leadingItem, trailingItem }) => + leadingItem?.type === 'section' || trailingItem?.type === 'section' ? null : ( + + ) + } + contentContainerStyle={[styles.list, { paddingBottom: spacing.lg + insets.bottom }]} + refreshing={refreshing} + onRefresh={refreshTasks} + ListFooterComponent={ + provider === 'github' && githubMode === 'items' && githubCanShowPagination ? ( + + void handleGitHubPageChange(githubCurrentPage - 1)} + > + + + { + if (!taskUiReady) { + return + } + setShowGitHubPagePicker(true) + }} + > + + {githubTotalCount === null + ? `Page ${githubCurrentPage + 1}` + : `Page ${githubCurrentPage + 1} of ${githubTotalPages}`} + + + = githubTotalPages - 1) || + githubPaginationLoading + }} + style={[ + styles.paginationButton, + ((!githubCanLoadUncountedNextPage && + githubCurrentPage >= githubTotalPages - 1) || + githubPaginationLoading) && + styles.paginationButtonDisabled + ]} + disabled={ + (!githubCanLoadUncountedNextPage && + githubCurrentPage >= githubTotalPages - 1) || + githubPaginationLoading + } + onPress={() => void handleGitHubPageChange(githubCurrentPage + 1)} + > + {githubLoadingTargetPage === githubCurrentPage + 1 ? ( + + ) : ( + + )} + + + ) : null + } + renderItem={({ item: entry }) => { + if (entry.type === 'section') { + return ( + + + + {entry.label} + + + ) + } + const item = entry.item + const repo = taskRepositoryMeta(item, reposById) + const isGitHubPr = item.provider === 'github' && item.source.type === 'pr' + const isGitLabMr = item.provider === 'gitlab' && item.source.type === 'mr' + const githubPrDelta = isGitHubPr ? formatGitHubPRDelta(item.source) : null + const branchSummary = hostedBranchSummary(item) + return ( + [styles.taskRow, pressed && styles.taskRowPressed]} + onPress={() => { + triggerMediumImpact() + if (item.provider === 'gitlabTodo') { + void Linking.openURL(item.source.targetUrl) + return + } + setActionItem(item) + }} + > + + + + + + + {item.title} + + {formatUpdatedAt(item.updatedAt)} + + + + + {taskKindLabel(item)} · {item.subtitle} + + + {branchSummary ? ( + + + + {branchSummary.head} + + + into {branchSummary.base} + + + ) : null} + {isGitHubPr || isGitLabMr ? ( + + {isGitHubPr && githubPrDelta ? ( + + + {githubPrDelta} + + + ) : null} + {isGitHubPr || isGitLabMr ? ( + + + {isGitHubPr + ? getGitHubReviewSummary(item.source) + : getHostedReviewLabel(item.source)} + + + ) : null} + + + {getHostedChecksLabel(item.source)} + + + {isGitHubPr || isGitLabMr ? ( + + + {isGitHubPr + ? getGitHubMergeLabel(item.source) + : getHostedMergeLabel(item.source)} + + + ) : null} + + ) : null} + + + + + {item.status} + + + + + ) + }} + /> + )} + + { + const resume = taskResumeRef.current + persistTaskSource(next) + setProvider(next) + setItems([]) + if (next === 'github') { + const nextMode = resume.githubMode === 'project' ? 'project' : 'items' + setGithubMode(nextMode) + if (nextMode === 'project') { + setQuery('') + setAppliedQuery('') + return + } + const preset = + resume.githubItemsPreset === null + ? githubPreset + : normalizeGitHubPreset(resume.githubItemsPreset ?? githubPreset) + const nextQuery = + resume.githubItemsPreset === null + ? (resume.githubItemsQuery ?? '') + : getTaskPresetQuery(preset) + const nextKind = githubKindFromQuery(nextQuery, preset) + setGithubPreset(preset) + setGithubKind(nextKind) + setQuery(nextQuery) + setAppliedQuery(scopeGitHubTaskSearch(nextQuery, nextKind)) + } else if (next === 'linear') { + const nextQuery = resume.linearQuery ?? '' + setLinearFilter(normalizeLinearFilter(resume.linearPreset)) + setQuery(nextQuery) + setAppliedQuery(nextQuery.trim()) + } else { + setQuery('') + setAppliedQuery('') + } + }} + onClose={() => setShowProviderPicker(false)} + /> + + setShowRepoPicker(false)} + > + + Repositories + Choose which repositories to query. + + + + { + const allSelection = new Set() + setSelectedRepoIds(allSelection) + persistRepoSelection(allSelection, hostedRepos) + }} + > + + All repositories + {repositoryCount(hostedRepos.length)} + + {selectedRepoIds.size === 0 ? : null} + + + {hostedRepos.map((repo) => { + const selected = selectedRepoIds.has(repo.id) + return ( + + + toggleRepoSelection(repo.id)} + > + + + + {repo.displayName} + + + {repo.path} + + + {selected ? : null} + + + ) + })} + + + + setShowGitHubIssueSourcePicker(false)} + > + + GitHub Issue Sources + + Choose whether each repository queries and creates work from upstream or origin. + + + + + {githubIssueSourceRows.length === 0 ? ( + + No alternate issue sources available. + + ) : ( + githubIssueSourceRows.map(({ repo, sources }, index) => { + const selectedPreference = + repo.issueSourcePreference === 'origin' || repo.issueSourcePreference === 'upstream' + ? repo.issueSourcePreference + : 'upstream' + return ( + + {index > 0 ? : null} + + + + {repo.displayName} + + + Querying{' '} + {issueSourceSlug( + selectedPreference === 'origin' ? sources.prs : sources.upstreamCandidate + )} + + + + {(['upstream', 'origin'] as const).map((preference) => { + const selected = selectedPreference === preference + const slug = + preference === 'upstream' + ? issueSourceSlug(sources.upstreamCandidate) + : issueSourceSlug(sources.prs) + return ( + void setGitHubIssueSourcePreference(repo, preference)} + > + + {preference === 'upstream' ? 'Upstream' : 'Origin'} + + + {slug} + + + ) + })} + + + + ) + }) + )} + + + + { + if (kind === 'project') { + setGithubMode('project') + setItems([]) + persistTaskResumeState({ githubMode: 'project' }) + return + } + const preset = kind === 'prs' ? 'prs' : 'issues' + const nextQuery = getTaskPresetQuery(preset) + setGithubMode('items') + setGithubKind(kind) + setGithubPreset(preset) + setQuery(nextQuery) + setAppliedQuery(nextQuery) + persistTaskResumeState({ + githubMode: 'items', + githubItemsPreset: preset, + githubItemsQuery: nextQuery + }) + }} + onClose={() => setShowGitHubKindPicker(false)} + /> + + { + const nextQuery = getTaskPresetQuery(preset) + setGithubMode('items') + setGithubKind(preset === 'issues' || preset === 'my-issues' ? 'issues' : 'prs') + setGithubPreset(preset) + setQuery(nextQuery) + setAppliedQuery(nextQuery) + persistTaskResumeState({ + githubItemsPreset: preset, + githubItemsQuery: nextQuery + }) + }} + onLongSelect={persistDefaultGitHubPreset} + onClose={() => setShowGitHubPresetPicker(false)} + /> + + setShowGitHubPagePicker(false)} + > + + GitHub Pages + Jump to a loaded or available result page. + + + {githubPagePickerPages.map((index) => { + const selected = index === githubCurrentPage + const loaded = index < githubPages.length + return ( + { + setShowGitHubPagePicker(false) + void handleGitHubPageChange(index) + }} + > + + Page {index + 1} + + {loaded ? 'Loaded' : 'Loads older results'} + + + {selected ? : null} + + ) + })} + + + + setShowGitHubProjectPicker(false)} + > + + GitHub Projects + Choose a project view for the Tasks page. + + + + + + { + setGithubProjectPasteInput(next) + setGithubProjectPasteError('') + }} + onSubmitEditing={() => void resolveGitHubProjectFromInput()} + placeholder="Add by URL or owner/number" + placeholderTextColor={colors.textMuted} + autoCapitalize="none" + autoCorrect={false} + /> + void resolveGitHubProjectFromInput()} + > + + {githubProjectPasteBusy ? 'Adding...' : 'Add'} + + + + {githubProjectPasteError ? ( + {githubProjectPasteError} + ) : null} + + + {githubProjectError ? {githubProjectError} : null} + + {githubProjectPartialFailures.length > 0 ? ( + + + + + {githubProjectPartialFailures.length === 1 && + githubProjectPartialFailures[0]!.owner !== '*' + ? `Couldn't load projects from ${githubProjectPartialFailures[0]!.owner}.` + : `Some organizations didn't load (${githubProjectPartialFailures.length}).`} + + + Use Add by URL to reach missing projects. + + + {githubProjectPartialFailures + .map( + (failure) => + `${failure.owner === '*' ? 'orgs' : failure.owner}: ${failure.message}` + ) + .join(' · ')} + + + + ) : null} + + + {githubProjectLoading && githubProjects.length === 0 ? ( + + + + ) : githubProjects.length === 0 && + pinnedGitHubProjects.length === 0 && + recentGitHubProjects.length === 0 ? ( + + void loadGitHubProjects().catch((err) => { + setGithubProjectError( + err instanceof Error ? err.message : 'Failed to load projects' + ) + }) + } + > + + No projects loaded + Tap to retry. + + + ) : ( + <> + {pinnedGitHubProjects.length > 0 ? ( + <> + Pinned + {pinnedGitHubProjects.map((project, index) => { + const key = githubProjectKey(project) + const selected = + activeGitHubProject !== null && githubProjectKey(activeGitHubProject) === key + return ( + + {index > 0 ? : null} + { + setShowGitHubProjectPicker(false) + void selectGitHubProject(project) + }} + > + + + {project.summary?.title ?? `#${project.number}`} + + + {project.owner} · #{project.number} + + + { + event.stopPropagation() + persistGitHubProjectSettings({ + ...githubProjectSettings, + pinned: githubProjectSettings.pinned.filter( + (entry) => githubProjectKey(entry) !== key + ) + }) + }} + > + Remove + + {selected ? : null} + + + ) + })} + + ) : null} + + {recentGitHubProjects.length > 0 ? ( + <> + Recent + {recentGitHubProjects.map((project, index) => { + const key = githubProjectKey(project) + const selected = + activeGitHubProject !== null && githubProjectKey(activeGitHubProject) === key + return ( + + {index > 0 ? : null} + { + setShowGitHubProjectPicker(false) + void selectGitHubProject(project) + }} + > + + + {project.summary?.title ?? `#${project.number}`} + + + {project.owner} · #{project.number} + + + {githubProjectSettings.lastViewByProject[key]?.viewId ? ( + { + event.stopPropagation() + persistGitHubProjectSettings({ + ...githubProjectSettings, + pinned: [ + ...githubProjectSettings.pinned, + { + owner: project.owner, + ownerType: project.ownerType, + number: project.number + } + ].slice(0, 20) + }) + }} + > + Pin + + ) : null} + {selected ? : null} + + + ) + })} + + ) : null} + + + {githubProjectLoading ? 'Browse all (loading...)' : 'Browse all'} + + {browseGitHubProjects.length === 0 ? ( + + {githubProjectPickerSearch.trim() ? 'No matching projects.' : 'No more projects.'} + + ) : ( + browseGitHubProjects.map((project, index) => { + const selected = + activeGitHubProject !== null && + githubProjectKey(activeGitHubProject) === githubProjectKey(project) + return ( + + {index > 0 ? : null} + { + setShowGitHubProjectPicker(false) + void selectGitHubProject(project) + }} + > + + + {project.title} + + + {project.owner} · #{project.number} + + + {selected ? : null} + + + ) + }) + )} + + )} + + + + { + const view = githubProjectViews.find((candidate) => candidate.id === viewId) + if (view && view.layout !== 'TABLE_LAYOUT') { + setGithubProjectError("Orca doesn't support this GitHub Project layout yet.") + return + } + if (pendingGitHubProjectViewSelection) { + commitGitHubProjectView(pendingGitHubProjectViewSelection, viewId) + setPendingGitHubProjectViewSelection(null) + return + } + if (!activeGitHubProject || !activeGitHubProjectKey) { + return + } + commitGitHubProjectView(activeGitHubProject, viewId) + }} + onClose={() => { + setShowGitHubProjectViewPicker(false) + if (pendingGitHubProjectViewSelection) { + setPendingGitHubProjectViewSelection(null) + } + }} + /> + + { + if (fieldId === PROJECT_VIEW_DEFAULT_SORT) { + setGithubProjectSortOverride(null) + return + } + setGithubProjectSortOverride((current) => { + if (!current || current.fieldId !== fieldId) { + return { fieldId, direction: 'ASC' } + } + if (current.direction === 'ASC') { + return { fieldId, direction: 'DESC' } + } + return null + }) + }} + onClose={() => setShowGitHubProjectSortPicker(false)} + /> + + setShowGitHubProjectFieldsPicker(false)} + > + + Project Fields + + Choose which Project fields appear on item cards. + + + + {githubProjectAvailableSummaryFields.length === 0 ? ( + This view has no extra fields to show. + ) : ( + githubProjectAvailableSummaryFields.map((field, index) => { + const visible = !githubProjectHiddenFieldIds.has(field.id) + return ( + + {index > 0 ? : null} + toggleGitHubProjectFieldVisibility(field.id)} + > + + + {field.name} + + + {visible ? 'Shown on cards' : 'Hidden from cards'} + + + {visible ? : null} + + + ) + }) + )} + + + + { + setGitlabView(view) + if (view === 'todos') { + // Why: GitLab Todos is a server-side pending-todos stream; the + // runtime method has no search query, so clear item-search state + // when entering that view instead of carrying an invisible filter. + setQuery('') + setAppliedQuery('') + } + }} + onClose={() => setShowGitLabViewPicker(false)} + /> + + setShowGitLabFilterPicker(false)} + /> + + { + setLinearFilter(filter) + setQuery('') + setAppliedQuery('') + persistTaskResumeState({ linearPreset: filter, linearQuery: '' }) + }} + onClose={() => setShowLinearFilterPicker(false)} + /> + + { + setSelectedLinearWorkspaceId(workspaceId) + setSelectedLinearTeamIds(new Set()) + if (client) { + void client + .sendRequest('linear.selectWorkspace', { workspaceId }) + .then(() => loadLinearContext()) + .catch((err) => { + setError(err instanceof Error ? err.message : 'Failed to switch workspace') + }) + } + }} + onClose={() => setShowLinearWorkspacePicker(false)} + /> + + setShowLinearTeamPicker(false)} + > + + Linear Teams + Choose which teams appear in Tasks. + + + { + const next = new Set(linearTeams.map((team) => team.id)) + setSelectedLinearTeamIds(next) + persistLinearTeamSelection(next, linearTeams) + }} + > + + All teams + {linearTeams.length} teams + + {selectedLinearTeamIds.size === linearTeams.length ? ( + + ) : null} + + {linearTeams.map((team) => { + const selected = selectedLinearTeamIds.has(team.id) + return ( + + + { + const next = new Set(selectedLinearTeamIds) + if (next.has(team.id)) { + next.delete(team.id) + } else { + next.add(team.id) + } + const normalized = + next.size === 0 || next.size === linearTeams.length + ? new Set(linearTeams.map((entry) => entry.id)) + : next + setSelectedLinearTeamIds(normalized) + persistLinearTeamSelection(normalized, linearTeams) + }} + > + + + {team.name} + + + {team.workspaceName ?? team.key} + + + {selected ? : null} + + + ) + })} + + + + setLinearStatusPickerItem(null)} + zIndex={TASK_SECONDARY_DRAWER_Z_INDEX} + > + + Change Status + + {linearStatusPickerItem?.source.identifier ?? 'Linear issue'} + + + + {linearStatesLoading ? ( + + + Loading states... + + ) : linearStates.length === 0 ? ( + No states available + ) : ( + linearStates.map((state, index) => { + const selected = + state.name === linearStatusPickerItem?.source.state.name && + state.type === linearStatusPickerItem?.source.state.type + return ( + + {index > 0 ? : null} + { + if (!linearStatusPickerItem) { + return + } + void setLinearStatus(linearStatusPickerItem, state, { + closeDetail: false + }).then(() => setLinearStatusPickerItem(null)) + }} + > + + + + {state.name} + + + {state.type} + + + {selected ? : null} + + + ) + }) + )} + + + + setShowLinearViewPicker(false)} + /> + + setShowLinearGroupPicker(false)} + /> + + setShowLinearOrderPicker(false)} + /> + + setShowLinearDisplayPicker(false)} + > + + Display Properties + + + {LINEAR_DISPLAY_OPTIONS.map((property, index) => { + const selected = effectiveLinearDisplayProperties.has(property.value) + return ( + + {index > 0 ? : null} + { + if (property.value === 'team') { + setLinearTeamPropertyTouched(true) + } + setLinearDisplayProperties((current) => { + const next = new Set(current) + if (next.has(property.value)) { + next.delete(property.value) + } else { + next.add(property.value) + } + return next + }) + }} + > + + {property.label} + + {selected ? : null} + + + ) + })} + + + + setShowSortPicker(false)} + /> + + { + setShowCreateTargetPicker(false) + setShowCreateTask(false) + }} + > + + + + New {providerLabel} Issue + + + {provider === 'github' || provider === 'gitlab' + ? 'Create an issue in the selected repository.' + : 'Create an issue in the selected Linear team.'} + + + + + + {provider === 'github' || provider === 'gitlab' ? 'Repository' : 'Team'} + + { + if (!taskUiReady) { + return + } + setShowCreateTargetPicker(true) + }} + > + {provider === 'github' || provider === 'gitlab' ? ( + + ) : null} + + {selectedCreateTargetLabel} + + + + + {provider === 'github' && + selectedCreateRepo && + hasGitHubIssueSourceChoice(selectedCreateGitHubSources) ? ( + + Issue source + + File in{' '} + {selectedCreateIssuePreference === 'origin' + ? issueSourceSlug(selectedCreateGitHubSources?.prs) + : issueSourceSlug(selectedCreateGitHubSources?.upstreamCandidate)} + + + {(['upstream', 'origin'] as const).map((preference) => { + const selected = selectedCreateIssuePreference === preference + const slug = + preference === 'upstream' + ? issueSourceSlug(selectedCreateGitHubSources?.upstreamCandidate) + : issueSourceSlug(selectedCreateGitHubSources?.prs) + return ( + + void setGitHubIssueSourcePreference(selectedCreateRepo, preference) + } + > + + {preference === 'upstream' ? 'Upstream' : 'Origin'} + + + {slug} + + + ) + })} + + + ) : null} + + Title + + + Description + + + void createTask()} + > + {creatingTask ? ( + + ) : ( + Create Issue + )} + + + + + { + if (provider === 'github' || provider === 'gitlab') { + setCreateRepoId(value) + } else { + setCreateTeamId(value) + } + }} + onClose={() => setShowCreateTargetPicker(false)} + /> + + { + if (linearConnectState !== 'connecting') { + setShowLinearConnect(false) + } + }} + > + + + + Connect Linear workspace + + + Paste a Personal API key to browse issues from that workspace. + + + + Personal API key + { + setLinearApiKeyDraft(next) + if (linearConnectState === 'error') { + setLinearConnectState('idle') + setLinearConnectError('') + } + }} + placeholder="lin_api_..." + placeholderTextColor={colors.textMuted} + autoCapitalize="none" + autoCorrect={false} + secureTextEntry + editable={linearConnectState !== 'connecting'} + onSubmitEditing={() => void connectLinearAccount()} + /> + {linearConnectState === 'error' && linearConnectError ? ( + {linearConnectError} + ) : null} + void Linking.openURL('https://linear.app/settings/account/security')} + > + + Linear Settings / Security / New API key + + + + + Your key is encrypted via the host OS keychain and stored locally. + + + void connectLinearAccount()} + > + {linearConnectState === 'connecting' ? ( + + ) : ( + Connect + )} + + + + + { + if (workspaceRepoPickerItem) { + openWorkspaceCreate(workspaceRepoPickerItem, repoId) + } + setWorkspaceRepoPickerItem(null) + }} + onClose={() => setWorkspaceRepoPickerItem(null)} + zIndex={TASK_SECONDARY_DRAWER_Z_INDEX} + /> + + setWorkspaceCreateDraft(null)} + zIndex={TASK_SECONDARY_DRAWER_Z_INDEX} + > + {workspaceCreateDraft ? ( + + + Create Workspace + + {workspaceCreateDraft.item.title} + + + + + + Repository + setShowWorkspaceCreateRepoPicker(true)} + > + {workspaceCreateTargetRepo ? ( + + ) : null} + + {workspaceCreateTargetRepo?.displayName ?? 'Select a repository'} + + {workspaceCreateCanPickRepo ? ( + + ) : null} + + + + {workspaceCreateTargetConnectionId ? ( + + SSH Connection + + + + + + {workspaceCreateTargetRepo?.displayName ?? 'Remote repository'} + + + {workspaceSshStatusLabel(workspaceCreateSshStatus)} + + + {workspaceCreateSshStatus === 'connected' ? null : ( + void connectWorkspaceSshRepo()} + > + + {workspaceCreateSshConnectInProgress ? 'Connecting...' : 'Connect'} + + + )} + + {workspaceCreateSshError ? ( + {workspaceCreateSshError} + ) : null} + + + ) : null} + + + + Workspace Name [Optional] + + + + + + Agent + setShowWorkspaceAgentPicker(true)} + > + + + {workspaceCreateRequiresSshConnection + ? 'Connect repository first' + : workspaceAgentDetectionPending + ? 'Detecting agents...' + : workspaceAgentLabel(resolvedWorkspaceAgent)} + + + + + + setShowWorkspaceAdvanced((current) => !current)} + > + Advanced + {showWorkspaceAdvanced ? ( + + ) : ( + + )} + + + {showWorkspaceAdvanced ? ( + + Start from + { + setWorkspaceBaseBranchQuery(workspaceBaseBranch?.refName ?? '') + setShowWorkspaceBaseBranchPicker(true) + }} + > + + + {workspaceBaseBranch?.refName ?? 'Default branch'} + + + + {workspaceBaseBranch ? ( + + Create from {workspaceBaseBranch.refName} + + ) : null} + + ) : null} + + + {workspaceCreateTargetRepo ? null : ( + + {workspaceCreateDraft.item.provider === 'linear' + ? 'Add a Git repository before creating a Linear workspace.' + : 'Repository not found.'} + + )} + + + { + // Why: this compact issue-to-workspace flow should match the + // basic create workspace path; sparse checkout can return later. + void createWorkspace( + workspaceCreateDraft.item, + workspaceCreateDraft.repoIdOverride, + undefined, + resolvedWorkspaceAgent, + workspaceNameDraft.trim(), + undefined, + workspaceBaseBranch?.refName, + workspaceBranchNameOverride && + workspaceNameDraft.trim() === workspaceBranchAutoName + ? workspaceBranchNameOverride + : undefined, + undefined + ) + }} + > + {creatingKey === workspaceCreateDraft.item.key ? ( + + ) : ( + + {workspaceAgentDetectionPending + ? 'Detecting agents...' + : workspaceCreateRequiresSshConnection + ? 'Connect Repository' + : 'Create Workspace'} + + )} + + + + ) : null} + + + { + setWorkspaceCreateDraft((current) => + current ? { ...current, repoIdOverride: repoId } : current + ) + setShowWorkspaceCreateRepoPicker(false) + }} + onClose={() => setShowWorkspaceCreateRepoPicker(false)} + zIndex={TASK_SECONDARY_DRAWER_Z_INDEX + 1} + /> + + { + setWorkspaceAgentOverridden(true) + setWorkspaceAgent(agent) + setShowWorkspaceAgentPicker(false) + }} + onClose={() => setShowWorkspaceAgentPicker(false)} + zIndex={TASK_SECONDARY_DRAWER_Z_INDEX + 1} + /> + + setShowWorkspaceBaseBranchPicker(false)} + zIndex={TASK_SECONDARY_DRAWER_Z_INDEX + 1} + > + + + Start From + Pick an existing branch or ref. + + + + { + clearWorkspaceBaseBranch() + }} + > + + {workspaceBaseBranch === null ? ( + + ) : null} + + + Default branch + Use this repository's configured base + + + {workspaceBaseBranchLoading ? ( + + + + ) : workspaceBaseBranchError ? ( + {workspaceBaseBranchError} + ) : workspaceBaseBranchQuery.trim() && workspaceBaseBranchResults.length === 0 ? ( + No branches match. + ) : null} + {workspaceBaseBranchResults.map((branch) => ( + + + { + selectWorkspaceBaseBranch(branch) + }} + > + + {workspaceBaseBranch?.refName === branch.refName ? ( + + ) : null} + + + + {branch.refName} + + {branch.localBranchName !== branch.refName ? ( + + Branch name: {branch.localBranchName} + + ) : null} + + + + ))} + + + + + setShowWorkspaceSparsePicker(false)} + zIndex={TASK_SECONDARY_DRAWER_Z_INDEX + 1} + > + + + Sparse Checkout + + + { + setWorkspaceSparsePresetId(null) + setShowWorkspaceSparsePicker(false) + }} + > + + {workspaceSparsePresetId === null ? ( + + ) : null} + + + Full checkout + Use the whole repository + + + {workspaceSparsePresets.map((preset) => ( + + + + { + setWorkspaceSparsePresetId(preset.id) + setShowWorkspaceSparsePicker(false) + }} + > + + {workspaceSparsePresetId === preset.id ? ( + + ) : null} + + + + {preset.name} + + + {preset.directories.join(', ')} + + + + startEditWorkspaceSparsePreset(preset)} + > + + + + + ))} + + + New preset + + + + + { + if (!workspaceSparseSaving) { + setWorkspaceSparseDraft(null) + } + }} + zIndex={TASK_SECONDARY_DRAWER_Z_INDEX + 2} + > + {workspaceSparseDraft ? ( + + + + {workspaceSparseDraft.mode === 'new' ? 'New Sparse Preset' : 'Edit Sparse Preset'} + + + + + Name + + setWorkspaceSparseDraft({ ...workspaceSparseDraft, name }) + } + placeholder="Renderer UI" + placeholderTextColor={colors.textMuted} + autoCapitalize="none" + autoCorrect={false} + maxLength={80} + /> + + + Directories + + setWorkspaceSparseDraft({ ...workspaceSparseDraft, directoriesText }) + } + placeholder={'src/renderer\npackages/ui'} + placeholderTextColor={colors.textMuted} + autoCapitalize="none" + autoCorrect={false} + multiline + textAlignVertical="top" + /> + + + {workspaceSparseDraftError ?? + (workspaceSparseDraftParsed?.directories.length === 1 + ? '1 directory' + : `${workspaceSparseDraftParsed?.directories.length ?? 0} directories`)} + + + + setWorkspaceSparseDraft(null)} + > + Cancel + + void saveWorkspaceSparsePreset()} + > + {workspaceSparseSaving ? ( + + ) : null} + Save + + + + ) : null} + + + setSetupPrompt(null)} + zIndex={TASK_SECONDARY_DRAWER_Z_INDEX + 1} + > + {setupPrompt ? ( + + + Run Setup Script? + + {setupPrompt.repoName} requires a setup choice before creating this workspace. + + + + + + + {setupSourceLabel(setupPrompt.source)} + + + {setupPrompt.command} + + + + + void createWorkspace( + setupPrompt.item, + setupPrompt.repoIdOverride, + 'run', + setupPrompt.agentOverride, + setupPrompt.workspaceNameOverride, + setupPrompt.noteOverride, + setupPrompt.baseBranchOverride, + setupPrompt.branchNameOverride, + setupPrompt.sparseCheckoutOverride + ) + } + > + + + {creatingKey === setupPrompt.item.key ? 'Creating...' : 'Run setup and create'} + + + + + void createWorkspace( + setupPrompt.item, + setupPrompt.repoIdOverride, + 'skip', + setupPrompt.agentOverride, + setupPrompt.workspaceNameOverride, + setupPrompt.noteOverride, + setupPrompt.baseBranchOverride, + setupPrompt.branchNameOverride, + setupPrompt.sparseCheckoutOverride + ) + } + > + + Skip setup and create + + + + ) : null} + + + setOrcaYamlTrustPrompt(null)} + zIndex={TASK_SECONDARY_DRAWER_Z_INDEX + 1} + > + {orcaYamlTrustPrompt ? ( + + + + {orcaYamlTrustPrompt.previouslyApproved + ? `${orcaYamlTrustPrompt.repoName}'s setup script changed` + : `Run setup from ${orcaYamlTrustPrompt.repoName}?`} + + + This repository's orca.yaml runs on your machine before the workspace starts. Only + run it if you trust this repository. + + + + + + + {orcaYamlTrustPrompt.previouslyApproved ? 'New setup script' : 'Setup script'} + + + {orcaYamlTrustPrompt.scriptContent} + + + + + void (async () => { + try { + await persistSetupHookTrust( + orcaYamlTrustPrompt.repoId, + orcaYamlTrustPrompt.contentHash, + false + ) + setOrcaYamlTrustPrompt(null) + await createWorkspace( + orcaYamlTrustPrompt.item, + orcaYamlTrustPrompt.repoIdOverride, + 'run', + orcaYamlTrustPrompt.agentOverride, + orcaYamlTrustPrompt.workspaceNameOverride, + orcaYamlTrustPrompt.noteOverride, + orcaYamlTrustPrompt.baseBranchOverride, + orcaYamlTrustPrompt.branchNameOverride, + orcaYamlTrustPrompt.sparseCheckoutOverride, + orcaYamlTrustPrompt.contentHash + ) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to trust setup script.') + } + })() + } + > + + Run hooks + + + + void (async () => { + try { + await persistSetupHookTrust( + orcaYamlTrustPrompt.repoId, + orcaYamlTrustPrompt.contentHash, + true + ) + setOrcaYamlTrustPrompt(null) + await createWorkspace( + orcaYamlTrustPrompt.item, + orcaYamlTrustPrompt.repoIdOverride, + 'run', + orcaYamlTrustPrompt.agentOverride, + orcaYamlTrustPrompt.workspaceNameOverride, + orcaYamlTrustPrompt.noteOverride, + orcaYamlTrustPrompt.baseBranchOverride, + orcaYamlTrustPrompt.branchNameOverride, + orcaYamlTrustPrompt.sparseCheckoutOverride, + orcaYamlTrustPrompt.contentHash + ) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to trust setup script.') + } + })() + } + > + + Always trust and run + + + { + const prompt = orcaYamlTrustPrompt + setOrcaYamlTrustPrompt(null) + void createWorkspace( + prompt.item, + prompt.repoIdOverride, + 'skip', + prompt.agentOverride, + prompt.workspaceNameOverride, + prompt.noteOverride, + prompt.baseBranchOverride, + prompt.branchNameOverride, + prompt.sparseCheckoutOverride + ) + }} + > + + Don't run + + + + ) : null} + + + { + setProjectRepoNotInOrca(null) + }} + > + {projectRepoNotInOrca ? ( + + + Repository not in Orca + + {projectRepoNotInOrca.owner}/{projectRepoNotInOrca.repo} is not added to Orca. Add + this repository from the desktop app, then refresh mobile Tasks. + + + + + {projectRepoNotInOrca.url ? ( + { + if (projectRepoNotInOrca.url) { + void Linking.openURL(projectRepoNotInOrca.url) + } + }} + > + + Open in GitHub + + ) : null} + {projectRepoNotInOrca.url ? : null} + + void copyTextToClipboard( + `project-repo:${projectRepoNotInOrca.owner}/${projectRepoNotInOrca.repo}`, + `${projectRepoNotInOrca.owner}/${projectRepoNotInOrca.repo}` + ) + } + > + + + {copiedLinkKey === + `project-repo:${projectRepoNotInOrca.owner}/${projectRepoNotInOrca.repo}` + ? 'Copied' + : 'Copy repository'} + + + + + ) : null} + + + setProjectRowItem(null)} + > + {projectRowItem ? ( + + + + + + {projectRowItem.content.title} + + + + GitHub Project · {projectRowItem.content.repository ?? activeProjectLabel} + {projectRowItem.content.number ? ` #${projectRowItem.content.number}` : ''} + + + + + + + Type + + {projectRowItem.itemType === 'PULL_REQUEST' + ? 'Pull request' + : projectRowItem.itemType === 'ISSUE' + ? 'Issue' + : projectRowItem.itemType === 'DRAFT_ISSUE' + ? 'Draft issue' + : 'Project item'} + + + + Status + + {projectRowStatusLabel(projectRowItem)} + + + + {SHOW_MOBILE_DETAIL_LABEL_CHIPS && + (projectRowDetail?.provider === 'github' + ? projectRowDetail.labels + : projectRowItem.content.labels.map((label) => label.name) + ).length > 0 ? ( + + {(projectRowDetail?.provider === 'github' + ? projectRowDetail.labels + : projectRowItem.content.labels.map((label) => label.name) + ) + .slice(0, 6) + .map((label) => ( + + {label} + + ))} + + ) : null} + {SHOW_MOBILE_PROJECT_METADATA_EDITORS && projectRowItem.itemType === 'ISSUE' ? ( + + + Issue type + + {projectRowItem.content.issueType?.name ?? 'No type'} + + + {projectIssueTypesLoading ? ( + + + Loading issue types... + + ) : projectIssueTypesError ? ( + {projectIssueTypesError} + ) : projectIssueTypes.length === 0 ? ( + + No issue types configured for this repository. + + ) : ( + + {projectIssueTypes.map((issueType) => { + const selected = projectRowItem.content.issueType?.id === issueType.id + return ( + + void mutateProjectRowIssueType(projectRowItem, issueType) + } + > + + + {issueType.name} + + + ) + })} + {projectRowItem.content.issueType ? ( + void mutateProjectRowIssueType(projectRowItem, null)} + > + Clear type + + ) : null} + + )} + + ) : null} + {SHOW_MOBILE_PROJECT_METADATA_EDITORS && + editableProjectFields(githubProjectTable).length > 0 ? ( + + Project fields + {editableProjectFields(githubProjectTable).map((field) => { + const currentLabel = projectFieldValueLabel(projectRowItem, field) + const draftValue = projectFieldDrafts[field.id] ?? '' + const saveTextField = (): void => { + if (field.dataType === 'NUMBER') { + const number = Number(draftValue) + if (!Number.isFinite(number)) { + setProjectRowDetailError('Enter a valid number.') + return + } + void mutateProjectRowField(projectRowItem, field, { + kind: 'number', + number + }) + return + } + if (field.dataType === 'DATE') { + const date = draftValue.trim() + if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) { + setProjectRowDetailError('Enter a date as YYYY-MM-DD.') + return + } + void mutateProjectRowField(projectRowItem, field, { kind: 'date', date }) + return + } + void mutateProjectRowField(projectRowItem, field, { + kind: 'text', + text: draftValue + }) + } + return ( + + + {field.name} + + {currentLabel} + + + {field.dataType === 'SINGLE_SELECT' && field.kind === 'single-select' ? ( + + {field.options.map((option) => { + const fieldValue = projectRowItem.fieldValuesByFieldId?.[field.id] + const selected = + fieldValue?.kind === 'single-select' && + fieldValue.optionId === option.id + return ( + + void mutateProjectRowField(projectRowItem, field, { + kind: 'single-select', + optionId: option.id + }) + } + > + + {selected ? ( + + ) : null} + {option.name} + + + ) + })} + + ) : field.dataType === 'ITERATION' && field.kind === 'iteration' ? ( + + {field.iterations.length === 0 ? ( + No iterations available. + ) : ( + field.iterations.map((iteration) => { + const fieldValue = projectRowItem.fieldValuesByFieldId?.[field.id] + const selected = + fieldValue?.kind === 'iteration' && + fieldValue.iterationId === iteration.id + return ( + + void mutateProjectRowField(projectRowItem, field, { + kind: 'iteration', + iterationId: iteration.id + }) + } + > + + {iteration.title} + + {iteration.completed ? 'Completed' : 'Current & upcoming'} ·{' '} + {iteration.startDate} · {iteration.duration}d + + + {selected ? ( + + ) : null} + + ) + }) + )} + + ) : ( + <> + + setProjectFieldDrafts((current) => ({ + ...current, + [field.id]: next + })) + } + placeholder={ + field.dataType === 'DATE' + ? 'YYYY-MM-DD' + : field.dataType === 'NUMBER' + ? 'Number' + : 'Text' + } + placeholderTextColor={colors.textMuted} + keyboardType={field.dataType === 'NUMBER' ? 'numeric' : 'default'} + autoCapitalize="none" + /> + + Save field + + + )} + void mutateProjectRowField(projectRowItem, field, null)} + > + Clear field + + + ) + })} + + ) : null} + {SHOW_MOBILE_PROJECT_METADATA_EDITORS && projectRowType(projectRowItem) ? ( + + + Labels + + {(projectRowDetail?.provider === 'github' + ? projectRowDetail.labels + : projectRowItem.content.labels.map((label) => label.name) + ).length || 'None'} + + + {projectLabelsLoading ? ( + + + Loading labels... + + ) : projectLabelsError ? ( + {projectLabelsError} + ) : projectAvailableLabels.length === 0 ? ( + No labels in this repository. + ) : ( + + {[ + ...new Set([ + ...projectAvailableLabels, + ...(projectRowDetail?.provider === 'github' + ? projectRowDetail.labels + : projectRowItem.content.labels.map((label) => label.name)) + ]) + ].map((label) => { + const selected = ( + projectRowDetail?.provider === 'github' + ? projectRowDetail.labels + : projectRowItem.content.labels.map((entry) => entry.name) + ).includes(label) + return ( + + void mutateProjectRowMetadata( + projectRowItem, + selected ? { removeLabels: [label] } : { addLabels: [label] } + ) + } + > + + {selected ? : null} + {label} + + + ) + })} + + )} + + ) : null} + {SHOW_MOBILE_PROJECT_METADATA_EDITORS && projectRowType(projectRowItem) ? ( + + + Assignees + + {(projectRowDetail?.provider === 'github' + ? projectRowDetail.assignees + : projectRowItem.content.assignees.map((assignee) => assignee.login) + ).length || 'None'} + + + {projectAssignableUsersLoading ? ( + + + Loading assignees... + + ) : projectAssignableUsersError ? ( + {projectAssignableUsersError} + ) : projectAssignableUsers.length === 0 ? ( + + No assignable users found for this repository. + + ) : ( + + {[ + ...new Map( + [ + ...projectAssignableUsers, + ...projectRowItem.content.assignees, + ...(projectRowDetail?.provider === 'github' + ? projectRowDetail.assignees.map((login) => ({ + login, + name: null, + avatarUrl: null + })) + : []) + ].map((user) => [user.login, user]) + ).values() + ].map((user) => { + const selected = ( + projectRowDetail?.provider === 'github' + ? projectRowDetail.assignees + : projectRowItem.content.assignees.map((assignee) => assignee.login) + ).includes(user.login) + return ( + + void mutateProjectRowMetadata( + projectRowItem, + selected + ? { removeAssignees: [user.login] } + : { addAssignees: [user.login] } + ) + } + > + + {selected ? : null} + {user.login} + + + ) + })} + + )} + + ) : null} + {projectRowDetailLoading ? ( + + + + ) : projectRowDetailError ? ( + {projectRowDetailError} + ) : ( + <> + {SHOW_MOBILE_PROJECT_METADATA_EDITORS && projectRowType(projectRowItem) ? ( + <> + + Title + + + void mutateProjectRowIssueOrPr(projectRowItem, { + title: projectTitleDraft.trim() + }) + } + > + Save title + + + + Description + + + void mutateProjectRowIssueOrPr(projectRowItem, { + body: projectBodyDraft + }) + } + > + Save description + + + + + ) : ( + <> + + Title + {projectRowItem.content.title} + + + Description + + + + )} + {SHOW_MOBILE_PROJECT_REVIEW_PANELS && + projectRowItem.itemType === 'PULL_REQUEST' && + projectRowDetail?.provider === 'github' && + projectRowHostedRepo ? ( + <> + + + Reviewers + + {getGitHubReviewSummary(projectRowDetail)} + + + {getGitHubReviewerRows(projectRowDetail).length === 0 ? ( + No reviewers requested. + ) : ( + getGitHubReviewerRows(projectRowDetail).map((reviewer) => ( + + + + {reviewer.login.slice(0, 1).toUpperCase()} + + + + + {reviewer.login} + + {reviewer.name ? ( + + {reviewer.name} + + ) : null} + + {reviewer.stateLabel} + + )) + )} + {projectAssignableUsersLoading ? ( + + + Loading reviewers... + + ) : projectAssignableUsersError ? ( + {projectAssignableUsersError} + ) : projectReviewerCandidates.length === 0 ? ( + No reviewer suggestions found. + ) : ( + + {projectReviewerCandidates.map((user) => { + const selected = projectSelectedReviewerLogins.has( + user.login.trim().toLowerCase() + ) + return ( + + void requestProjectGitHubReviewers(projectRowItem, [user.login]) + } + > + + {selected ? ( + + ) : null} + {user.login} + + + ) + })} + + )} + + void requestProjectGitHubReviewers(projectRowItem)} + > + Request review + + + + {projectRowType(projectRowItem) === 'pr' ? ( + + + Checks + + void refreshProjectGitHubChecks(projectRowItem)} + > + Refresh + + void rerunProjectGitHubChecks(projectRowItem, true)} + > + Rerun failed + + void rerunProjectGitHubChecks(projectRowItem, false)} + > + Rerun all + + + + {projectRowDetail.checks.length === 0 ? ( + No checks found. + ) : ( + projectRowDetail.checks.map((check) => ( + { + if (check.url) { + void Linking.openURL(check.url) + } + }} + > + + {check.name} · {check.conclusion ?? check.status} + + {check.url ? ( + + ) : null} + + )) + )} + + ) : null} + + {projectRowDetail.files.length > 0 ? ( + + Changed files + {projectRowDetail.files.map((file) => ( + + + void toggleProjectGitHubFileExpansion(projectRowItem, file) + } + > + + {file.path} + {typeof file.additions === 'number' || + typeof file.deletions === 'number' + ? ` · +${file.additions ?? 0} -${file.deletions ?? 0}` + : ''} + + + {expandedPrFilePath === file.path ? 'Hide' : 'View'} + + + + void toggleProjectGitHubFileViewed(projectRowItem, file) + } + > + + {file.viewerViewedState === 'VIEWED' + ? 'Mark unviewed' + : 'Mark viewed'} + + + {expandedPrFilePath === file.path ? ( + + {prFileLoadingPath === file.path ? ( + + ) : prFileContents[file.path]?.originalIsBinary || + prFileContents[file.path]?.modifiedIsBinary ? ( + Binary file. + ) : prFileContents[file.path] ? ( + + setPrFileCommentDrafts((current) => ({ + ...current, + [draftKey]: next + })) + } + onSubmitComment={(line) => + void addProjectGitHubFileReviewComment( + projectRowItem, + file, + line + ) + } + /> + ) : ( + + File contents unavailable. + + )} + + ) : null} + + ))} + + ) : null} + + ) : null} + {projectRowDetail?.provider === 'github' ? ( + + + Discussion + + {discussionSummary(projectRowDetail.comments.length)} + + + {projectRowDetail.comments.length === 0 ? ( + No comments. + ) : ( + projectDetailCommentGroups.map((group) => { + const groupId = detailCommentGroupId(group) + const root = detailCommentGroupRoot(group) + const count = detailCommentGroupCount(group) + const isCollapsedResolved = + isResolvedDetailCommentGroup(group) && + !expandedResolvedCommentGroups.has(groupId) + if (isCollapsedResolved) { + return ( + + setExpandedResolvedCommentGroups((current) => { + const next = new Set(current) + next.add(groupId) + return next + }) + } + > + + Resolved {group.kind === 'thread' ? 'thread' : 'comment'} by{' '} + {commentAuthor(root)} + + + {count > 1 ? `${count} comments` : 'Show'} + + + ) + } + + const renderProjectComment = ( + comment: DetailComment, + options: { nested?: boolean } = {} + ): ReactNode => { + const commentId = String(comment.id) + const isEditingComment = + SHOW_MOBILE_COMMENT_THREAD_TOOLS && + projectEditingCommentId === commentId + return ( + + + {commentSourceLabel(comment)} + + + {commentAuthor(comment)} + {commentDate(comment.createdAt) + ? ` · ${commentDate(comment.createdAt)}` + : ''} + + {isEditingComment ? ( + <> + + + + void updateProjectRowComment(projectRowItem, comment) + } + > + Save + + { + setProjectEditingCommentId(null) + setProjectEditingCommentDraft('') + }} + > + Cancel + + + + ) : ( + <> + + {renderCommentReactions(comment)} + {SHOW_MOBILE_COMMENT_THREAD_TOOLS ? ( + + {projectRowType(projectRowItem) === 'pr' && + comment.threadId ? ( + + void toggleProjectGitHubReviewThread( + projectRowItem, + comment + ) + } + > + + {comment.isResolved + ? 'Reopen thread' + : 'Resolve thread'} + + + ) : null} + + setItemReplyDrafts((current) => ({ + ...current, + [commentId]: next + })) + } + placeholder="Reply" + placeholderTextColor={colors.textMuted} + multiline + textAlignVertical="top" + /> + + void replyToProjectGitHubComment( + projectRowItem, + comment + ) + } + > + Reply + + { + setProjectEditingCommentId(commentId) + setProjectEditingCommentDraft(comment.body) + }} + > + Edit + + + void deleteProjectRowComment(projectRowItem, comment) + } + > + Delete + + + ) : null} + + )} + + ) + } + + return ( + + {group.kind === 'thread' + ? [ + renderProjectComment(group.root), + ...group.replies.map((reply) => + renderProjectComment(reply, { nested: true }) + ) + ] + : renderProjectComment(group.comment)} + + ) + }) + )} + {renderCommentComposer({ + value: projectCommentDraft, + onChangeText: setProjectCommentDraft, + disabled: projectMutating, + onSubmit: () => void addProjectRowComment(projectRowItem) + })} + + ) : null} + + )} + + + + {canCreateWorkspaceFromProjectRow(projectRowItem) ? ( + void createWorkspaceFromProjectRow(projectRowItem)} + > + + Create Workspace + + ) : ( + + Workspaces can only be created from GitHub issues and pull requests. + + )} + + {projectRowItem.content.url ? ( + <> + {canCreateWorkspaceFromProjectRow(projectRowItem) ? ( + + ) : null} + { + if (projectRowItem.content.url) { + void Linking.openURL(projectRowItem.content.url) + } + }} + > + + Open in GitHub + + + + projectRowItem.content.url + ? void copyTaskLink( + `github-project:${projectRowItem.id}`, + projectRowItem.content.url + ) + : undefined + } + > + + + {copiedLinkKey === `github-project:${projectRowItem.id}` + ? 'Copied' + : 'Copy GitHub link'} + + + + ) : null} + {projectRowType(projectRowItem) && + projectRowItem.content.state !== 'MERGED' && + projectRowItem.itemType !== 'DRAFT_ISSUE' ? ( + <> + + { + const nextState = + projectRowItem.content.state === 'CLOSED' ? 'open' : 'closed' + if (projectRowItem.itemType === 'PULL_REQUEST') { + setPendingHostedStateChange({ + source: 'project', + row: projectRowItem, + nextState + }) + return + } + setPendingHostedStateChange({ + source: 'project', + row: projectRowItem, + nextState + }) + }} + > + {projectRowItem.content.state === 'CLOSED' ? ( + + ) : ( + + )} + + {projectRowItem.content.state === 'CLOSED' ? 'Reopen item' : 'Close item'} + + + + ) : null} + {projectRowItem.itemType === 'PULL_REQUEST' && + projectRowItem.content.state !== 'CLOSED' && + projectRowItem.content.state !== 'MERGED' ? ( + <> + + setMergeMethodProjectRow(projectRowItem)} + > + + Merge pull request + + {!projectRowHostedRepo ? ( + + Merge requires this repository in Orca. + + ) : null} + + ) : null} + + + ) : null} + + + setActionItem(null)}> + {actionItem ? ( + + + + + + {actionItem.title} + + setDetailRefreshSeq((current) => current + 1)} + > + + + + + {taskKindLabel(actionItem)} · {actionItem.subtitle} + + + + + {detailLoading ? ( + + + + ) : detailError ? ( + {detailError} + ) : detailPayload ? ( + <> + + + Type + {taskKindLabel(actionItem)} + + + Status + {actionItem.status} + + {detailPayload.provider === 'linear' && detailPayload.assignee ? ( + + Assignee + {detailPayload.assignee} + + ) : null} + {detailPayload.provider === 'linear' && detailPayload.project ? ( + + Project + {detailPayload.project.name} + + ) : null} + {(detailPayload.provider === 'github' || detailPayload.provider === 'gitlab') && + detailPayload.assignees.length > 0 ? ( + + Assignees + + {detailPayload.assignees.join(', ')} + + + ) : null} + + + {SHOW_MOBILE_DETAIL_LABEL_CHIPS && detailPayload.labels.length > 0 ? ( + + {detailPayload.labels.map((label) => ( + + {label} + + ))} + + ) : null} + + + Description + {SHOW_MOBILE_DETAIL_METADATA_EDITORS && + ((actionItem.provider === 'github' && + detailPayload.provider === 'github' && + (actionItem.source.type === 'issue' || actionItem.source.type === 'pr')) || + (actionItem.provider === 'gitlab' && + detailPayload.provider === 'gitlab' && + (actionItem.source.type === 'issue' || + actionItem.source.type === 'mr'))) ? ( + <> + + { + if ( + actionItem.provider === 'github' && + actionItem.source.type === 'pr' + ) { + void updateGitHubPullRequestMetadata(actionItem, { + body: itemBodyDraft + }) + return + } + if ( + actionItem.provider === 'github' && + actionItem.source.type === 'issue' + ) { + void updateGitHubIssueMetadata(actionItem, { + body: itemBodyDraft + }) + return + } + if ( + actionItem.provider === 'gitlab' && + (actionItem.source.type === 'issue' || + actionItem.source.type === 'mr') + ) { + void updateGitLabIssueMetadata(actionItem, { + body: itemBodyDraft + }) + } + }} + > + Save description + + + + ) : ( + + )} + + + {SHOW_MOBILE_DETAIL_METADATA_EDITORS && + ((actionItem.provider === 'github' && + detailPayload.provider === 'github' && + (actionItem.source.type === 'issue' || actionItem.source.type === 'pr')) || + (actionItem.provider === 'gitlab' && + (actionItem.source.type === 'issue' || actionItem.source.type === 'mr') && + detailPayload.provider === 'gitlab')) ? ( + <> + + Title + + { + if ( + actionItem.provider === 'github' && + actionItem.source.type === 'pr' + ) { + void updateGitHubPullRequestMetadata(actionItem, { + title: itemTitleDraft.trim() + }) + return + } + if (actionItem.provider === 'github') { + void updateGitHubIssueMetadata(actionItem, { + title: itemTitleDraft.trim() + }) + return + } + if (actionItem.provider === 'gitlab') { + void updateGitLabIssueMetadata(actionItem, { + title: itemTitleDraft.trim() + }) + } + }} + > + Save title + + + + + + Labels + + {detailPayload.labels.length || 'None'} + + + {actionItem.provider === 'github' ? ( + itemLabelsLoading ? ( + + + Loading labels... + + ) : itemLabelsError ? ( + {itemLabelsError} + ) : itemAvailableLabels.length === 0 ? ( + No labels in this repository. + ) : ( + + {[...new Set([...itemAvailableLabels, ...detailPayload.labels])].map( + (label) => { + const selected = detailPayload.labels.includes(label) + return ( + + void updateGitHubIssueMetadata( + actionItem, + selected + ? { removeLabels: [label] } + : { addLabels: [label] } + ) + } + > + + {selected ? ( + + ) : null} + {label} + + + ) + } + )} + + ) + ) : ( + <> + + + + void updateGitLabIssueMetadata(actionItem, { + addLabels: splitCommaList(itemAddLabelsDraft), + removeLabels: splitCommaList(itemRemoveLabelsDraft) + }) + } + > + Update labels + + + )} + + + + + Assignees + + {detailPayload.assignees.length || 'None'} + + + {actionItem.provider === 'github' ? ( + itemAssignableUsersLoading ? ( + + + Loading assignees... + + ) : itemAssignableUsersError ? ( + {itemAssignableUsersError} + ) : itemAssignableUsers.length === 0 ? ( + + No assignable users found for this repository. + + ) : ( + + {[ + ...new Map( + [ + ...itemAssignableUsers, + ...detailPayload.assignees.map((login) => ({ + login, + name: null, + avatarUrl: null + })) + ].map((user) => [user.login, user]) + ).values() + ].map((user) => { + const selected = detailPayload.assignees.includes(user.login) + return ( + + void updateGitHubIssueMetadata( + actionItem, + selected + ? { removeAssignees: [user.login] } + : { addAssignees: [user.login] } + ) + } + > + + {selected ? ( + + ) : null} + {user.login} + + + ) + })} + + ) + ) : actionItem.source.type === 'issue' ? ( + <> + + + + void updateGitLabIssueMetadata(actionItem, { + addAssignees: splitCommaList(itemAddAssigneesDraft), + removeAssignees: splitCommaList(itemRemoveAssigneesDraft) + }) + } + > + Update assignees + + + ) : null} + + + ) : null} + + {SHOW_MOBILE_DETAIL_REVIEW_PANELS && + actionItem.provider === 'github' && + actionItem.source.type === 'pr' ? ( + + + Reviewers + {detailPayload.provider === 'github' ? ( + + {getGitHubReviewSummary(detailPayload)} + + ) : null} + + {detailPayload.provider === 'github' ? ( + getGitHubReviewerRows(detailPayload).length === 0 ? ( + No reviewers requested. + ) : ( + getGitHubReviewerRows(detailPayload).map((reviewer) => ( + + + + {reviewer.login.slice(0, 1).toUpperCase()} + + + + + {reviewer.login} + + {reviewer.name ? ( + + {reviewer.name} + + ) : null} + + {reviewer.stateLabel} + + )) + ) + ) : null} + {itemAssignableUsersLoading ? ( + + + Loading reviewers... + + ) : itemAssignableUsersError ? ( + {itemAssignableUsersError} + ) : itemReviewerCandidates.length === 0 ? ( + No reviewer suggestions found. + ) : ( + + {itemReviewerCandidates.map((user) => { + const selected = itemSelectedReviewerLogins.has( + user.login.trim().toLowerCase() + ) + return ( + + void requestGitHubReviewers(actionItem, [user.login]) + } + > + + {selected ? : null} + {user.login} + + + ) + })} + + )} + + void requestGitHubReviewers(actionItem)} + > + Request review + + + ) : null} + + {SHOW_MOBILE_DETAIL_REVIEW_PANELS && + detailPayload.provider === 'github' && + actionItem.provider === 'github' && + actionItem.source.type === 'pr' ? ( + + + Checks + + void refreshGitHubChecks(actionItem)} + > + Refresh + + void rerunGitHubChecks(actionItem, true)} + > + Rerun failed + + void rerunGitHubChecks(actionItem, false)} + > + Rerun all + + + + {detailPayload.checks.length === 0 ? ( + No checks found. + ) : ( + detailPayload.checks.map((check) => ( + { + if (check.url) { + void Linking.openURL(check.url) + } + }} + > + + {check.name} · {check.conclusion ?? check.status} + + {check.url ? ( + + ) : null} + + )) + )} + + ) : null} + + {SHOW_MOBILE_DETAIL_REVIEW_PANELS && + detailPayload.provider === 'github' && + detailPayload.files.length > 0 ? ( + + Changed files + {detailPayload.files.map((file) => + actionItem.provider === 'github' && actionItem.source.type === 'pr' ? ( + + void toggleGitHubFileExpansion(actionItem, file)} + > + + {file.path} + {typeof file.additions === 'number' || + typeof file.deletions === 'number' + ? ` · +${file.additions ?? 0} -${file.deletions ?? 0}` + : ''} + + + {expandedPrFilePath === file.path ? 'Hide' : 'View'} + + + void toggleGitHubFileViewed(actionItem, file)} + > + + {file.viewerViewedState === 'VIEWED' + ? 'Mark unviewed' + : 'Mark viewed'} + + + {expandedPrFilePath === file.path ? ( + + {prFileLoadingPath === file.path ? ( + + ) : prFileContents[file.path]?.originalIsBinary || + prFileContents[file.path]?.modifiedIsBinary ? ( + Binary file. + ) : prFileContents[file.path] ? ( + + setPrFileCommentDrafts((current) => ({ + ...current, + [draftKey]: next + })) + } + onSubmitComment={(line) => + void addGitHubFileReviewComment(actionItem, file, line) + } + /> + ) : ( + File contents unavailable. + )} + + ) : null} + + ) : ( + + {file.path} + {typeof file.additions === 'number' || + typeof file.deletions === 'number' + ? ` · +${file.additions ?? 0} -${file.deletions ?? 0}` + : ''} + + ) + )} + + ) : null} + + {SHOW_MOBILE_DETAIL_REVIEW_PANELS && + detailPayload.provider === 'gitlab' && + actionItem.provider === 'gitlab' && + actionItem.source.type === 'mr' ? ( + + + Pipeline + + {detailPayload.pipelineJobs.length + ? `${detailPayload.pipelineJobs.length} jobs` + : 'None'} + + + {detailPayload.pipelineJobs.length === 0 ? ( + No pipeline runs for this MR. + ) : ( + detailPayload.pipelineJobs.map((job) => { + const duration = formatDurationSeconds(job.duration) + return ( + { + if (job.webUrl) { + void Linking.openURL(job.webUrl) + } + }} + > + + + {job.name} + + + {job.status} + + + + {[job.stage, duration].filter(Boolean).join(' · ')} + + + ) + }) + )} + + ) : null} + + {SHOW_MOBILE_LINEAR_DETAIL_TOOLS && + detailPayload.provider === 'linear' && + actionItem.provider === 'linear' ? ( + + + Sub-issues + + {detailPayload.children.length || 'None'} + + + {detailPayload.children.length === 0 ? ( + No sub-issues. + ) : ( + detailPayload.children.map((child) => ( + + void openLinearSubIssue(child, actionItem.source.workspaceId) + } + > + + {child.identifier} · {child.title} + + Open + + )) + )} + + void createLinearSubIssue(actionItem)} + > + Add sub-issue + + + ) : null} + + + + Discussion + + {discussionSummary(detailPayload.comments.length)} + + + {detailPayload.comments.length === 0 ? ( + No comments. + ) : ( + detailCommentGroups.map(renderDetailCommentGroup) + )} + {(detailPayload.provider === 'github' && actionItem.provider === 'github') || + (detailPayload.provider === 'gitlab' && actionItem.provider === 'gitlab') + ? renderCommentComposer({ + value: itemCommentDraft, + onChangeText: setItemCommentDraft, + disabled: mutatingStatus, + onSubmit: () => void addHostedItemComment(actionItem) + }) + : null} + {detailPayload.provider === 'linear' && actionItem.provider === 'linear' + ? renderCommentComposer({ + value: linearCommentDraft, + onChangeText: setLinearCommentDraft, + disabled: mutatingStatus, + onSubmit: () => void addLinearComment(actionItem) + }) + : null} + + + ) : null} + + + + { + if (actionItem.provider === 'linear' && workspaceRepos.length > 1) { + setWorkspaceRepoPickerItem(actionItem) + return + } + openWorkspaceCreate(actionItem) + }} + > + + + {creatingKey === actionItem.key ? 'Creating...' : 'Create Workspace'} + + + + + void Linking.openURL(actionItem.source.url)} + > + + {taskExternalOpenLabel(actionItem)} + + + {actionItem.provider === 'linear' ? ( + <> + + + void copyTextToClipboard( + `linear-url:${actionItem.key}`, + actionItem.source.url + ) + } + > + + + {copiedLinkKey === `linear-url:${actionItem.key}` + ? 'Copied' + : 'Copy Linear link'} + + + + ) : null} + + {actionItem.provider === 'github' ? ( + <> + + + void copyTaskLink(`task:${actionItem.key}`, actionItem.source.url) + } + > + + + {copiedLinkKey === `task:${actionItem.key}` ? 'Copied' : 'Copy GitHub link'} + + + + ) : null} + + {actionItem.provider === 'github' && actionItem.source.state !== 'merged' ? ( + <> + + { + const githubItem = actionItem as Extract + setPendingHostedStateChange({ + source: 'task', + item: githubItem, + nextState: githubItem.source.state === 'closed' ? 'open' : 'closed' + }) + }} + > + {actionItem.source.state === 'closed' ? ( + + ) : ( + + )} + {taskStatusActionLabel(actionItem)} + + + ) : null} + + {actionItem.provider === 'github' && + actionItem.source.type === 'pr' && + actionItem.source.state === 'open' ? ( + <> + + + setMergeMethodTaskItem( + actionItem as Extract + ) + } + > + + Merge pull request + + {isGitHubPrMergeBlocked(actionItem) ? ( + GitHub reports merge conflicts. + ) : null} + + ) : null} + + {actionItem.provider === 'gitlab' && + actionItem.source.state !== 'merged' && + actionItem.source.state !== 'locked' ? ( + <> + + { + const gitlabItem = actionItem as Extract + setPendingHostedStateChange({ + source: 'task', + item: gitlabItem, + nextState: gitlabItem.source.state === 'closed' ? 'opened' : 'closed' + }) + }} + > + {actionItem.source.state === 'closed' ? ( + + ) : ( + + )} + {taskStatusActionLabel(actionItem)} + + + ) : null} + + {actionItem.provider === 'gitlab' && + actionItem.source.type === 'mr' && + actionItem.source.state === 'opened' ? ( + <> + + + setMergeMethodTaskItem( + actionItem as Extract + ) + } + > + + Merge merge request + + + ) : null} + + {actionItem.provider === 'linear' ? ( + <> + + { + setLinearStatusPickerItem( + actionItem as Extract + ) + }} + > + + Change status + + + ) : null} + + + ) : null} + + + ({ + label: getHostedReviewMergeMethodLabel(method), + icon: GitBranch, + onPress: () => { + setPendingProjectGitHubMerge({ row: mergeMethodProjectRow, method }) + } + })) + : [] + } + onClose={() => setMergeMethodProjectRow(null)} + /> + ({ + label: + mergeMethodTaskItem.provider === 'gitlab' && method === 'merge' + ? 'Merge' + : getHostedReviewMergeMethodLabel(method), + icon: GitBranch, + onPress: () => { + const item = mergeMethodTaskItem + setPendingHostedMerge({ item, method }) + } + })) + : [] + } + onClose={() => setMergeMethodTaskItem(null)} + /> + { + if (!taskUiReady || !pendingHostedMerge) { + return + } + void mergeHostedReview(pendingHostedMerge.item, pendingHostedMerge.method) + }} + onCancel={() => setPendingHostedMerge(null)} + /> + { + if (!taskUiReady || !pendingProjectGitHubMerge) { + return + } + void mergeProjectGitHubPullRequest( + pendingProjectGitHubMerge.row, + pendingProjectGitHubMerge.method + ) + }} + onCancel={() => setPendingProjectGitHubMerge(null)} + /> + { + if (!taskUiReady || !pendingHostedStateChange) { + return + } + if (pendingHostedStateChange.source === 'task') { + if (pendingHostedStateChange.item.provider === 'gitlab') { + void toggleGitLabStatus(pendingHostedStateChange.item) + return + } + void toggleGitHubStatus(pendingHostedStateChange.item) + return + } + void mutateProjectRowIssueOrPr(pendingHostedStateChange.row, { + state: pendingHostedStateChange.nextState + }) + }} + onCancel={() => setPendingHostedStateChange(null)} + /> + + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.bgBase + }, + topChrome: { + backgroundColor: colors.bgPanel, + borderBottomWidth: 1, + borderBottomColor: colors.borderSubtle + }, + statusBar: { + minHeight: 38, + paddingTop: spacing.xs, + paddingHorizontal: spacing.lg, + flexDirection: 'row', + alignItems: 'center' + }, + backButton: { + width: 32, + height: 32, + alignItems: 'center', + justifyContent: 'center', + marginRight: spacing.xs + }, + titleWrap: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + minWidth: 0 + }, + title: { + fontSize: 16, + fontWeight: '700', + color: colors.textPrimary + }, + iconButton: { + width: 32, + height: 32, + alignItems: 'center', + justifyContent: 'center' + }, + toolbar: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingHorizontal: spacing.md, + paddingVertical: spacing.xs + 2 + }, + toolbarScroll: { + borderTopWidth: 1, + borderTopColor: colors.borderSubtle + }, + segmentButton: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + borderWidth: 1, + borderColor: colors.borderSubtle, + borderRadius: radii.button, + paddingHorizontal: spacing.sm + 2, + paddingVertical: spacing.xs + }, + segmentIconButton: { + width: 32, + height: 30, + alignItems: 'center', + justifyContent: 'center', + borderWidth: 1, + borderColor: colors.borderSubtle, + borderRadius: radii.button + }, + segmentCountPill: { + minWidth: 32, + height: 30, + alignItems: 'center', + justifyContent: 'center', + borderWidth: 1, + borderColor: colors.borderSubtle, + borderRadius: radii.button, + paddingHorizontal: spacing.sm + }, + segmentRepoDot: { + width: 8, + height: 8, + borderRadius: 4 + }, + segmentButtonText: { + fontSize: 12, + fontWeight: '600', + color: colors.textPrimary + }, + segmentSecondaryText: { + fontSize: 12, + color: colors.textSecondary + }, + searchBar: { + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: colors.borderSubtle + }, + errorBanner: { + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm, + backgroundColor: colors.bgPanel, + borderBottomWidth: 1, + borderBottomColor: colors.borderSubtle + }, + errorText: { + color: colors.statusRed, + fontSize: 13 + }, + sourceErrorBanner: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm, + backgroundColor: colors.bgPanel, + borderBottomWidth: 1, + borderBottomColor: colors.borderSubtle + }, + sourceErrorCopy: { + flex: 1, + minWidth: 0 + }, + sourceErrorText: { + color: colors.statusAmber, + fontSize: 13, + fontWeight: '600' + }, + sourceErrorSlug: { + fontFamily: typography.monoFamily, + color: colors.textPrimary + }, + sourceErrorMessage: { + marginTop: 2, + color: colors.textSecondary, + fontSize: 12 + }, + sourceErrorRetry: { + borderWidth: 1, + borderColor: colors.borderSubtle, + borderRadius: radii.button, + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs + }, + sourceErrorRetryText: { + color: colors.textPrimary, + fontSize: 12, + fontWeight: '600' + }, + sourceNoticeBanner: { + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm, + backgroundColor: colors.bgPanel, + borderBottomWidth: 1, + borderBottomColor: colors.borderSubtle + }, + sourceNoticeText: { + color: colors.statusAmber, + fontSize: 13 + }, + centered: { + flex: 1, + alignItems: 'center', + justifyContent: 'center' + }, + emptyText: { + color: colors.textSecondary, + fontSize: typography.bodySize + }, + centeredHint: { + color: colors.textMuted, + fontSize: 12, + lineHeight: 17, + marginTop: spacing.sm, + maxWidth: 280, + textAlign: 'center' + }, + centerActionButton: { + marginTop: spacing.md, + minWidth: 160 + }, + list: { + paddingTop: spacing.xs + }, + repoSectionHeader: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + paddingHorizontal: spacing.lg, + paddingTop: spacing.md, + paddingBottom: spacing.xs, + backgroundColor: colors.bgBase + }, + repoSectionDot: { + width: 8, + height: 8, + borderRadius: 4 + }, + repoSectionTitle: { + flex: 1, + minWidth: 0, + fontSize: 11, + fontWeight: '600', + color: colors.textMuted, + textTransform: 'uppercase', + letterSpacing: 0.5 + }, + separator: { + height: 1, + backgroundColor: colors.borderSubtle, + marginLeft: spacing.lg + 26, + marginRight: spacing.lg + }, + taskRow: { + flexDirection: 'row', + alignItems: 'flex-start', + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm + 2 + }, + taskRowPressed: { + backgroundColor: colors.bgRaised + }, + taskIcon: { + width: 20, + paddingTop: 3, + marginRight: spacing.sm, + alignItems: 'center' + }, + taskMain: { + flex: 1, + minWidth: 0, + marginRight: spacing.sm + }, + taskTitleRow: { + flexDirection: 'row', + gap: spacing.sm, + alignItems: 'flex-start' + }, + taskTitle: { + flex: 1, + fontSize: 14, + fontWeight: '600', + color: colors.textPrimary, + lineHeight: 18 + }, + updatedAt: { + fontSize: 11, + color: colors.textMuted, + paddingTop: 2 + }, + metaRow: { + flexDirection: 'row', + alignItems: 'center', + marginTop: 4, + gap: spacing.xs + }, + repoDot: { + width: 7, + height: 7, + borderRadius: 4 + }, + pickerRepoDot: { + width: 9, + height: 9, + borderRadius: 4.5 + }, + subtitle: { + flex: 1, + fontSize: 11, + color: colors.textSecondary + }, + branchMetaRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + marginTop: 3, + minWidth: 0 + }, + branchMetaText: { + flexShrink: 1, + minWidth: 0, + maxWidth: 180, + fontSize: 11, + color: colors.textPrimary + }, + branchMetaBase: { + flexShrink: 1, + minWidth: 0, + fontSize: 10, + color: colors.textMuted + }, + prSignalRow: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: spacing.xs, + marginTop: spacing.xs + 1 + }, + prSignalChip: { + alignSelf: 'flex-start', + borderWidth: 1, + borderColor: colors.borderSubtle, + borderRadius: radii.button, + backgroundColor: colors.bgPanel, + paddingHorizontal: spacing.xs + 2, + paddingVertical: 2 + }, + prSignalSuccess: { + borderColor: colors.statusGreen + }, + prSignalWarning: { + borderColor: colors.statusAmber + }, + prSignalDanger: { + borderColor: colors.statusRed + }, + prSignalText: { + fontSize: 10, + color: colors.textSecondary, + fontWeight: '600' + }, + statusPill: { + maxWidth: 112, + backgroundColor: colors.bgRaised, + borderRadius: 999, + paddingHorizontal: spacing.sm, + paddingVertical: 3, + borderWidth: 1, + borderColor: colors.borderSubtle + }, + statusPillSelf: { + alignSelf: 'flex-start', + backgroundColor: colors.bgRaised, + borderRadius: 999, + paddingHorizontal: spacing.sm, + paddingVertical: 3, + borderWidth: 1, + borderColor: colors.borderSubtle, + marginTop: spacing.sm + }, + linearStatePill: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs + }, + linearStateDot: { + width: 7, + height: 7, + borderRadius: 4 + }, + linearListTrailing: { + alignItems: 'flex-end', + gap: spacing.xs + }, + taskRowTrailing: { + alignItems: 'flex-end', + gap: spacing.xs + }, + statusText: { + fontSize: 11, + color: colors.textSecondary + }, + statusTextFlex: { + flex: 1, + minWidth: 0 + }, + paginationFooter: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: spacing.sm, + paddingHorizontal: spacing.lg, + paddingTop: spacing.md, + paddingBottom: spacing.sm + }, + paginationButton: { + width: 44, + minHeight: 38, + alignItems: 'center', + justifyContent: 'center', + borderWidth: 1, + borderColor: colors.borderSubtle, + borderRadius: radii.button, + backgroundColor: colors.bgRaised, + paddingVertical: spacing.sm + }, + paginationButtonDisabled: { + opacity: 0.45 + }, + paginationLabel: { + color: colors.textSecondary, + fontSize: 12, + textAlign: 'center' + }, + paginationLabelButton: { + flex: 1, + alignItems: 'center', + borderRadius: radii.button, + paddingHorizontal: spacing.xs, + paddingVertical: spacing.sm + }, + boardContainer: { + gap: spacing.md, + padding: spacing.md + }, + boardColumn: { + width: 280, + maxHeight: '100%', + borderWidth: 1, + borderColor: colors.borderSubtle, + borderRadius: radii.card, + backgroundColor: colors.bgPanel, + overflow: 'hidden' + }, + boardHeader: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: colors.borderSubtle + }, + boardTitle: { + flex: 1, + color: colors.textPrimary, + fontSize: 13, + fontWeight: '600' + }, + boardCount: { + color: colors.textMuted, + fontSize: 11 + }, + boardCard: { + margin: spacing.sm, + marginBottom: 0, + borderRadius: radii.card, + borderWidth: 1, + borderColor: colors.borderSubtle, + backgroundColor: colors.bgBase, + padding: spacing.md + }, + repoPickerGroup: { + backgroundColor: colors.bgPanel, + borderRadius: 12, + overflow: 'hidden' + }, + pagePickerList: { + maxHeight: 420, + backgroundColor: colors.bgPanel, + borderRadius: 12 + }, + projectPickerControls: { + gap: spacing.sm, + marginBottom: spacing.md + }, + projectWarningBanner: { + flexDirection: 'row', + alignItems: 'flex-start', + gap: spacing.sm, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + marginBottom: spacing.md, + borderWidth: 1, + borderColor: colors.statusAmber, + borderRadius: radii.card, + backgroundColor: colors.bgPanel + }, + projectWarningTextWrap: { + flex: 1, + minWidth: 0 + }, + projectWarningTitle: { + color: colors.textPrimary, + fontSize: 12, + fontWeight: '600' + }, + projectWarningText: { + color: colors.textSecondary, + fontSize: 11, + marginTop: 2 + }, + projectDataNotice: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm, + backgroundColor: colors.bgPanel, + borderBottomWidth: 1, + borderBottomColor: colors.borderSubtle + }, + projectDataNoticeText: { + flex: 1, + color: colors.statusAmber, + fontSize: 13 + }, + projectGroupHeader: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm, + backgroundColor: colors.bgPanel + }, + projectGroupChevronCollapsed: { + transform: [{ rotate: '-90deg' }] + }, + projectGroupTitle: { + flex: 1, + minWidth: 0, + color: colors.textPrimary, + fontSize: 12, + fontWeight: '600' + }, + projectGroupMeta: { + color: colors.textMuted, + fontSize: 11 + }, + projectFieldPillRow: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: spacing.xs, + marginTop: spacing.xs + }, + projectFieldPill: { + maxWidth: '100%', + borderWidth: 1, + borderColor: colors.borderSubtle, + borderRadius: 999, + backgroundColor: colors.bgPanel, + paddingHorizontal: spacing.sm, + paddingVertical: 2 + }, + projectFieldPillText: { + color: colors.textSecondary, + fontSize: 11 + }, + projectFieldPillEmptyText: { + color: colors.textMuted + }, + projectPasteRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm + }, + projectPasteInput: { + flex: 1 + }, + projectPasteButton: { + minHeight: 40, + alignItems: 'center', + justifyContent: 'center' + }, + pickerRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingHorizontal: spacing.md + 2, + paddingVertical: spacing.md, + borderBottomWidth: 1, + borderBottomColor: colors.borderSubtle + }, + pickerRowSelected: { + backgroundColor: colors.bgRaised + }, + pickerRowContent: { + flex: 1, + minWidth: 0 + }, + pickerRowLabel: { + fontSize: typography.bodySize, + color: colors.textPrimary + }, + pickerRowSubtitle: { + fontSize: 11, + color: colors.textMuted, + marginTop: 1 + }, + pickerRowWithAction: { + flexDirection: 'row', + alignItems: 'center' + }, + pickerRowMain: { + flex: 1, + minWidth: 0, + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingHorizontal: spacing.md + 2, + paddingVertical: spacing.md + }, + pickerCheck: { + width: 18, + alignItems: 'center' + }, + pickerContent: { + flex: 1, + minWidth: 0 + }, + pickerLabel: { + fontSize: typography.bodySize, + color: colors.textPrimary + }, + monoText: { + fontFamily: Platform.select({ ios: 'Menlo', android: 'monospace', default: 'monospace' }) + }, + pickerSubtitle: { + fontSize: 11, + color: colors.textMuted, + marginTop: 1 + }, + iconActionButton: { + width: 44, + height: 44, + alignItems: 'center', + justifyContent: 'center' + }, + groupSeparator: { + height: StyleSheet.hairlineWidth, + backgroundColor: colors.borderSubtle, + marginHorizontal: spacing.md + }, + repoPickerRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingHorizontal: spacing.md + 2, + paddingVertical: spacing.md + }, + repoPickerTextWrap: { + flex: 1, + minWidth: 0 + }, + repoPickerTitle: { + fontSize: typography.bodySize, + color: colors.textPrimary + }, + repoPickerSubtitle: { + fontSize: 11, + color: colors.textMuted, + marginTop: 1 + }, + sheetHeader: { + paddingHorizontal: spacing.xs, + marginBottom: spacing.md + }, + sheetTitleRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm + }, + sheetTitle: { + flex: 1, + minWidth: 0, + fontSize: 15, + fontWeight: '700', + color: colors.textPrimary, + lineHeight: 20 + }, + sheetSubtitle: { + fontSize: 12, + color: colors.textMuted, + marginTop: 2 + }, + actionGroup: { + backgroundColor: colors.bgPanel, + borderRadius: 12, + overflow: 'hidden' + }, + detailGroup: { + backgroundColor: colors.bgPanel, + borderRadius: 12, + padding: spacing.md, + marginBottom: spacing.md, + gap: spacing.md + }, + detailLoading: { + paddingVertical: spacing.lg, + alignItems: 'center' + }, + detailLoadingInline: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm + }, + detailError: { + color: colors.statusRed, + fontSize: 13 + }, + detailMetaGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: spacing.sm + }, + detailMetaItem: { + minWidth: 96, + flexGrow: 1 + }, + detailMetaLabel: { + fontSize: 11, + color: colors.textMuted, + marginBottom: 2 + }, + detailMetaValue: { + fontSize: 13, + color: colors.textPrimary, + fontWeight: '600' + }, + chipRow: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: spacing.xs + }, + detailChip: { + borderWidth: 1, + borderColor: colors.borderSubtle, + backgroundColor: colors.bgRaised, + borderRadius: 999, + paddingHorizontal: spacing.sm, + paddingVertical: 2 + }, + detailChipSelected: { + borderColor: colors.accentBlue, + backgroundColor: colors.bgRaised + }, + detailChipText: { + fontSize: 11, + color: colors.textSecondary + }, + issueTypeChipContent: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs + }, + issueTypeDot: { + width: 7, + height: 7, + borderRadius: 999 + }, + detailSection: { + gap: spacing.xs + }, + detailSectionHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: spacing.sm + }, + detailSectionTitle: { + fontSize: 11, + fontWeight: '600', + color: colors.textMuted, + textTransform: 'uppercase', + letterSpacing: 0.5 + }, + detailSectionMeta: { + flexShrink: 0, + fontSize: 11, + color: colors.textMuted + }, + fieldButton: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + backgroundColor: colors.bgRaised, + borderRadius: radii.input, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + borderWidth: 1, + borderColor: colors.borderSubtle + }, + fieldButtonDisabled: { + opacity: 0.55 + }, + fieldButtonPlaceholder: { + color: colors.textMuted + }, + fieldButtonText: { + flex: 1, + fontSize: typography.bodySize, + color: colors.textPrimary + }, + workspaceCreateForm: { + gap: 0 + }, + workspaceCreateField: { + marginBottom: spacing.md + }, + workspaceCreateLabel: { + fontSize: 13, + fontWeight: '500', + color: colors.textSecondary, + marginBottom: spacing.xs + }, + workspaceCreateLabelHint: { + fontWeight: '400', + color: colors.textMuted + }, + workspaceAdvancedToggle: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + paddingVertical: spacing.sm, + marginBottom: spacing.xs + }, + workspaceAdvancedText: { + fontSize: typography.bodySize, + fontWeight: '500', + color: colors.textSecondary + }, + workspaceCreateActions: { + flexDirection: 'row', + justifyContent: 'flex-end', + marginTop: spacing.sm + }, + workspaceCreateButton: { + minWidth: 160, + paddingHorizontal: spacing.lg + }, + sshConnectCard: { + backgroundColor: colors.bgRaised, + borderRadius: radii.input, + borderWidth: 1, + borderColor: colors.borderSubtle, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + gap: spacing.xs + }, + sshStatusRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm + }, + sshStatusDot: { + width: 8, + height: 8, + borderRadius: 999 + }, + sshStatusDotConnected: { + backgroundColor: colors.statusGreen + }, + sshStatusDotProgress: { + backgroundColor: colors.statusAmber + }, + sshStatusDotDisconnected: { + backgroundColor: colors.statusRed + }, + sshStatusCopy: { + flex: 1, + minWidth: 0 + }, + sshStatusTitle: { + fontSize: typography.bodySize, + color: colors.textPrimary, + fontWeight: '600' + }, + reviewerRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + borderWidth: 1, + borderColor: colors.borderSubtle, + borderRadius: radii.card, + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs + }, + reviewerAvatar: { + width: 24, + height: 24, + borderRadius: 12, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.bgRaised + }, + reviewerAvatarText: { + fontSize: 11, + fontWeight: '700', + color: colors.textSecondary + }, + reviewerInfo: { + flex: 1, + minWidth: 0 + }, + reviewerName: { + fontSize: 12, + fontWeight: '600', + color: colors.textPrimary + }, + reviewerMeta: { + fontSize: 11, + color: colors.textMuted + }, + reviewerState: { + flexShrink: 0, + fontSize: 11, + color: colors.textSecondary + }, + projectFieldCard: { + borderWidth: 1, + borderColor: colors.borderSubtle, + borderRadius: radii.card, + padding: spacing.sm, + gap: spacing.xs + }, + projectFieldName: { + flex: 1, + minWidth: 0, + fontSize: 12, + fontWeight: '600', + color: colors.textPrimary + }, + projectFieldValue: { + maxWidth: 140, + fontSize: 12, + color: colors.textMuted + }, + projectIterationList: { + gap: spacing.xs + }, + projectIterationCopy: { + flex: 1, + minWidth: 0, + gap: 2 + }, + detailLine: { + fontSize: 12, + lineHeight: 17, + color: colors.textSecondary + }, + fileActionRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: spacing.sm, + paddingVertical: 2 + }, + fileCard: { + borderWidth: 1, + borderColor: colors.borderSubtle, + borderRadius: radii.card, + padding: spacing.sm, + gap: spacing.xs + }, + pipelineStatusChip: { + flexShrink: 0, + borderWidth: 1, + borderColor: colors.borderSubtle, + borderRadius: 999, + backgroundColor: colors.bgRaised, + paddingHorizontal: spacing.sm, + paddingVertical: 2 + }, + pipelineStatusSuccess: { + borderColor: colors.statusGreen + }, + pipelineStatusWarning: { + borderColor: colors.statusAmber + }, + pipelineStatusDanger: { + borderColor: colors.statusRed + }, + pipelineStatusActive: { + borderColor: colors.accentBlue + }, + pipelineStatusText: { + fontSize: 10, + color: colors.textSecondary, + fontWeight: '600', + textTransform: 'uppercase' + }, + filePreview: { + gap: spacing.xs, + marginTop: spacing.xs + }, + fileDiff: { + borderWidth: 1, + borderColor: colors.borderSubtle, + borderRadius: radii.row, + overflow: 'hidden' + }, + diffLineBlock: { + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: colors.borderSubtle, + borderLeftWidth: 2, + borderLeftColor: colors.borderSubtle, + paddingHorizontal: spacing.xs, + paddingVertical: spacing.xs, + gap: spacing.xs + }, + diffLineAdded: { + borderLeftColor: colors.statusGreen + }, + diffLineRemoved: { + borderLeftColor: colors.statusRed + }, + diffCodeRow: { + flexDirection: 'row', + gap: spacing.xs, + alignItems: 'flex-start' + }, + diffLineNumbers: { + width: 76, + flexShrink: 0, + fontFamily: typography.monoFamily, + fontSize: 10, + lineHeight: 16, + color: colors.textMuted + }, + codeLine: { + flex: 1, + fontFamily: typography.monoFamily, + fontSize: 11, + lineHeight: 16, + color: colors.textSecondary + }, + diffCodeAdded: { + color: colors.statusGreen + }, + diffCodeRemoved: { + color: colors.statusRed + }, + detailMuted: { + fontSize: 12, + color: colors.textSecondary + }, + commentBlock: { + borderTopWidth: 1, + borderTopColor: colors.borderSubtle, + paddingTop: spacing.sm + }, + commentThreadGroup: { + gap: spacing.xs + }, + commentReplyBlock: { + marginLeft: spacing.md, + paddingLeft: spacing.sm, + borderLeftWidth: 1, + borderLeftColor: colors.borderSubtle + }, + commentResolvedBlock: { + opacity: 0.6 + }, + resolvedCommentSummary: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + borderWidth: 1, + borderColor: colors.borderSubtle, + borderRadius: radii.card, + backgroundColor: colors.bgPanel, + paddingHorizontal: spacing.sm, + paddingVertical: spacing.sm + }, + resolvedCommentTitle: { + flex: 1, + minWidth: 0, + fontSize: 12, + color: colors.textSecondary + }, + commentSource: { + fontSize: 11, + lineHeight: 15, + color: colors.textSecondary, + marginBottom: 2 + }, + commentMeta: { + fontSize: 11, + color: colors.textMuted, + marginBottom: 2 + }, + commentControls: { + gap: spacing.xs, + marginTop: spacing.sm + }, + reactionRow: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: spacing.xs, + marginTop: spacing.xs + }, + reactionChip: { + borderWidth: 1, + borderColor: colors.borderSubtle, + backgroundColor: colors.bgRaised, + borderRadius: 999, + paddingHorizontal: spacing.sm, + paddingVertical: 2 + }, + reactionText: { + fontSize: 11, + color: colors.textSecondary + }, + inlineActionRow: { + flexDirection: 'row', + alignItems: 'center', + flexWrap: 'wrap', + gap: spacing.xs + }, + actionRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + paddingHorizontal: spacing.md + 2, + paddingVertical: spacing.md + }, + actionText: { + flex: 1, + fontSize: typography.bodySize, + color: colors.textPrimary + }, + actionSeparator: { + height: StyleSheet.hairlineWidth, + backgroundColor: colors.borderSubtle, + marginHorizontal: spacing.md + }, + setupPromptBox: { + backgroundColor: colors.bgPanel, + borderRadius: 12, + borderWidth: 1, + borderColor: colors.borderSubtle, + padding: spacing.md, + marginBottom: spacing.md, + gap: spacing.xs + }, + setupPromptCommand: { + fontFamily: typography.monoFamily, + fontSize: 12, + lineHeight: 17, + color: colors.textPrimary + }, + linearStatesBlock: { + paddingTop: spacing.sm + }, + linearStatesTitle: { + fontSize: 11, + fontWeight: '600', + color: colors.textMuted, + textTransform: 'uppercase', + letterSpacing: 0.5, + paddingHorizontal: spacing.md + 2, + paddingBottom: spacing.xs + }, + emptyInlineText: { + color: colors.textSecondary, + fontSize: 13, + paddingHorizontal: spacing.md + 2, + paddingBottom: spacing.md + }, + createForm: { + gap: spacing.sm + }, + fieldLabel: { + fontSize: 12, + fontWeight: '600', + color: colors.textSecondary + }, + inlineTextLink: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + alignSelf: 'flex-start', + paddingVertical: spacing.xs + }, + inlineTextLinkText: { + color: colors.textSecondary, + fontSize: 12, + textDecorationLine: 'underline' + }, + securityHintRow: { + flexDirection: 'row', + alignItems: 'flex-start', + gap: spacing.xs + }, + securityHintText: { + flex: 1, + color: colors.textMuted, + fontSize: 11, + lineHeight: 16 + }, + targetButton: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + borderWidth: 1, + borderColor: colors.borderSubtle, + backgroundColor: colors.bgPanel, + borderRadius: radii.input, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm + 2 + }, + targetButtonText: { + flex: 1, + color: colors.textPrimary, + fontSize: typography.bodySize + }, + issueSourceBox: { + borderWidth: 1, + borderColor: colors.borderSubtle, + borderRadius: radii.card, + backgroundColor: colors.bgPanel, + padding: spacing.sm, + gap: spacing.xs + }, + issueSourceHint: { + fontSize: 12, + color: colors.textSecondary + }, + issueSourceSegment: { + flexDirection: 'row', + borderWidth: 1, + borderColor: colors.borderSubtle, + borderRadius: radii.input, + backgroundColor: colors.bgBase, + padding: 2, + gap: 2 + }, + issueSourceSegmentButton: { + flex: 1, + borderRadius: radii.input - 2, + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs + }, + issueSourceSegmentButtonActive: { + backgroundColor: colors.bgRaised + }, + issueSourceSegmentText: { + fontSize: 12, + fontWeight: '700', + color: colors.textMuted + }, + issueSourceSegmentTextActive: { + color: colors.textPrimary + }, + issueSourceSlug: { + marginTop: 1, + fontSize: 10, + color: colors.textMuted + }, + drawerLoadingRow: { + paddingVertical: spacing.lg, + alignItems: 'center' + }, + input: { + borderWidth: 1, + borderColor: colors.borderSubtle, + backgroundColor: colors.bgPanel, + borderRadius: radii.input, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + color: colors.textPrimary, + fontSize: typography.bodySize + }, + bodyInput: { + minHeight: 88 + }, + monoInput: { + fontFamily: Platform.select({ ios: 'Menlo', android: 'monospace', default: 'monospace' }) + }, + commentInput: { + minHeight: 72, + marginTop: spacing.sm + }, + commentComposer: { + position: 'relative', + marginTop: spacing.sm + }, + commentComposerInput: { + minHeight: 40, + maxHeight: 120, + marginTop: 0, + paddingRight: 44 + }, + commentComposerSend: { + position: 'absolute', + right: spacing.xs, + bottom: spacing.xs, + width: 32, + height: 32, + borderRadius: radii.button, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.bgRaised + }, + commentComposerSendPressed: { + opacity: 0.75 + }, + commentComposerSendDisabled: { + opacity: 0.5 + }, + replyInput: { + minHeight: 48, + marginTop: spacing.xs + }, + stackedInput: { + marginTop: spacing.sm + }, + inlineSaveButton: { + alignSelf: 'flex-start', + marginTop: spacing.sm, + borderRadius: radii.button, + borderWidth: 1, + borderColor: colors.borderSubtle, + paddingHorizontal: spacing.md, + paddingVertical: spacing.xs + }, + inlineSaveButtonCompact: { + alignSelf: 'flex-start', + borderRadius: radii.button, + borderWidth: 1, + borderColor: colors.borderSubtle, + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs + }, + inlineSaveText: { + color: colors.textPrimary, + fontSize: 12, + fontWeight: '600' + }, + inlineButtonRow: { + flexDirection: 'row', + alignItems: 'center', + flexWrap: 'wrap', + gap: spacing.xs, + marginTop: spacing.xs + }, + drawerActionRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm + }, + secondaryActionButton: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + borderRadius: radii.button, + borderWidth: 1, + borderColor: colors.borderSubtle, + paddingVertical: spacing.sm + }, + secondaryActionText: { + color: colors.textPrimary, + fontSize: typography.bodySize, + fontWeight: '600' + }, + primaryActionButton: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: spacing.xs, + borderRadius: radii.button, + backgroundColor: colors.textPrimary, + paddingVertical: spacing.sm + }, + primaryActionText: { + color: colors.bgBase, + fontSize: typography.bodySize, + fontWeight: '700' + }, + inlineDeleteText: { + color: colors.statusRed, + fontSize: 12, + fontWeight: '600' + }, + createButton: { + marginTop: spacing.sm, + backgroundColor: colors.textPrimary, + borderRadius: radii.button, + paddingVertical: spacing.sm + 2, + alignItems: 'center' + }, + createButtonDisabled: { + opacity: 0.5 + }, + createButtonText: { + color: colors.bgBase, + fontSize: typography.bodySize, + fontWeight: '700' + } +}) + +function getPrSignalToneStyle(tone: 'neutral' | 'success' | 'warning' | 'danger') { + if (tone === 'success') { + return styles.prSignalSuccess + } + if (tone === 'warning') { + return styles.prSignalWarning + } + if (tone === 'danger') { + return styles.prSignalDanger + } + return null +} + +function getGitLabPipelineStatusStyle(status: string) { + switch (status) { + case 'success': + return styles.pipelineStatusSuccess + case 'failed': + return styles.pipelineStatusDanger + case 'manual': + return styles.pipelineStatusWarning + case 'running': + case 'pending': + case 'created': + case 'preparing': + case 'waiting_for_resource': + case 'scheduled': + return styles.pipelineStatusActive + case 'canceled': + case 'skipped': + default: + return null + } } diff --git a/mobile/src/agent-history/mobile-ai-vault-resume-metadata.ts b/mobile/src/agent-history/mobile-ai-vault-resume-metadata.ts index 8dd619f7cea..4e7ffe8b2b8 100644 --- a/mobile/src/agent-history/mobile-ai-vault-resume-metadata.ts +++ b/mobile/src/agent-history/mobile-ai-vault-resume-metadata.ts @@ -41,7 +41,12 @@ export async function loadMobileResumeMetadata( .sendRequest('projectGroup.list', undefined, { timeoutMs: RESUME_RPC_TIMEOUT_MS }) .catch(() => null), client - .sendRequest('settings.get', undefined, { timeoutMs: RESUME_RPC_TIMEOUT_MS }) + .sendRequest( + 'settings.get', + // Resume reads only `settings`; the catalog comes from settings.agentCatalog.get. + { includeAgentCatalog: false }, + { timeoutMs: RESUME_RPC_TIMEOUT_MS } + ) .catch(() => null), client .sendRequest('worktree.ps', { limit: 10000 }, { timeoutMs: RESUME_RPC_TIMEOUT_MS }) diff --git a/mobile/src/components/use-new-workspace-create-submit.ts b/mobile/src/components/use-new-workspace-create-submit.ts index e0f942ad36e..945c528c87c 100644 --- a/mobile/src/components/use-new-workspace-create-submit.ts +++ b/mobile/src/components/use-new-workspace-create-submit.ts @@ -93,7 +93,9 @@ export function useNewWorkspaceCreateSubmit(args: { } let latestRuntimeSettings = args.runtimeSettings try { - const settingsResponse = await client.sendRequest('settings.get') + const settingsResponse = await client.sendRequest('settings.get', { + includeAgentCatalog: false + }) if (settingsResponse.ok) { const result = (settingsResponse as RpcSuccess).result as { settings: NewWorktreeRuntimeSettings diff --git a/mobile/src/components/use-new-workspace-runtime-context.ts b/mobile/src/components/use-new-workspace-runtime-context.ts index 05df7b53fe3..e257fc46d05 100644 --- a/mobile/src/components/use-new-workspace-runtime-context.ts +++ b/mobile/src/components/use-new-workspace-runtime-context.ts @@ -39,7 +39,7 @@ export function useNewWorkspaceRuntimeContext( client.sendRequest('linear.status') ]) const [settingsRes, uiRes] = await Promise.allSettled([ - client.sendRequest('settings.get'), + client.sendRequest('settings.get', { includeAgentCatalog: false }), client.sendRequest('ui.get') ]) if (stale) { diff --git a/mobile/src/home/mobile-home-host-requests.ts b/mobile/src/home/mobile-home-host-requests.ts index e0e19a036d3..af1108da2c1 100644 --- a/mobile/src/home/mobile-home-host-requests.ts +++ b/mobile/src/home/mobile-home-host-requests.ts @@ -73,7 +73,9 @@ export function fetchMobileHomeTaskProviders( disposed: () => boolean ): void { Promise.all([ - sendSingleFlightRequest(client, hostId, 'settings.get'), + sendSingleFlightRequest(client, hostId, 'settings.get', { + includeAgentCatalog: false + }), sendSingleFlightRequest(client, hostId, 'preflight.check'), sendSingleFlightRequest(client, hostId, 'linear.status') ]) diff --git a/mobile/src/session/mobile-new-tab-agent-loader.test.ts b/mobile/src/session/mobile-new-tab-agent-loader.test.ts index b9f5d9e174e..9f51e7fd878 100644 --- a/mobile/src/session/mobile-new-tab-agent-loader.test.ts +++ b/mobile/src/session/mobile-new-tab-agent-loader.test.ts @@ -40,6 +40,10 @@ describe('mobile new-tab agent loading', () => { 'preflight.detectAgents', 'settings.get' ]) + // The 512 KiB agent catalog must not ride this hot read. + expect( + client.sendRequest.mock.calls.find(([method]) => method === 'settings.get')?.[1] + ).toEqual({ includeAgentCatalog: false }) }) it('detects agents through the worktree repo connection for SSH sessions', async () => { diff --git a/mobile/src/session/mobile-new-tab-agent-loader.ts b/mobile/src/session/mobile-new-tab-agent-loader.ts index ea87e74ba0e..12e443b3b13 100644 --- a/mobile/src/session/mobile-new-tab-agent-loader.ts +++ b/mobile/src/session/mobile-new-tab-agent-loader.ts @@ -23,7 +23,8 @@ export async function loadMobileNewTabAgentOptions(args: { ? client.sendRequest('preflight.detectAgents') : loadWorkspaceDetectedAgents(client, worktreeId) const [settingsResponse, detectedResponse] = await Promise.all([ - client.sendRequest('settings.get'), + // Only `settings` is read here; opt out of the piggybacked agent catalog (old hosts ignore the param). + client.sendRequest('settings.get', { includeAgentCatalog: false }), detectedAgentsRequest ]) if (!settingsResponse.ok) { diff --git a/mobile/src/session/use-pr-bot-author-overrides.ts b/mobile/src/session/use-pr-bot-author-overrides.ts index 94e39794616..01222478e72 100644 --- a/mobile/src/session/use-pr-bot-author-overrides.ts +++ b/mobile/src/session/use-pr-bot-author-overrides.ts @@ -33,7 +33,8 @@ export function usePRBotAuthorOverrides( } let stale = false void client - .sendRequest('settings.get') + // Only prBotAuthorOverrides is read; opt out of the piggybacked agent catalog. + .sendRequest('settings.get', { includeAgentCatalog: false }) .then((response) => { if (stale || !response.ok) { return diff --git a/mobile/src/transport/agent-catalog-sync.test.ts b/mobile/src/transport/agent-catalog-sync.test.ts index a844a000e31..8340496c7dd 100644 --- a/mobile/src/transport/agent-catalog-sync.test.ts +++ b/mobile/src/transport/agent-catalog-sync.test.ts @@ -6,45 +6,119 @@ import type { RpcClient } from './rpc-client' const tick = () => new Promise((resolve) => setTimeout(resolve, 0)) function fakeClient(response: unknown): { client: RpcClient; methods: string[] } { + return routingClient(() => response) +} + +/** Lets a test answer the dedicated read and the legacy piggyback differently. */ +function routingClient(route: (method: string) => unknown): { + client: RpcClient + methods: string[] +} { const methods: string[] = [] const client = { sendRequest: async (method: string) => { methods.push(method) - return response + return route(method) }, getState: () => 'connected' } as unknown as RpcClient return { client, methods } } +function catalogResult(revision: number): unknown { + return { + ok: true, + id: '1', + result: { + agentCatalog: { + version: 1, + revision, + defaultAgent: 'auto', + disabledAgents: [], + customAgents: [], + deletedCustomAgents: [] + } + }, + _meta: { runtimeId: 'runtime-1' } + } +} + +function failure(code: string, message = 'nope'): unknown { + return { ok: false, id: '1', error: { code, message }, _meta: { runtimeId: 'runtime-1' } } +} + describe('agentCatalogSync', () => { - it('hydrates the catalog snapshot from settings.get', async () => { - const { client, methods } = fakeClient({ - ok: true, - id: '1', - result: { - agentCatalog: { - version: 1, - revision: 7, - defaultAgent: 'auto', - disabledAgents: [], - customAgents: [], - deletedCustomAgents: [] - } - }, - _meta: { runtimeId: 'runtime-1' } - }) + it('hydrates the catalog snapshot from the dedicated settings.agentCatalog.get read', async () => { + const { client, methods } = fakeClient(catalogResult(7)) const conn = agentCatalogSync.openConnection('cat-a', client) conn.hydrate() await tick() - expect(methods).toEqual(['settings.get']) + expect(methods).toEqual(['settings.agentCatalog.get']) expect(agentCatalogSync.getSnapshot('cat-a')).toMatchObject({ version: 1, revision: 7 }) conn.dispose() agentCatalogSync.clear('cat-a') }) + it.each(['forbidden', 'method_not_found'])( + 'falls back to the settings.get piggyback when an old host answers %s', + async (code) => { + const { client, methods } = routingClient((method) => + method === 'settings.agentCatalog.get' ? failure(code) : catalogResult(3) + ) + const conn = agentCatalogSync.openConnection('cat-old', client) + conn.hydrate() + await tick() + + expect(methods).toEqual(['settings.agentCatalog.get', 'settings.get']) + expect(agentCatalogSync.getSnapshot('cat-old')).toMatchObject({ revision: 3 }) + + conn.dispose() + agentCatalogSync.clear('cat-old') + } + ) + + it('keeps the cache and skips the fallback when the dedicated read fails transiently', async () => { + let dedicated: unknown = catalogResult(5) + const { client, methods } = routingClient((method) => + method === 'settings.agentCatalog.get' ? dedicated : catalogResult(9) + ) + const conn = agentCatalogSync.openConnection('cat-transient', client) + conn.hydrate() + await tick() + expect(agentCatalogSync.getSnapshot('cat-transient')).toMatchObject({ revision: 5 }) + + dedicated = failure('internal_error', 'boom') + methods.length = 0 + conn.announce(6) + await tick() + + expect(methods).toEqual(['settings.agentCatalog.get']) + expect(agentCatalogSync.getSnapshot('cat-transient')).toMatchObject({ revision: 5 }) + + conn.dispose() + agentCatalogSync.clear('cat-transient') + }) + + it('drops the cache when a downgraded host answers the dedicated read without a catalog', async () => { + let result: unknown = catalogResult(5) + const { client } = routingClient(() => result) + const conn = agentCatalogSync.openConnection('cat-absent', client) + conn.hydrate() + await tick() + expect(agentCatalogSync.getSnapshot('cat-absent')).toMatchObject({ revision: 5 }) + + result = { ok: true, id: '1', result: {}, _meta: { runtimeId: 'runtime-1' } } + conn.hydrate() + await tick() + + expect(agentCatalogSync.getSnapshot('cat-absent')).toBeNull() + + conn.dispose() + agentCatalogSync.clear('cat-absent') + }) + it('stores the projection error variant so repair copy can render', async () => { const { client } = fakeClient({ ok: true, diff --git a/mobile/src/transport/agent-catalog-sync.ts b/mobile/src/transport/agent-catalog-sync.ts index 03f142d0625..a96da5fa9f3 100644 --- a/mobile/src/transport/agent-catalog-sync.ts +++ b/mobile/src/transport/agent-catalog-sync.ts @@ -1,9 +1,11 @@ // Why: mobile's per-host cache of the runtime agent catalog. The host owns the // catalog; mobile only mirrors the env-free revisioned snapshot it publishes on -// `settings.get`, refetching on `agentCatalogChanged` events. Never merges with -// local state and never carries a custom env key or value (env-free by DTO -// construction — no fields are added here). +// `settings.agentCatalog.get` (falling back to the legacy `settings.get` +// piggyback against old hosts), refetching on `agentCatalogChanged` events. +// Never merges with local state and never carries a custom env key or value +// (env-free by DTO construction — no fields are added here). import type { RpcClient } from './rpc-client' +import type { RpcResponse, RpcSuccess } from './types' import type { AgentCatalogProjectionError, AgentCatalogSnapshot @@ -29,28 +31,51 @@ function parseCatalogValue(raw: unknown): AgentCatalogValue | null { return raw as AgentCatalogValue } -async function fetchCatalog(client: RpcClient): Promise> { - let response +async function sendCatalogRead(client: RpcClient, method: string): Promise { try { - response = await client.sendRequest('settings.get') + return (await client.sendRequest(method)) ?? null } catch { - return { kind: 'unavailable' } - } - if (!response || !response.ok) { - return { kind: 'unavailable' } + return null } +} + +// Mobile's method allowlist is checked before dispatch, so a host that predates +// the dedicated read rejects it with 'forbidden' rather than 'method_not_found'. +function isMethodUnavailable(response: RpcResponse | null): boolean { + const error = response && !response.ok ? response.error : undefined + return ( + error?.code === 'forbidden' || + error?.code === 'method_not_found' || + error?.message.includes('not available to mobile clients') === true + ) +} + +function readCatalog(response: RpcSuccess): SnapshotFetchOutcome { const result = response.result as { agentCatalog?: unknown } | null const runtimeId = (response as { _meta?: { runtimeId?: string } })._meta?.runtimeId ?? '' const value = parseCatalogValue(result?.agentCatalog) if (!value) { - // settings.get answered without a catalog: this host publishes none (it was - // downgraded, or predates the catalog). Keeping the cached one would leave the - // picker offering custom agents the host can no longer launch. + // The host answered without a catalog: it publishes none (it was downgraded, + // or predates the catalog). Keeping the cached one would leave the picker + // offering custom agents the host can no longer launch. return { kind: 'absent', runtimeId } } return { kind: 'value', runtimeId, value } } +async function fetchCatalog(client: RpcClient): Promise> { + const dedicated = await sendCatalogRead(client, 'settings.agentCatalog.get') + if (dedicated?.ok) { + return readCatalog(dedicated) + } + if (!isMethodUnavailable(dedicated)) { + return { kind: 'unavailable' } + } + // Old host: the catalog ships only piggybacked on settings.get. + const legacy = await sendCatalogRead(client, 'settings.get') + return legacy?.ok ? readCatalog(legacy) : { kind: 'unavailable' } +} + const sync = createRevisionedSnapshotSync() export const agentCatalogSync = { diff --git a/mobile/src/transport/revisioned-snapshot-sync.test.ts b/mobile/src/transport/revisioned-snapshot-sync.test.ts index 773159f2edb..c7829e34a08 100644 --- a/mobile/src/transport/revisioned-snapshot-sync.test.ts +++ b/mobile/src/transport/revisioned-snapshot-sync.test.ts @@ -151,6 +151,39 @@ describe('createRevisionedSnapshotSync', () => { expect(sync.getSnapshot('b')?.revision).toBe(20) }) + // Every host disconnect clears its cache, so retaining the key would grow this + // map by one entry per host ever connected to, for the process lifetime. + it('drops the host key on clear, not just its cached value', async () => { + const sync = createRevisionedSnapshotSync() + const { fetch, resolvers } = deferredFetch() + + for (let host = 0; host < 5; host++) { + const conn = sync.openConnection(`host-${host}`, fetch) + conn.hydrate() + resolvers[host]!({ kind: 'value', runtimeId: 'r', value: { revision: 1 } }) + await tick() + conn.dispose() + sync.clear(`host-${host}`) + } + + expect(sync.trackedHostCountForTests()).toBe(0) + }) + + it('still fences an in-flight fetch whose host was cleared', async () => { + const sync = createRevisionedSnapshotSync() + const { fetch, resolvers } = deferredFetch() + const conn = sync.openConnection('host', fetch) + + conn.hydrate() + sync.clear('host') + // Resolves against the dropped state; must not resurrect the host entry. + resolvers[0]!({ kind: 'value', runtimeId: 'r', value: { revision: 7 } }) + await tick() + + expect(sync.getSnapshot('host')).toBeNull() + expect(sync.trackedHostCountForTests()).toBe(0) + }) + it('stores a projection error as the cached value so the UI can render repair copy', async () => { const sync = createRevisionedSnapshotSync() const { fetch, resolvers } = deferredFetch() diff --git a/mobile/src/transport/revisioned-snapshot-sync.ts b/mobile/src/transport/revisioned-snapshot-sync.ts index 2dde1c4d578..28886fb594d 100644 --- a/mobile/src/transport/revisioned-snapshot-sync.ts +++ b/mobile/src/transport/revisioned-snapshot-sync.ts @@ -41,6 +41,8 @@ export type RevisionedSnapshotSync = { clear: (hostId: string) => void getSnapshot: (hostId: string) => V | null subscribe: (hostId: string, listener: () => void) => () => void + // Hosts still holding a state entry; proves clear() drops the key, not just the value. + trackedHostCountForTests: () => number } type HostState = { @@ -207,6 +209,9 @@ export function createRevisionedSnapshotSync< if (!state) { return } + // Fence in-flight fetches and stale handles on the object they captured, + // THEN drop the key — keeping every host ever connected to would grow this + // map for the process lifetime. state.epoch += 1 state.value = null state.runtimeId = null @@ -214,11 +219,15 @@ export function createRevisionedSnapshotSync< state.fetching = false state.hydratePending = false state.highestAnnounced = null + states.delete(hostId) notify(hostId) }, getSnapshot(hostId) { return states.get(hostId)?.value ?? null }, + trackedHostCountForTests() { + return states.size + }, subscribe(hostId, listener) { let set = listeners.get(hostId) if (!set) { diff --git a/mobile/src/transport/settings-get-catalog-optout.test.ts b/mobile/src/transport/settings-get-catalog-optout.test.ts new file mode 100644 index 00000000000..7f1295cf0ed --- /dev/null +++ b/mobile/src/transport/settings-get-catalog-optout.test.ts @@ -0,0 +1,87 @@ +// Why: the agent catalog projection is budgeted at 512 KiB and only rides +// settings.get as the legacy piggyback for hosts without settings.agentCatalog.get. +// A caller that reads settings without opting out drags that payload onto +// every unrelated read, so the opt-out is asserted rather than left to review. +import { readdirSync, readFileSync, statSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +const MOBILE_ROOT = join(__dirname, '..', '..') +// The only caller allowed to piggyback: it falls back to settings.get against +// hosts that predate settings.agentCatalog.get. +const CATALOG_PIGGYBACK_OWNER = join(MOBILE_ROOT, 'src', 'transport', 'agent-catalog-sync.ts') + +function listSourceFiles(root: string): string[] { + const files: string[] = [] + for (const entry of readdirSync(root)) { + const path = join(root, entry) + if (statSync(path).isDirectory()) { + files.push(...listSourceFiles(path)) + continue + } + if (/\.[cm]?[jt]sx?$/.test(entry) && !/\.test\.[cm]?[jt]sx?$/.test(entry)) { + files.push(path) + } + } + return files +} + +function scannedSources(): { path: string; source: string }[] { + return [join(MOBILE_ROOT, 'app'), join(MOBILE_ROOT, 'src')] + .flatMap((root) => listSourceFiles(root)) + .filter((path) => path !== CATALOG_PIGGYBACK_OWNER) + .map((path) => ({ + path, + // Drop line comments so an explanatory comment between the method name and + // its params can't push the opt-out outside the match window. + source: readFileSync(path, 'utf8').replace(/^\s*\/\/.*$/gm, '') + })) +} + +function describeSite(path: string, match: string): string { + return `${path.slice(MOBILE_ROOT.length + 1)}: ${match.split('\n')[0]}` +} + +function callSitesNotOptingOut(): string[] { + const sites: string[] = [] + for (const { path, source } of scannedSources()) { + for (const match of source.matchAll(/['"]settings\.get['"](.{0,80})/gs)) { + if (!match[1]!.includes('includeAgentCatalog')) { + sites.push(describeSite(path, match[0]!)) + } + } + } + return sites +} + +// sendSingleFlightRequest coalesces by (client, host, requestKind) and lets the +// latest queued params win, so two settings.get callers disagreeing about +// includeAgentCatalog could hand one of them the other's response. Keeping every +// single-flight settings.get caller opted out makes that mismatch unreachable. +function singleFlightSettingsSitesNotOptingOut(): string[] { + const sites: string[] = [] + for (const { path, source } of scannedSources()) { + for (const match of source.matchAll( + /sendSingleFlightRequest\([^)]*?['"]settings\.get['"](.{0,80})/gs + )) { + if (!match[1]!.includes('includeAgentCatalog: false')) { + sites.push(describeSite(path, match[0]!)) + } + } + } + return sites +} + +describe('mobile settings.get catalog opt-out', () => { + it('opts every mobile settings.get caller out of the piggybacked agent catalog', () => { + expect(callSitesNotOptingOut()).toEqual([]) + }) + + it('keeps every single-flight settings.get caller on the same opted-out params', () => { + expect(singleFlightSettingsSitesNotOptingOut()).toEqual([]) + }) + + it('keeps the catalog piggyback off the params-blind single-flight path', () => { + expect(readFileSync(CATALOG_PIGGYBACK_OWNER, 'utf8')).not.toContain('sendSingleFlightRequest') + }) +}) diff --git a/src/cli/handlers/orchestration-timeout-flags.test.ts b/src/cli/handlers/orchestration-timeout-flags.test.ts new file mode 100644 index 00000000000..c1435712c54 --- /dev/null +++ b/src/cli/handlers/orchestration-timeout-flags.test.ts @@ -0,0 +1,295 @@ +// Timeout-flag validation and task-list brief output for the orchestration CLI +// handlers; payload/handle mapping lives in orchestration.test.ts. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + callMock, + getTerminalHandleMock, + handlerInvoker, + restoreTerminalIdentityEnv +} from './orchestration-handler-test-harness' + +// Why: isolate the handler's flag-to-param mapping; printResult only writes output. +vi.mock('../format', () => ({ printResult: vi.fn() })) +vi.mock('../selectors', () => ({ getTerminalHandle: getTerminalHandleMock })) + +import { ORCHESTRATION_HANDLERS } from './orchestration' +import { printResult } from '../format' + +afterEach(restoreTerminalIdentityEnv) + +describe('orchestration timeout flag validation', () => { + const invalidTimeoutValues: [string, string | boolean][] = [ + ['missing', true], + ['empty', ''], + ['non-numeric', 'not-a-number'], + ['zero', '0'], + ['negative', '-1'] + ] + + beforeEach(() => { + callMock.mockReset() + delete process.env.ORCA_TERMINAL_HANDLE + delete process.env.ORCA_PANE_KEY + }) + + const invokeCheck = handlerInvoker(ORCHESTRATION_HANDLERS['orchestration check']) + const invokeAsk = handlerInvoker(ORCHESTRATION_HANDLERS['orchestration ask']) + + it.each(invalidTimeoutValues)('rejects invalid check --timeout-ms: %s', async (_label, value) => { + const flags = new Map([ + ['wait', true], + ['timeout-ms', value] + ]) + + await expect(invokeCheck(flags)).rejects.toThrow(/--timeout-ms/) + expect(callMock).not.toHaveBeenCalled() + }) + + it('passes a parsed check timeout and peek mode into the RPC payload', async () => { + process.env.ORCA_TERMINAL_HANDLE = 'term_worker' + callMock.mockResolvedValue({ result: { messages: [], count: 0 } }) + + await invokeCheck( + new Map([ + ['wait', true], + ['peek', true], + ['timeout-ms', '250'] + ]) + ) + + // Why: --peek rides with unread:false so pre-peek runtimes fall back to + // the non-consuming all mode instead of the destructive mark-read default. + expect(callMock).toHaveBeenCalledWith('orchestration.check', { + terminal: 'term_worker', + terminalPaneKey: undefined, + unread: false, + peek: true, + all: undefined, + types: undefined, + format: undefined, + compatibilityCliCommand: expect.stringMatching(/^orca(?:-ide)?$/), + run: undefined, + ack: undefined, + wait: true, + timeoutMs: 250 + }) + }) + + it('filters already-read rows from a peek response for pre-peek runtimes', async () => { + process.env.ORCA_TERMINAL_HANDLE = 'term_worker' + callMock.mockResolvedValue({ + result: { + messages: [ + { id: 'msg_old', from_handle: 'a', subject: 'seen', read: 1 }, + { id: 'msg_new', from_handle: 'a', subject: 'fresh', read: 0 } + ], + count: 2, + formatted: 'banners built from all rows' + } + }) + vi.mocked(printResult).mockClear() + + await invokeCheck(new Map([['peek', true]])) + + const response = vi.mocked(printResult).mock.calls[0]?.[0] as { + result: { messages: { id: string }[]; count: number; formatted?: string } + } + expect(response.result.messages.map((m) => m.id)).toEqual(['msg_new']) + expect(response.result.count).toBe(1) + // Why: the pre-peek runtime built `formatted` from all rows, including + // the read one the filter just removed. + expect(response.result.formatted).toBeUndefined() + }) + + it('rejects combined read modes before calling the runtime', async () => { + process.env.ORCA_TERMINAL_HANDLE = 'term_worker' + callMock.mockClear() + + await expect( + invokeCheck( + new Map([ + ['unread', true], + ['peek', true] + ]) + ) + ).rejects.toMatchObject({ + code: 'invalid_argument', + message: expect.stringContaining('read mode') + }) + expect(callMock).not.toHaveBeenCalled() + }) + + it('warns when a pre-peek runtime returned a full 100-row page', async () => { + process.env.ORCA_TERMINAL_HANDLE = 'term_worker' + const rows = Array.from({ length: 100 }, (_, i) => ({ + id: `msg_${i}`, + from_handle: 'a', + subject: `s${i}`, + read: i === 0 ? 0 : 1 + })) + callMock.mockResolvedValue({ result: { messages: rows, count: 100 } }) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + await invokeCheck(new Map([['peek', true]])) + + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('newest 100 messages')) + errorSpy.mockRestore() + }) + + it('fails --peek --wait against a runtime that returned only read rows', async () => { + process.env.ORCA_TERMINAL_HANDLE = 'term_worker' + callMock.mockResolvedValue({ + result: { + messages: [{ id: 'msg_old', from_handle: 'a', subject: 'seen', read: 1 }], + count: 1 + } + }) + + await expect( + invokeCheck( + new Map([ + ['peek', true], + ['wait', true] + ]) + ) + ).rejects.toMatchObject({ code: 'peek_wait_unsupported' }) + }) + + it.each(invalidTimeoutValues)('rejects invalid ask --timeout-ms: %s', async (_label, value) => { + const flags = new Map([ + ['to', 'term_coord'], + ['question', 'Proceed?'], + ['timeout-ms', value] + ]) + + await expect(invokeAsk(flags)).rejects.toThrow(/--timeout-ms/) + expect(callMock).not.toHaveBeenCalled() + }) + + it('uses the parsed ask timeout for both runtime wait and client timeout', async () => { + process.env.ORCA_TERMINAL_HANDLE = 'term_worker' + callMock.mockResolvedValue({ + result: { + answer: 'yes', + messageId: 'msg_1', + threadId: 'thread_1', + timedOut: false + } + }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await invokeAsk( + new Map([ + ['to', 'term_coord'], + ['question', 'Proceed?'], + ['timeout-ms', '123'] + ]) + ) + + expect(callMock).toHaveBeenCalledWith( + 'orchestration.ask', + { + to: 'term_coord', + run: undefined, + question: 'Proceed?', + resume: undefined, + options: undefined, + timeoutMs: 123, + from: 'term_worker', + compatibilityCliCommand: expect.stringMatching(/^orca(?:-ide)?$/), + compatibilityWindowsCommand: undefined + }, + { timeoutMs: 5_123, orchestrationCapability: undefined } + ) + }) + + it('passes an ask resume without creating a new question payload', async () => { + process.env.ORCA_TERMINAL_HANDLE = 'term_worker' + callMock.mockResolvedValue({ + result: { + answer: 'yes', + messageId: 'msg_question', + threadId: 'msg_question', + timedOut: false + } + }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await invokeAsk(new Map([['resume', 'msg_question']])) + + expect(callMock).toHaveBeenCalledWith( + 'orchestration.ask', + { + to: undefined, + run: undefined, + question: undefined, + resume: 'msg_question', + options: undefined, + timeoutMs: undefined, + from: 'term_worker', + compatibilityCliCommand: expect.stringMatching(/^orca(?:-ide)?$/), + compatibilityWindowsCommand: undefined + }, + { timeoutMs: 605_000, orchestrationCapability: undefined } + ) + }) + + it('rejects ambiguous ask create/resume input before RPC', async () => { + process.env.ORCA_TERMINAL_HANDLE = 'term_worker' + await expect( + invokeAsk( + new Map([ + ['question', 'new'], + ['resume', 'msg_old'] + ]) + ) + ).rejects.toMatchObject({ code: 'invalid_argument' }) + expect(callMock).not.toHaveBeenCalled() + }) +}) + +describe('orchestration task-list brief output', () => { + it('requests server-side brief and falls back client-side for older runtimes', async () => { + callMock.mockReset().mockResolvedValue({ + result: { + // No spec_truncated field — the pre-brief-runtime signature. + tasks: [{ id: 'task_1', spec: `First line\n${'detail '.repeat(40)}`, status: 'ready' }], + count: 1 + } + }) + vi.mocked(printResult).mockClear() + + await handlerInvoker(ORCHESTRATION_HANDLERS['orchestration task-list'])( + new Map([['brief', true]]) + ) + + expect(callMock).toHaveBeenCalledWith( + 'orchestration.taskList', + expect.objectContaining({ brief: true }) + ) + const response = vi.mocked(printResult).mock.calls[0]?.[0] as { + result: { tasks: { spec: string; spec_truncated: boolean }[] } + } + expect(response.result.tasks[0].spec).toHaveLength(160) + expect(response.result.tasks[0].spec_truncated).toBe(true) + }) + + it('passes server-abbreviated rows through untouched', async () => { + const serverTasks = [ + { id: 'task_1', spec: 'already brief…', status: 'ready', spec_truncated: true } + ] + callMock.mockReset().mockResolvedValue({ result: { tasks: serverTasks, count: 1 } }) + vi.mocked(printResult).mockClear() + + await handlerInvoker(ORCHESTRATION_HANDLERS['orchestration task-list'])( + new Map([['brief', true]]) + ) + + const response = vi.mocked(printResult).mock.calls[0]?.[0] as { + result: { tasks: { spec: string; spec_truncated: boolean }[] } + } + // Why: re-abbreviating a server-truncated spec would flip spec_truncated + // back to false (the truncated text fits the cap). + expect(response.result.tasks).toBe(serverTasks) + }) +}) diff --git a/src/cli/handlers/orchestration.test.ts b/src/cli/handlers/orchestration.test.ts index 4e38a132e3f..40e2d373942 100644 --- a/src/cli/handlers/orchestration.test.ts +++ b/src/cli/handlers/orchestration.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { + type CliFlagMap, callMock, getTerminalHandleMock, handlerInvoker, @@ -7,8 +8,7 @@ import { restoreTerminalIdentityEnv, staleHandleError, stubStaleHandleRemint, - stubStaleHandleRemintFailure, - type CliFlagMap + stubStaleHandleRemintFailure } from './orchestration-handler-test-harness' // Why: isolate the handler's flag-to-param mapping; printResult only writes output. @@ -17,7 +17,6 @@ vi.mock('../selectors', () => ({ getTerminalHandle: getTerminalHandleMock })) import { ORCHESTRATION_HANDLERS } from './orchestration' import { RuntimeClientError } from '../runtime-client' -import { printResult } from '../format' afterEach(restoreTerminalIdentityEnv) @@ -452,12 +451,16 @@ describe('orchestration dispatch Forget + raw read CLI handlers (W-T2)', () => { 'orchestration dispatch-forget', new Map([ ['task', 'task_1'], - ['expected-failure-id', 'fail-1'] + ['expected-failure-id', 'fail-1'], + ['from', 'term_coord'], + ['run', 'run_1'] ]) ) expect(callMock).toHaveBeenCalledWith('orchestration.dispatchForget', { task: 'task_1', + run: 'run_1', + from: 'term_coord', expectedFailureId: 'fail-1' }) }) @@ -469,11 +472,40 @@ describe('orchestration dispatch Forget + raw read CLI handlers (W-T2)', () => { await invoke( 'orchestration dispatch-forget', - new Map([['task', 'task_1']]) + new Map([ + ['task', 'task_1'], + ['from', 'term_coord'] + ]) ) expect(callMock).toHaveBeenCalledWith('orchestration.dispatchForget', { task: 'task_1', + run: undefined, + from: 'term_coord', + expectedFailureId: undefined + }) + }) + + // Owner authorization is Run-scoped, so an unattested coordinator terminal must + // still send a resolved handle rather than relying on the envelope fallback. + it('dispatch-forget sends the coordinator handle resolved from the live terminal', async () => { + process.env.ORCA_TERMINAL_HANDLE = 'term_env' + callMock + .mockResolvedValueOnce({ result: { terminal: { handle: 'term_env' } } }) + .mockResolvedValueOnce({ + dispatch: { id: 'ctx_1', task_id: 'task_1', status: 'forgotten' } + }) + + await invoke( + 'orchestration dispatch-forget', + new Map([['task', 'task_1']]) + ) + + expect(callMock).toHaveBeenCalledWith('terminal.show', { terminal: 'term_env' }) + expect(callMock).toHaveBeenCalledWith('orchestration.dispatchForget', { + task: 'task_1', + run: undefined, + from: 'term_env', expectedFailureId: undefined }) }) @@ -634,279 +666,3 @@ describe('orchestration task-create caller handle', () => { }) }) }) -describe('orchestration timeout flag validation', () => { - const invalidTimeoutValues: [string, string | boolean][] = [ - ['missing', true], - ['empty', ''], - ['non-numeric', 'not-a-number'], - ['zero', '0'], - ['negative', '-1'] - ] - - beforeEach(() => { - callMock.mockReset() - delete process.env.ORCA_TERMINAL_HANDLE - delete process.env.ORCA_PANE_KEY - }) - - const invokeCheck = handlerInvoker(ORCHESTRATION_HANDLERS['orchestration check']) - const invokeAsk = handlerInvoker(ORCHESTRATION_HANDLERS['orchestration ask']) - - it.each(invalidTimeoutValues)('rejects invalid check --timeout-ms: %s', async (_label, value) => { - const flags = new Map([ - ['wait', true], - ['timeout-ms', value] - ]) - - await expect(invokeCheck(flags)).rejects.toThrow(/--timeout-ms/) - expect(callMock).not.toHaveBeenCalled() - }) - - it('passes a parsed check timeout and peek mode into the RPC payload', async () => { - process.env.ORCA_TERMINAL_HANDLE = 'term_worker' - callMock.mockResolvedValue({ result: { messages: [], count: 0 } }) - - await invokeCheck( - new Map([ - ['wait', true], - ['peek', true], - ['timeout-ms', '250'] - ]) - ) - - // Why: --peek rides with unread:false so pre-peek runtimes fall back to - // the non-consuming all mode instead of the destructive mark-read default. - expect(callMock).toHaveBeenCalledWith('orchestration.check', { - terminal: 'term_worker', - terminalPaneKey: undefined, - unread: false, - peek: true, - all: undefined, - types: undefined, - format: undefined, - compatibilityCliCommand: expect.stringMatching(/^orca(?:-ide)?$/), - run: undefined, - ack: undefined, - wait: true, - timeoutMs: 250 - }) - }) - - it('filters already-read rows from a peek response for pre-peek runtimes', async () => { - process.env.ORCA_TERMINAL_HANDLE = 'term_worker' - callMock.mockResolvedValue({ - result: { - messages: [ - { id: 'msg_old', from_handle: 'a', subject: 'seen', read: 1 }, - { id: 'msg_new', from_handle: 'a', subject: 'fresh', read: 0 } - ], - count: 2, - formatted: 'banners built from all rows' - } - }) - vi.mocked(printResult).mockClear() - - await invokeCheck(new Map([['peek', true]])) - - const response = vi.mocked(printResult).mock.calls[0]?.[0] as { - result: { messages: { id: string }[]; count: number; formatted?: string } - } - expect(response.result.messages.map((m) => m.id)).toEqual(['msg_new']) - expect(response.result.count).toBe(1) - // Why: the pre-peek runtime built `formatted` from all rows, including - // the read one the filter just removed. - expect(response.result.formatted).toBeUndefined() - }) - - it('rejects combined read modes before calling the runtime', async () => { - process.env.ORCA_TERMINAL_HANDLE = 'term_worker' - callMock.mockClear() - - await expect( - invokeCheck( - new Map([ - ['unread', true], - ['peek', true] - ]) - ) - ).rejects.toMatchObject({ - code: 'invalid_argument', - message: expect.stringContaining('read mode') - }) - expect(callMock).not.toHaveBeenCalled() - }) - - it('warns when a pre-peek runtime returned a full 100-row page', async () => { - process.env.ORCA_TERMINAL_HANDLE = 'term_worker' - const rows = Array.from({ length: 100 }, (_, i) => ({ - id: `msg_${i}`, - from_handle: 'a', - subject: `s${i}`, - read: i === 0 ? 0 : 1 - })) - callMock.mockResolvedValue({ result: { messages: rows, count: 100 } }) - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - - await invokeCheck(new Map([['peek', true]])) - - expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('newest 100 messages')) - errorSpy.mockRestore() - }) - - it('fails --peek --wait against a runtime that returned only read rows', async () => { - process.env.ORCA_TERMINAL_HANDLE = 'term_worker' - callMock.mockResolvedValue({ - result: { - messages: [{ id: 'msg_old', from_handle: 'a', subject: 'seen', read: 1 }], - count: 1 - } - }) - - await expect( - invokeCheck( - new Map([ - ['peek', true], - ['wait', true] - ]) - ) - ).rejects.toMatchObject({ code: 'peek_wait_unsupported' }) - }) - - it.each(invalidTimeoutValues)('rejects invalid ask --timeout-ms: %s', async (_label, value) => { - const flags = new Map([ - ['to', 'term_coord'], - ['question', 'Proceed?'], - ['timeout-ms', value] - ]) - - await expect(invokeAsk(flags)).rejects.toThrow(/--timeout-ms/) - expect(callMock).not.toHaveBeenCalled() - }) - - it('uses the parsed ask timeout for both runtime wait and client timeout', async () => { - process.env.ORCA_TERMINAL_HANDLE = 'term_worker' - callMock.mockResolvedValue({ - result: { - answer: 'yes', - messageId: 'msg_1', - threadId: 'thread_1', - timedOut: false - } - }) - vi.spyOn(console, 'log').mockImplementation(() => {}) - - await invokeAsk( - new Map([ - ['to', 'term_coord'], - ['question', 'Proceed?'], - ['timeout-ms', '123'] - ]) - ) - - expect(callMock).toHaveBeenCalledWith( - 'orchestration.ask', - { - to: 'term_coord', - run: undefined, - question: 'Proceed?', - resume: undefined, - options: undefined, - timeoutMs: 123, - from: 'term_worker', - compatibilityCliCommand: expect.stringMatching(/^orca(?:-ide)?$/), - compatibilityWindowsCommand: undefined - }, - { timeoutMs: 5_123, orchestrationCapability: undefined } - ) - }) - - it('passes an ask resume without creating a new question payload', async () => { - process.env.ORCA_TERMINAL_HANDLE = 'term_worker' - callMock.mockResolvedValue({ - result: { - answer: 'yes', - messageId: 'msg_question', - threadId: 'msg_question', - timedOut: false - } - }) - vi.spyOn(console, 'log').mockImplementation(() => {}) - - await invokeAsk(new Map([['resume', 'msg_question']])) - - expect(callMock).toHaveBeenCalledWith( - 'orchestration.ask', - { - to: undefined, - run: undefined, - question: undefined, - resume: 'msg_question', - options: undefined, - timeoutMs: undefined, - from: 'term_worker', - compatibilityCliCommand: expect.stringMatching(/^orca(?:-ide)?$/), - compatibilityWindowsCommand: undefined - }, - { timeoutMs: 605_000, orchestrationCapability: undefined } - ) - }) - - it('rejects ambiguous ask create/resume input before RPC', async () => { - process.env.ORCA_TERMINAL_HANDLE = 'term_worker' - await expect( - invokeAsk( - new Map([ - ['question', 'new'], - ['resume', 'msg_old'] - ]) - ) - ).rejects.toMatchObject({ code: 'invalid_argument' }) - expect(callMock).not.toHaveBeenCalled() - }) -}) - -describe('orchestration task-list brief output', () => { - it('requests server-side brief and falls back client-side for older runtimes', async () => { - callMock.mockReset().mockResolvedValue({ - result: { - // No spec_truncated field — the pre-brief-runtime signature. - tasks: [{ id: 'task_1', spec: `First line\n${'detail '.repeat(40)}`, status: 'ready' }], - count: 1 - } - }) - vi.mocked(printResult).mockClear() - - await handlerInvoker(ORCHESTRATION_HANDLERS['orchestration task-list'])( - new Map([['brief', true]]) - ) - - expect(callMock).toHaveBeenCalledWith( - 'orchestration.taskList', - expect.objectContaining({ brief: true }) - ) - const response = vi.mocked(printResult).mock.calls[0]?.[0] as { - result: { tasks: { spec: string; spec_truncated: boolean }[] } - } - expect(response.result.tasks[0].spec).toHaveLength(160) - expect(response.result.tasks[0].spec_truncated).toBe(true) - }) - - it('passes server-abbreviated rows through untouched', async () => { - const serverTasks = [ - { id: 'task_1', spec: 'already brief…', status: 'ready', spec_truncated: true } - ] - callMock.mockReset().mockResolvedValue({ result: { tasks: serverTasks, count: 1 } }) - vi.mocked(printResult).mockClear() - - await handlerInvoker(ORCHESTRATION_HANDLERS['orchestration task-list'])( - new Map([['brief', true]]) - ) - - const response = vi.mocked(printResult).mock.calls[0]?.[0] as { - result: { tasks: { spec: string; spec_truncated: boolean }[] } - } - // Why: re-abbreviating a server-truncated spec would flip spec_truncated - // back to false (the truncated text fits the cap). - expect(response.result.tasks).toBe(serverTasks) - }) -}) diff --git a/src/cli/handlers/orchestration/dispatch-handlers.ts b/src/cli/handlers/orchestration/dispatch-handlers.ts index a6fcdf99ab6..fc4ee57fa0f 100644 --- a/src/cli/handlers/orchestration/dispatch-handlers.ts +++ b/src/cli/handlers/orchestration/dispatch-handlers.ts @@ -88,12 +88,14 @@ export const ORCHESTRATION_DISPATCH_INSPECTION_HANDLERS: Record { + 'orchestration dispatch-forget': async ({ flags, client, cwd, json }) => { const result = await client.call<{ dispatch: { id: string; task_id: string; status: string } | null }>('orchestration.dispatchForget', { task: getRequiredStringFlag(flags, 'task'), - expectedFailureId: getOptionalStringFlag(flags, 'expected-failure-id') + expectedFailureId: getOptionalStringFlag(flags, 'expected-failure-id'), + run: getOptionalStringFlag(flags, 'run'), + from: await resolveCoordinatorTerminalHandle(flags, cwd, client) }) printResult(result, json, (value) => { if (!value.dispatch) { diff --git a/src/cli/specs/orchestration.ts b/src/cli/specs/orchestration.ts index c775088c0c8..a62c06807c8 100644 --- a/src/cli/specs/orchestration.ts +++ b/src/cli/specs/orchestration.ts @@ -200,8 +200,8 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [ path: ['orchestration', 'dispatch-forget'], summary: 'Forget a dispatch stranded in an unknown launch state', usage: - 'orca orchestration dispatch-forget --task [--expected-failure-id ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'task', 'expected-failure-id'], + 'orca orchestration dispatch-forget --task [--expected-failure-id ] [--run ] [--from ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'task', 'expected-failure-id', 'run', 'from'], notes: [ 'The task returns to blocked; retry with: orca orchestration task-update --id --status ready.' ] diff --git a/src/main/agent-launch/agent-catalog-durable-write.test.ts b/src/main/agent-launch/agent-catalog-durable-write.test.ts new file mode 100644 index 00000000000..1e36804beeb --- /dev/null +++ b/src/main/agent-launch/agent-catalog-durable-write.test.ts @@ -0,0 +1,159 @@ +// Catalog/reference authoring against the real Store: an ack means the bytes are on +// disk, a refused write is a typed failure, and payload budgets measure the projection +// persistence actually produces (which expands the caller's patch). +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import type { GlobalSettings } from '../../shared/types' +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' + +const testState = { dir: '' } + +vi.mock('electron', () => ({ + app: { getPath: () => testState.dir }, + safeStorage: { + isEncryptionAvailable: () => false, + encryptString: (plaintext: string) => Buffer.from(plaintext, 'utf-8'), + decryptString: (ciphertext: Buffer) => ciphertext.toString('utf-8') + } +})) + +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn() })) +vi.mock('../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: vi.fn(() => null), + sshConfigHostsToTargets: vi.fn(() => []) +})) + +async function createService(dataFile: string) { + vi.resetModules() + const { Store } = await import('../persistence') + const { AgentCatalogService } = await import('./agent-catalog-service') + const store = new Store({ dataFile }) + return { store, service: new AgentCatalogService(store) } +} + +function readPersisted(dataFile: string): { settings: GlobalSettings } { + return JSON.parse(readFileSync(dataFile, 'utf-8')) as { settings: GlobalSettings } +} + +const CREATE_CODEX = { + expectedRevision: 1, + mutation: { + kind: 'create' as const, + baseAgent: 'codex' as const, + draft: { + label: 'Durable', + commandOverride: null, + args: '', + env: {}, + syncEnv: false + } + } +} + +describe('durable catalog authoring through the real Store', () => { + let dir = '' + let dataFile = '' + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'orca-agent-catalog-durable-')) + testState.dir = dir + dataFile = join(dir, 'orca-data.json') + }) + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + }) + + it('has the created agent on disk before the mutation returns ok', async () => { + const { service } = await createService(dataFile) + const result = service.mutate(CREATE_CODEX) + expect(result.ok).toBe(true) + // No debounce wait, no flush: the ack itself is the durability barrier. + expect(readPersisted(dataFile).settings.customTuiAgents).toHaveLength(1) + }) + + it('has the tombstoned deletion on disk before the mutation returns ok', async () => { + const { service, store } = await createService(dataFile) + const created = service.mutate(CREATE_CODEX) + expect(created.ok).toBe(true) + const [persistedAgent] = store.getSettings().customTuiAgents ?? [] + const deleted = service.mutate({ + expectedRevision: service.getRevision(), + mutation: { kind: 'delete-custom', id: persistedAgent.id } + }) + expect(deleted.ok).toBe(true) + const persisted = readPersisted(dataFile).settings + expect(persisted.customTuiAgents).toHaveLength(0) + expect(persisted.deletedCustomTuiAgents).toHaveLength(1) + }) + + it('reports a typed failure instead of acking when the write cannot land', async () => { + const { service, store } = await createService(dataFile) + expect(service.mutate(CREATE_CODEX).ok).toBe(true) + store.freezeWrites() + const result = service.mutate({ + expectedRevision: service.getRevision(), + mutation: { + kind: 'create', + baseAgent: 'claude', + draft: { + label: 'Lost', + commandOverride: null, + args: '', + env: {}, + syncEnv: false + } + } + }) + expect(result).toMatchObject({ + ok: false, + code: 'agent_catalog_write_failed' + }) + // Rolled back: an unacknowledged agent must not linger in memory either. + expect(store.getSettings().customTuiAgents).toHaveLength(1) + expect(readPersisted(dataFile).settings.customTuiAgents).toHaveLength(1) + }) + + it('rejects a reference write whose persisted projection expands past 512 KiB', async () => { + const { service, store } = await createService(dataFile) + // Under the cap as written; persistence derives legacy commitMessageAi from it, + // so the projection that ships carries the instructions twice. + const instructions = 'x'.repeat(300_000) + const result = service.mutateReferences({ + expectedReferenceRevision: 1, + mutation: { + kind: 'source-control-update', + changes: { instructionsByOperation: { commitMessage: instructions } } + } + }) + expect(result).toMatchObject({ + ok: false, + code: 'agent_reference_payload_too_large' + }) + expect(store.getSettings().sourceControlAi?.instructionsByOperation?.commitMessage).not.toBe( + instructions + ) + }) + + it('keeps a reference write whose persisted projection stays under 512 KiB', async () => { + const { service, store } = await createService(dataFile) + const result = service.mutateReferences({ + expectedReferenceRevision: 1, + mutation: { + kind: 'source-control-update', + changes: { + instructionsByOperation: { commitMessage: 'x'.repeat(1_000) } + } + } + }) + expect(result.ok).toBe(true) + expect( + store.getSettings().sourceControlAi?.instructionsByOperation?.commitMessage + ).toHaveLength(1_000) + expect( + readPersisted(dataFile).settings.sourceControlAi?.instructionsByOperation?.commitMessage + ).toHaveLength(1_000) + }) +}) diff --git a/src/main/agent-launch/agent-catalog-mutations.test.ts b/src/main/agent-launch/agent-catalog-mutations.test.ts index c3edd7eaa8f..46cc9f5b525 100644 --- a/src/main/agent-launch/agent-catalog-mutations.test.ts +++ b/src/main/agent-launch/agent-catalog-mutations.test.ts @@ -6,6 +6,7 @@ import type { GlobalSettings } from '../../shared/types' import type { CustomAgentDraft } from '../../shared/agent-catalog-snapshot' +import { tokenizeStartupCommand } from '../../shared/tui-agent-startup-shell' import { AgentCatalogRepairTokenRegistry, applyAgentCatalogMutation, @@ -323,6 +324,28 @@ describe('duplicate', () => { expect(copy?.args).toBe('codex-real --fast --user-arg') }) + it('keeps a grouped prefix argument grouped instead of flattening it into two args', () => { + const result = apply({ + settings: settingsWith({ + agentCmdOverrides: { codex: '/opt/wrap --prompt "hello world"' }, + agentDefaultArgs: { codex: '--user-arg' } + }), + mutation: { kind: 'duplicate', sourceAgent: 'codex', label: 'Wrapped' } + }) + expect(result.ok).toBe(true) + if (!result.ok) { + return + } + const copy = result.patch.customTuiAgents?.[0] + expect(copy?.commandOverride).toBe('/opt/wrap') + // A bare join would store `--prompt hello world`, which re-splits into three. + expect(copy?.args).toBe('--prompt "hello world" --user-arg') + for (const shell of ['posix', 'powershell', 'cmd'] as const) { + const tokenized = tokenizeStartupCommand(copy?.args ?? '', shell) + expect(tokenized.ok && tokenized.tokens).toEqual(['--prompt', 'hello world', '--user-arg']) + } + }) + it('rejects a platform-ambiguous built-in prefix instead of guessing a grammar', () => { const result = apply({ settings: settingsWith({ diff --git a/src/main/agent-launch/agent-catalog-mutations.ts b/src/main/agent-launch/agent-catalog-mutations.ts index ace2c239fbb..c975528d878 100644 --- a/src/main/agent-launch/agent-catalog-mutations.ts +++ b/src/main/agent-launch/agent-catalog-mutations.ts @@ -13,10 +13,8 @@ import type { } from '../../shared/types' import type { AgentCatalogMutationRequest } from '../../shared/agent-catalog-snapshot' import { normalizeAgentCatalog, type AgentCatalog } from '../../shared/custom-tui-agents' -import type { - AgentCatalogMutationApplication, - TombstoneReferenceCount -} from './agent-catalog-draft-validation' +import type { AgentCatalogMutationApplication } from './agent-catalog-draft-validation' +import type { TombstoneReferenceCounter } from './agent-catalog-tombstone-gc' import { applyCreate, applyDelete, @@ -41,8 +39,9 @@ export type ApplyAgentCatalogMutationArgs = { currentRevision: number repairTokens: AgentCatalogRepairTokenRegistry /** Authoritative reference count per tombstone id; 'unknown' means an owner - * store could not be checked and the tombstone must be retained. */ - countTombstoneReferences: (id: CustomTuiAgentId) => TombstoneReferenceCount + * store could not be checked and the tombstone must be retained. Accepts a + * batch counter so a prune indexes the owners once instead of per tombstone. */ + countTombstoneReferences: TombstoneReferenceCounter } export type MutationContext = { diff --git a/src/main/agent-launch/agent-catalog-owner-scanners-batch-counter.test.ts b/src/main/agent-launch/agent-catalog-owner-scanners-batch-counter.test.ts new file mode 100644 index 00000000000..2bb426748ce --- /dev/null +++ b/src/main/agent-launch/agent-catalog-owner-scanners-batch-counter.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest' +import type { CustomTuiAgentId } from '../../shared/types' +import { AgentTombstoneReferenceIndex } from './agent-tombstone-reference-index' +import { createBatchTombstoneReferenceCounter } from './agent-catalog-owner-scanners' + +const idA = 'custom-agent:codex:fedcba98-7654-4321-8fed-cba987654321' as CustomTuiAgentId +const idB = 'custom-agent:claude:01234567-89ab-4cde-8f01-23456789abcd' as CustomTuiAgentId +const idC = 'custom-agent:gemini:01234567-89ab-4cde-8f01-23456789abce' as CustomTuiAgentId + +function countForIds( + index: AgentTombstoneReferenceIndex, + ids: readonly CustomTuiAgentId[] +): ReadonlyMap { + const counter = createBatchTombstoneReferenceCounter(index) + if (typeof counter === 'function') { + throw new Error('expected a batch counter') + } + return counter.countForIds(ids) +} + +describe('createBatchTombstoneReferenceCounter', () => { + it('matches per-id countReferences for every id in one owner pass', () => { + const index = new AgentTombstoneReferenceIndex() + let scans = 0 + index.register({ + owner: 'automation', + scan: () => { + scans += 1 + return { ok: true, referencedIds: [idA, idA, 'auto', null] } + } + }) + index.register({ + owner: 'workspace', + scan: () => { + scans += 1 + return { ok: true, referencedIds: [idB] } + } + }) + + const batch = countForIds(index, [idA, idB, idC]) + expect(scans).toBe(2) + expect([...batch]).toEqual([ + [idA, index.countReferences(idA)], + [idB, index.countReferences(idB)], + [idC, index.countReferences(idC)] + ]) + expect(batch.get(idA)).toBe(2) + expect(batch.get(idB)).toBe(1) + expect(batch.get(idC)).toBe(0) + }) + + it('reports unknown for the whole batch when any owner cannot be read', () => { + const index = new AgentTombstoneReferenceIndex() + index.register({ owner: 'automation', scan: () => ({ ok: true, referencedIds: [idA] }) }) + index.register({ owner: 'session', scan: () => ({ ok: false }) }) + + const batch = countForIds(index, [idA, idB]) + // Same conservative retain the per-id path produces. + expect(index.countReferences(idB)).toBe('unknown') + expect(batch.get(idA)).toBe('unknown') + expect(batch.get(idB)).toBe('unknown') + }) + + it('answers an empty batch without claiming references', () => { + const index = new AgentTombstoneReferenceIndex() + index.register({ owner: 'automation', scan: () => ({ ok: true, referencedIds: [idA] }) }) + expect([...countForIds(index, [])]).toEqual([]) + }) +}) diff --git a/src/main/agent-launch/agent-catalog-service-prune-batching.test.ts b/src/main/agent-launch/agent-catalog-service-prune-batching.test.ts new file mode 100644 index 00000000000..3d1067cf475 --- /dev/null +++ b/src/main/agent-launch/agent-catalog-service-prune-batching.test.ts @@ -0,0 +1,124 @@ +// P1-9: the service's prune must index the owners ONCE per prune. A per-id +// counter re-scans every owner store per tombstone, which is quadratic on real +// catalogs, so these tests count owner scans rather than prune outcomes. + +import { describe, expect, it } from 'vitest' +import type { + CustomTuiAgentId, + DeletedCustomTuiAgent, + GlobalSettings, + TerminalAgentQuickCommand +} from '../../shared/types' +import type { Store } from '../persistence' +import { AgentCatalogService } from './agent-catalog-service' + +const UUID_TAIL = '-89ab-4cde-8f01-23456789abcd' +const TOMBSTONE_COUNT = 25 + +function customId(index: number): CustomTuiAgentId { + return `custom-agent:codex:${`${index}`.padStart(8, '0')}${UUID_TAIL}` as CustomTuiAgentId +} + +function unreferencedTombstones(count: number): DeletedCustomTuiAgent[] { + return Array.from({ length: count }, (_, index) => ({ + id: customId(index), + baseAgent: 'codex' as const, + label: `Gone ${index}`, + deletedAt: index + })) +} + +type ScanCounts = { worktreeMeta: number; automations: number } + +function makeCountingStore(settings: GlobalSettings): { store: Store; scans: ScanCounts } { + const state = { settings } + const scans: ScanCounts = { worktreeMeta: 0, automations: 0 } + const preview = (updates: Partial): GlobalSettings => ({ + ...state.settings, + ...updates + }) + const stub = { + getSettings: () => state.settings, + getAgentCatalogMigrationError: () => null, + getAgentCatalogSchemaTooNew: () => null, + previewSettingsUpdate: preview, + updateSettingsDurable: (updates: Partial) => { + state.settings = preview(updates) + return state.settings + }, + updateSettings: (updates: Partial) => { + state.settings = preview(updates) + return state.settings + }, + getRepos: () => [], + listAutomations: () => { + scans.automations += 1 + return [] + }, + listAutomationRuns: () => [], + getAllWorktreeMeta: () => { + scans.worktreeMeta += 1 + return {} + } + } + return { store: stub as unknown as Store, scans } +} + +function baseSettings(overrides: Partial = {}): GlobalSettings { + return { + defaultTuiAgent: 'auto', + disabledTuiAgents: [], + customTuiAgents: [], + deletedCustomTuiAgents: [], + agentCatalogRevision: 1, + agentReferenceRevision: 1, + terminalQuickCommands: [], + agentCmdOverrides: {}, + ...overrides + } as GlobalSettings +} + +function agentQuickCommand(agent: CustomTuiAgentId): TerminalAgentQuickCommand { + return { id: 'qc-1', label: 'Q', action: 'agent-prompt', agent, prompt: 'p' } +} + +describe('agent catalog service tombstone prune batching (P1-9)', () => { + it('scans each owner once for a mutation prune, not once per tombstone', () => { + const { store, scans } = makeCountingStore( + baseSettings({ deletedCustomTuiAgents: unreferencedTombstones(TOMBSTONE_COUNT) }) + ) + const service = new AgentCatalogService(store) + + const created = service.mutate({ + expectedRevision: 1, + mutation: { + kind: 'create', + baseAgent: 'claude', + draft: { label: 'New One', commandOverride: null, args: '', env: {}, syncEnv: false } + } + }) + + expect(created.ok).toBe(true) + expect(store.getSettings().deletedCustomTuiAgents).toHaveLength(0) + expect(scans).toEqual({ worktreeMeta: 1, automations: 1 }) + }) + + it('scans each owner once for the post-reference-removal prune', () => { + const { store, scans } = makeCountingStore( + baseSettings({ + terminalQuickCommands: [agentQuickCommand(customId(0))], + deletedCustomTuiAgents: unreferencedTombstones(TOMBSTONE_COUNT) + }) + ) + const service = new AgentCatalogService(store) + + const result = service.mutateReferences({ + expectedReferenceRevision: 1, + mutation: { kind: 'quick-command-delete', id: 'qc-1' } + }) + + expect(result.ok).toBe(true) + expect(store.getSettings().deletedCustomTuiAgents).toHaveLength(0) + expect(scans).toEqual({ worktreeMeta: 1, automations: 1 }) + }) +}) diff --git a/src/main/agent-launch/agent-catalog-service-test-fixtures.ts b/src/main/agent-launch/agent-catalog-service-test-fixtures.ts new file mode 100644 index 00000000000..4ad8ef5f0d3 --- /dev/null +++ b/src/main/agent-launch/agent-catalog-service-test-fixtures.ts @@ -0,0 +1,108 @@ +// Shared store stub and catalog fixtures for the AgentCatalogService suites. + +import type { + CustomTuiAgent, + CustomTuiAgentId, + GlobalSettings, + Repo, + TerminalAgentQuickCommand, + WorktreeMeta +} from '../../shared/types' +import type { Automation, AutomationRun } from '../../shared/automations-types' +import type { Store } from '../persistence' + +export const UUID_A = '01234567-89ab-4cde-8f01-23456789abcd' +export const UUID_B = 'fedcba98-7654-4321-8fed-cba987654321' + +export function customId(base: string, uuid = UUID_A): CustomTuiAgentId { + return `custom-agent:${base}:${uuid}` as CustomTuiAgentId +} + +export function liveAgent(overrides: Partial = {}): CustomTuiAgent { + return { + id: customId('codex'), + baseAgent: 'codex', + label: 'My Codex', + args: '', + env: {}, + syncEnv: false, + ...overrides + } +} + +export type StoreStubState = { + settings: GlobalSettings + repos: Repo[] + automations: Automation[] + automationRuns?: AutomationRun[] + worktreeMeta?: Record + failAutomationScan?: boolean + failWorktreeScan?: boolean + agentCatalogMigrationError?: string | null + agentCatalogSchemaTooNew?: { persistedVersion: number; supportedVersion: number } | null + failDurableWrite?: boolean + // Stands in for persistence-side normalization/derivation of a patch. + expandOnPersist?: (updates: Partial) => Partial +} + +export function makeStoreStub(state: StoreStubState): Store { + const preview = (updates: Partial): GlobalSettings => ({ + ...state.settings, + ...updates, + ...state.expandOnPersist?.(updates) + }) + const stub = { + getSettings: () => state.settings, + getAgentCatalogMigrationError: () => state.agentCatalogMigrationError ?? null, + getAgentCatalogSchemaTooNew: () => state.agentCatalogSchemaTooNew ?? null, + previewSettingsUpdate: preview, + updateSettingsDurable: (updates: Partial) => { + if (state.failDurableWrite) { + throw new Error('disk failure') + } + state.settings = preview(updates) + return state.settings + }, + updateSettings: (updates: Partial) => { + state.settings = preview(updates) + return state.settings + }, + getRepos: () => state.repos, + listAutomations: () => { + if (state.failAutomationScan) { + throw new Error('store unavailable') + } + return state.automations + }, + listAutomationRuns: () => state.automationRuns ?? [], + getAllWorktreeMeta: () => { + if (state.failWorktreeScan) { + throw new Error('store unavailable') + } + return state.worktreeMeta ?? {} + } + } + return stub as unknown as Store +} + +export function baseSettings(overrides: Partial = {}): GlobalSettings { + return { + defaultTuiAgent: 'auto', + disabledTuiAgents: [], + customTuiAgents: [], + deletedCustomTuiAgents: [], + agentCatalogRevision: 1, + agentReferenceRevision: 1, + terminalQuickCommands: [], + agentCmdOverrides: {}, + ...overrides + } as GlobalSettings +} + +export function tombstoneFor(id: CustomTuiAgentId) { + return { id, baseAgent: 'codex' as const, label: 'Gone', deletedAt: 1 } +} + +export function agentQuickCommand(agent: CustomTuiAgentId): TerminalAgentQuickCommand { + return { id: 'qc-1', label: 'Q', action: 'agent-prompt', agent, prompt: 'p' } +} diff --git a/src/main/agent-launch/agent-catalog-service-write-policy.test.ts b/src/main/agent-launch/agent-catalog-service-write-policy.test.ts new file mode 100644 index 00000000000..907add48093 --- /dev/null +++ b/src/main/agent-launch/agent-catalog-service-write-policy.test.ts @@ -0,0 +1,332 @@ +// Write-policy suites for AgentCatalogService: the pre-v1 migration gate, the +// read-only newer-schema gate, the reference payload budget, and the +// durable-before-ack contract. +import { describe, expect, it } from 'vitest' +import type { GlobalSettings } from '../../shared/types' +import { AgentCatalogService } from './agent-catalog-service' +import { + baseSettings, + makeStoreStub, + type StoreStubState +} from './agent-catalog-service-test-fixtures' + +describe('pre-v1 migration gate', () => { + it('blocks catalog and reference mutations while the pinned pre-v1 backup has failed', () => { + const state: StoreStubState = { + settings: baseSettings(), + repos: [], + automations: [], + agentCatalogMigrationError: 'disk full' + } + const service = new AgentCatalogService(makeStoreStub(state)) + const before = state.settings + + const catalogResult = service.mutate({ + expectedRevision: 1, + mutation: { + kind: 'create', + baseAgent: 'codex', + draft: { label: 'Blocked', commandOverride: null, args: '', env: {}, syncEnv: false } + } + }) + expect(catalogResult).toEqual({ + ok: false, + code: 'agent_catalog_migration_blocked', + migrationError: 'disk full' + }) + + const referenceResult = service.mutateReferences({ + expectedReferenceRevision: 1, + mutation: { kind: 'quick-command-delete', id: 'qc-1' } + }) + expect(referenceResult).toEqual({ + ok: false, + code: 'agent_catalog_migration_blocked', + migrationError: 'disk full' + }) + + // No v1 write of any kind may land on the unbacked-up profile. + expect(state.settings).toBe(before) + }) + + it('carries the block into the local snapshot so Settings surfaces it on load', () => { + const state: StoreStubState = { + settings: baseSettings(), + repos: [], + automations: [], + agentCatalogMigrationError: 'disk full' + } + const service = new AgentCatalogService(makeStoreStub(state)) + expect(service.getLocalSnapshot().migrationBlockedError).toBe('disk full') + + state.agentCatalogMigrationError = null + expect(service.getLocalSnapshot().migrationBlockedError).toBeUndefined() + }) + + it('projects the block to remote clients as a boolean only — never the error text', () => { + const state: StoreStubState = { + settings: baseSettings(), + repos: [], + automations: [], + agentCatalogMigrationError: 'disk full at /Users/someone/Library' + } + const service = new AgentCatalogService(makeStoreStub(state)) + const remote = service.getRemoteSnapshot() + expect('customAgents' in remote && remote.migrationBlocked).toBe(true) + expect(JSON.stringify(remote)).not.toContain('disk full') + expect(JSON.stringify(remote)).not.toContain('/Users/') + + state.agentCatalogMigrationError = null + const healthy = service.getRemoteSnapshot() + expect('migrationBlocked' in healthy).toBe(false) + }) +}) + +describe('schema-newer-than-supported read-only gate', () => { + function readOnlyState(): StoreStubState { + return { + settings: baseSettings(), + repos: [], + automations: [], + agentCatalogSchemaTooNew: { persistedVersion: 2, supportedVersion: 1 } + } + } + + it('rejects catalog and reference mutations up front instead of via the write refusal', () => { + const state = readOnlyState() + // A durable write here would be persistence's refusal, reported as a generic + // failure; the gate must answer before any write is attempted. + state.failDurableWrite = true + const service = new AgentCatalogService(makeStoreStub(state)) + const before = state.settings + + expect( + service.mutate({ + expectedRevision: 1, + mutation: { + kind: 'create', + baseAgent: 'codex', + draft: { label: 'Blocked', commandOverride: null, args: '', env: {}, syncEnv: false } + } + }) + ).toEqual({ + ok: false, + code: 'agent_catalog_schema_too_new', + persistedVersion: 2, + supportedVersion: 1 + }) + + expect( + service.mutateReferences({ + expectedReferenceRevision: 1, + mutation: { kind: 'quick-command-delete', id: 'qc-1' } + }) + ).toEqual({ + ok: false, + code: 'agent_catalog_schema_too_new', + persistedVersion: 2, + supportedVersion: 1 + }) + + expect(state.settings).toBe(before) + }) + + it('carries the read-only state into the local snapshot so Settings surfaces it on load', () => { + const state = readOnlyState() + const service = new AgentCatalogService(makeStoreStub(state)) + expect(service.getLocalSnapshot().schemaTooNew).toEqual({ + persistedVersion: 2, + supportedVersion: 1 + }) + + state.agentCatalogSchemaTooNew = null + expect(service.getLocalSnapshot().schemaTooNew).toBeUndefined() + }) + + it('leaves the remote projection unchanged: no new wire field for old clients', () => { + const service = new AgentCatalogService(makeStoreStub(readOnlyState())) + const remote = service.getRemoteSnapshot() + expect('migrationBlocked' in remote).toBe(false) + expect(JSON.stringify(remote)).not.toContain('schemaTooNew') + }) +}) + +describe('reference payload budget (L1-#2)', () => { + function sourceControlAiWith( + instructions: Partial> + ): GlobalSettings['sourceControlAi'] { + return { + enabled: true, + agentId: null, + actions: {}, + selectedModelByAgent: {}, + selectedThinkingByModel: {}, + customAgentCommand: '', + instructionsByOperation: instructions + } as GlobalSettings['sourceControlAi'] + } + + function serviceWith(settings: GlobalSettings): { + service: AgentCatalogService + state: StoreStubState + } { + const state: StoreStubState = { settings, repos: [], automations: [] } + return { service: new AgentCatalogService(makeStoreStub(state)), state } + } + + it('rejects a growing write that pushes the reference snapshot past 512 KiB', () => { + const { service, state } = serviceWith( + baseSettings({ + sourceControlAi: sourceControlAiWith({ commitMessage: 'x'.repeat(600_000) }) + }) + ) + const before = state.settings + const result = service.mutateReferences({ + expectedReferenceRevision: 1, + mutation: { + kind: 'source-control-update', + changes: { customAgentCommand: 'generate-with-huge-profile' } + } + }) + expect(result).toMatchObject({ + ok: false, + code: 'agent_reference_payload_too_large', + referenceRevision: 1 + }) + // A rejected budget check performs no write. + expect(state.settings).toBe(before) + }) + + it('still commits a shrinking write while the snapshot is over budget', () => { + const { service, state } = serviceWith( + baseSettings({ + sourceControlAi: sourceControlAiWith({ + commitMessage: 'x'.repeat(600_000), + pullRequest: 'y'.repeat(600_000) + }) + }) + ) + const result = service.mutateReferences({ + expectedReferenceRevision: 1, + mutation: { + kind: 'source-control-update', + changes: { + instructionsByOperation: { commitMessage: '', pullRequest: 'y'.repeat(600_000) } + } + } + }) + // Still > 512 KiB after the write, but smaller than before: the user must be + // able to edit an over-budget profile back under budget. + expect(result.ok).toBe(true) + expect(state.settings.sourceControlAi?.instructionsByOperation?.commitMessage).toBe('') + }) + + it('keeps a small write under budget flowing normally', () => { + const { service } = serviceWith(baseSettings()) + const result = service.mutateReferences({ + expectedReferenceRevision: 1, + mutation: { kind: 'source-control-update', changes: { customAgentCommand: 'ok' } } + }) + expect(result.ok).toBe(true) + }) + + it('budgets the persisted projection, not the pre-persistence patch', () => { + // Persistence derives legacy commitMessageAi from sourceControlAi, so an + // under-budget patch lands as a projection carrying the instructions twice. + const state: StoreStubState = { + settings: baseSettings(), + repos: [], + automations: [], + expandOnPersist: (updates) => + updates.sourceControlAi + ? { + commitMessageAi: { + enabled: true, + agentId: null, + selectedModelByAgent: {}, + selectedThinkingByModel: {}, + customPrompt: updates.sourceControlAi.instructionsByOperation?.commitMessage ?? '', + customAgentCommand: '' + } as GlobalSettings['commitMessageAi'] + } + : {} + } + const service = new AgentCatalogService(makeStoreStub(state)) + const before = state.settings + const result = service.mutateReferences({ + expectedReferenceRevision: 1, + mutation: { + kind: 'source-control-update', + changes: { instructionsByOperation: { commitMessage: 'x'.repeat(300_000) } } + } + }) + expect(result).toMatchObject({ ok: false, code: 'agent_reference_payload_too_large' }) + expect(state.settings).toBe(before) + }) +}) + +describe('durable authoring acknowledgement (P0-2)', () => { + function serviceWith(state: StoreStubState): AgentCatalogService { + const store = makeStoreStub(state) + // Any debounced write here would ack before the bytes are durable. + Object.assign(store, { + updateSettings: () => { + throw new Error('authoring must not use the debounced settings write') + } + }) + return new AgentCatalogService(store) + } + + const createCodex = { + expectedRevision: 1, + mutation: { + kind: 'create' as const, + baseAgent: 'codex' as const, + draft: { label: 'Durable', commandOverride: null, args: '', env: {}, syncEnv: false } + } + } + + it('commits catalog and reference mutations through the durable write path', () => { + const state: StoreStubState = { settings: baseSettings(), repos: [], automations: [] } + const service = serviceWith(state) + expect(service.mutate(createCodex).ok).toBe(true) + expect(state.settings.customTuiAgents).toHaveLength(1) + const referenceResult = service.mutateReferences({ + expectedReferenceRevision: 1, + mutation: { kind: 'source-control-update', changes: { customAgentCommand: 'ok' } } + }) + expect(referenceResult.ok).toBe(true) + }) + + it('reports a typed failure and keeps state unchanged when the durable write fails', () => { + const state: StoreStubState = { + settings: baseSettings(), + repos: [], + automations: [], + failDurableWrite: true + } + const service = serviceWith(state) + const revisions: number[] = [] + service.onDidChange((revision) => revisions.push(revision)) + const before = state.settings + + expect(service.mutate(createCodex)).toEqual({ + ok: false, + code: 'agent_catalog_write_failed', + revision: 1 + }) + expect( + service.mutateReferences({ + expectedReferenceRevision: 1, + mutation: { kind: 'source-control-update', changes: { customAgentCommand: 'ok' } } + }) + ).toEqual({ + ok: false, + code: 'agent_reference_write_failed', + referenceRevision: 1, + catalogRevision: 1 + }) + expect(state.settings).toBe(before) + expect(revisions).toEqual([]) + }) +}) diff --git a/src/main/agent-launch/agent-catalog-service.test.ts b/src/main/agent-launch/agent-catalog-service.test.ts index 5756b0bccc8..514876ddadb 100644 --- a/src/main/agent-launch/agent-catalog-service.test.ts +++ b/src/main/agent-launch/agent-catalog-service.test.ts @@ -1,113 +1,21 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import type { - CustomTuiAgent, - CustomTuiAgentId, - GlobalSettings, - Repo, - TerminalAgentQuickCommand, - TuiAgent, - WorktreeMeta -} from '../../shared/types' +import type { GlobalSettings, Repo, TuiAgent, WorktreeMeta } from '../../shared/types' import type { Automation, AutomationRun } from '../../shared/automations-types' -import type { Store } from '../persistence' import { AgentCatalogService } from './agent-catalog-service' import { getHostAgentSessionRecordStore } from './agent-session-record-store-host' import type { HostSessionLaunchRecord } from './agent-session-record-store' import { getHostBackgroundAgentLaunchStore } from './background-agent-launch-store-host' - -const UUID_A = '01234567-89ab-4cde-8f01-23456789abcd' -const UUID_B = 'fedcba98-7654-4321-8fed-cba987654321' - -function customId(base: string, uuid = UUID_A): CustomTuiAgentId { - return `custom-agent:${base}:${uuid}` as CustomTuiAgentId -} - -function liveAgent(overrides: Partial = {}): CustomTuiAgent { - return { - id: customId('codex'), - baseAgent: 'codex', - label: 'My Codex', - args: '', - env: {}, - syncEnv: false, - ...overrides - } -} - -type StoreStubState = { - settings: GlobalSettings - repos: Repo[] - automations: Automation[] - automationRuns?: AutomationRun[] - worktreeMeta?: Record - failAutomationScan?: boolean - failWorktreeScan?: boolean - agentCatalogMigrationError?: string | null - failDurableWrite?: boolean - // Stands in for persistence-side normalization/derivation of a patch. - expandOnPersist?: (updates: Partial) => Partial -} - -function makeStoreStub(state: StoreStubState): Store { - const preview = (updates: Partial): GlobalSettings => ({ - ...state.settings, - ...updates, - ...state.expandOnPersist?.(updates) - }) - const stub = { - getSettings: () => state.settings, - getAgentCatalogMigrationError: () => state.agentCatalogMigrationError ?? null, - previewSettingsUpdate: preview, - updateSettingsDurable: (updates: Partial) => { - if (state.failDurableWrite) { - throw new Error('disk failure') - } - state.settings = preview(updates) - return state.settings - }, - updateSettings: (updates: Partial) => { - state.settings = preview(updates) - return state.settings - }, - getRepos: () => state.repos, - listAutomations: () => { - if (state.failAutomationScan) { - throw new Error('store unavailable') - } - return state.automations - }, - listAutomationRuns: () => state.automationRuns ?? [], - getAllWorktreeMeta: () => { - if (state.failWorktreeScan) { - throw new Error('store unavailable') - } - return state.worktreeMeta ?? {} - } - } - return stub as unknown as Store -} - -function baseSettings(overrides: Partial = {}): GlobalSettings { - return { - defaultTuiAgent: 'auto', - disabledTuiAgents: [], - customTuiAgents: [], - deletedCustomTuiAgents: [], - agentCatalogRevision: 1, - agentReferenceRevision: 1, - terminalQuickCommands: [], - agentCmdOverrides: {}, - ...overrides - } as GlobalSettings -} - -function tombstoneFor(id: CustomTuiAgentId) { - return { id, baseAgent: 'codex' as const, label: 'Gone', deletedAt: 1 } -} - -function agentQuickCommand(agent: CustomTuiAgentId): TerminalAgentQuickCommand { - return { id: 'qc-1', label: 'Q', action: 'agent-prompt', agent, prompt: 'p' } -} +import { + UUID_A, + UUID_B, + agentQuickCommand, + baseSettings, + customId, + liveAgent, + makeStoreStub, + tombstoneFor, + type StoreStubState +} from './agent-catalog-service-test-fixtures' describe('tombstone reference GC across owners', () => { const deadId = customId('codex', UUID_B) @@ -596,255 +504,3 @@ describe('local draft endpoint', () => { } }) }) - -describe('pre-v1 migration gate', () => { - it('blocks catalog and reference mutations while the pinned pre-v1 backup has failed', () => { - const state: StoreStubState = { - settings: baseSettings(), - repos: [], - automations: [], - agentCatalogMigrationError: 'disk full' - } - const service = new AgentCatalogService(makeStoreStub(state)) - const before = state.settings - - const catalogResult = service.mutate({ - expectedRevision: 1, - mutation: { - kind: 'create', - baseAgent: 'codex', - draft: { label: 'Blocked', commandOverride: null, args: '', env: {}, syncEnv: false } - } - }) - expect(catalogResult).toEqual({ - ok: false, - code: 'agent_catalog_migration_blocked', - migrationError: 'disk full' - }) - - const referenceResult = service.mutateReferences({ - expectedReferenceRevision: 1, - mutation: { kind: 'quick-command-delete', id: 'qc-1' } - }) - expect(referenceResult).toEqual({ - ok: false, - code: 'agent_catalog_migration_blocked', - migrationError: 'disk full' - }) - - // No v1 write of any kind may land on the unbacked-up profile. - expect(state.settings).toBe(before) - }) - - it('carries the block into the local snapshot so Settings surfaces it on load', () => { - const state: StoreStubState = { - settings: baseSettings(), - repos: [], - automations: [], - agentCatalogMigrationError: 'disk full' - } - const service = new AgentCatalogService(makeStoreStub(state)) - expect(service.getLocalSnapshot().migrationBlockedError).toBe('disk full') - - state.agentCatalogMigrationError = null - expect(service.getLocalSnapshot().migrationBlockedError).toBeUndefined() - }) - - it('projects the block to remote clients as a boolean only — never the error text', () => { - const state: StoreStubState = { - settings: baseSettings(), - repos: [], - automations: [], - agentCatalogMigrationError: 'disk full at /Users/someone/Library' - } - const service = new AgentCatalogService(makeStoreStub(state)) - const remote = service.getRemoteSnapshot() - expect('customAgents' in remote && remote.migrationBlocked).toBe(true) - expect(JSON.stringify(remote)).not.toContain('disk full') - expect(JSON.stringify(remote)).not.toContain('/Users/') - - state.agentCatalogMigrationError = null - const healthy = service.getRemoteSnapshot() - expect('migrationBlocked' in healthy).toBe(false) - }) -}) - -describe('reference payload budget (L1-#2)', () => { - function sourceControlAiWith( - instructions: Partial> - ): GlobalSettings['sourceControlAi'] { - return { - enabled: true, - agentId: null, - actions: {}, - selectedModelByAgent: {}, - selectedThinkingByModel: {}, - customAgentCommand: '', - instructionsByOperation: instructions - } as GlobalSettings['sourceControlAi'] - } - - function serviceWith(settings: GlobalSettings): { - service: AgentCatalogService - state: StoreStubState - } { - const state: StoreStubState = { settings, repos: [], automations: [] } - return { service: new AgentCatalogService(makeStoreStub(state)), state } - } - - it('rejects a growing write that pushes the reference snapshot past 512 KiB', () => { - const { service, state } = serviceWith( - baseSettings({ - sourceControlAi: sourceControlAiWith({ commitMessage: 'x'.repeat(600_000) }) - }) - ) - const before = state.settings - const result = service.mutateReferences({ - expectedReferenceRevision: 1, - mutation: { - kind: 'source-control-update', - changes: { customAgentCommand: 'generate-with-huge-profile' } - } - }) - expect(result).toMatchObject({ - ok: false, - code: 'agent_reference_payload_too_large', - referenceRevision: 1 - }) - // A rejected budget check performs no write. - expect(state.settings).toBe(before) - }) - - it('still commits a shrinking write while the snapshot is over budget', () => { - const { service, state } = serviceWith( - baseSettings({ - sourceControlAi: sourceControlAiWith({ - commitMessage: 'x'.repeat(600_000), - pullRequest: 'y'.repeat(600_000) - }) - }) - ) - const result = service.mutateReferences({ - expectedReferenceRevision: 1, - mutation: { - kind: 'source-control-update', - changes: { - instructionsByOperation: { commitMessage: '', pullRequest: 'y'.repeat(600_000) } - } - } - }) - // Still > 512 KiB after the write, but smaller than before: the user must be - // able to edit an over-budget profile back under budget. - expect(result.ok).toBe(true) - expect(state.settings.sourceControlAi?.instructionsByOperation?.commitMessage).toBe('') - }) - - it('keeps a small write under budget flowing normally', () => { - const { service } = serviceWith(baseSettings()) - const result = service.mutateReferences({ - expectedReferenceRevision: 1, - mutation: { kind: 'source-control-update', changes: { customAgentCommand: 'ok' } } - }) - expect(result.ok).toBe(true) - }) - - it('budgets the persisted projection, not the pre-persistence patch', () => { - // Persistence derives legacy commitMessageAi from sourceControlAi, so an - // under-budget patch lands as a projection carrying the instructions twice. - const state: StoreStubState = { - settings: baseSettings(), - repos: [], - automations: [], - expandOnPersist: (updates) => - updates.sourceControlAi - ? { - commitMessageAi: { - enabled: true, - agentId: null, - selectedModelByAgent: {}, - selectedThinkingByModel: {}, - customPrompt: updates.sourceControlAi.instructionsByOperation?.commitMessage ?? '', - customAgentCommand: '' - } as GlobalSettings['commitMessageAi'] - } - : {} - } - const service = new AgentCatalogService(makeStoreStub(state)) - const before = state.settings - const result = service.mutateReferences({ - expectedReferenceRevision: 1, - mutation: { - kind: 'source-control-update', - changes: { instructionsByOperation: { commitMessage: 'x'.repeat(300_000) } } - } - }) - expect(result).toMatchObject({ ok: false, code: 'agent_reference_payload_too_large' }) - expect(state.settings).toBe(before) - }) -}) - -describe('durable authoring acknowledgement (P0-2)', () => { - function serviceWith(state: StoreStubState): AgentCatalogService { - const store = makeStoreStub(state) - // Any debounced write here would ack before the bytes are durable. - Object.assign(store, { - updateSettings: () => { - throw new Error('authoring must not use the debounced settings write') - } - }) - return new AgentCatalogService(store) - } - - const createCodex = { - expectedRevision: 1, - mutation: { - kind: 'create' as const, - baseAgent: 'codex' as const, - draft: { label: 'Durable', commandOverride: null, args: '', env: {}, syncEnv: false } - } - } - - it('commits catalog and reference mutations through the durable write path', () => { - const state: StoreStubState = { settings: baseSettings(), repos: [], automations: [] } - const service = serviceWith(state) - expect(service.mutate(createCodex).ok).toBe(true) - expect(state.settings.customTuiAgents).toHaveLength(1) - const referenceResult = service.mutateReferences({ - expectedReferenceRevision: 1, - mutation: { kind: 'source-control-update', changes: { customAgentCommand: 'ok' } } - }) - expect(referenceResult.ok).toBe(true) - }) - - it('reports a typed failure and keeps state unchanged when the durable write fails', () => { - const state: StoreStubState = { - settings: baseSettings(), - repos: [], - automations: [], - failDurableWrite: true - } - const service = serviceWith(state) - const revisions: number[] = [] - service.onDidChange((revision) => revisions.push(revision)) - const before = state.settings - - expect(service.mutate(createCodex)).toEqual({ - ok: false, - code: 'agent_catalog_write_failed', - revision: 1 - }) - expect( - service.mutateReferences({ - expectedReferenceRevision: 1, - mutation: { kind: 'source-control-update', changes: { customAgentCommand: 'ok' } } - }) - ).toEqual({ - ok: false, - code: 'agent_reference_write_failed', - referenceRevision: 1, - catalogRevision: 1 - }) - expect(state.settings).toBe(before) - expect(revisions).toEqual([]) - }) -}) diff --git a/src/main/agent-launch/agent-catalog-service.ts b/src/main/agent-launch/agent-catalog-service.ts index 2762f756ea1..a5fe0194420 100644 --- a/src/main/agent-launch/agent-catalog-service.ts +++ b/src/main/agent-launch/agent-catalog-service.ts @@ -17,7 +17,6 @@ import { AgentCatalogRepairTokenRegistry, applyAgentCatalogMutation } from './agent-catalog-mutations' -import { buildUnreferencedTombstonePrunePatch } from './agent-catalog-tombstone-gc' import { buildAgentCatalogSnapshot, buildLocalAgentCatalogSnapshot, @@ -29,15 +28,16 @@ import { AgentTombstoneReferenceIndex, type AgentReferenceSummary } from './agent-tombstone-reference-index' -import { registerBuiltInOwnerScanners } from './agent-catalog-owner-scanners' import { - buildAgentReferenceSnapshot, - measureAgentReferenceProjection -} from './agent-reference-snapshot-projection' + createBatchTombstoneReferenceCounter, + registerBuiltInOwnerScanners +} from './agent-catalog-owner-scanners' import { agentCatalogMigrationBlockedError, + agentCatalogSchemaTooNewError, isSecurityReducingMutation, - type AgentCatalogMigrationBlockedError + type AgentCatalogMigrationBlockedError, + type AgentCatalogSchemaTooNewError } from './agent-catalog-write-policy' import { computeBaseDisableImpact } from './agent-catalog-base-disable-impact' import { @@ -45,7 +45,7 @@ import { type AgentCatalogWriteFailedError, type AgentReferenceWriteFailedError } from './agent-catalog-durable-write' -import { applyAgentReferenceMutation } from './agent-reference-mutations' +import { AgentReferenceAuthoringService } from './agent-reference-authoring-service' import type { AgentReferenceMutationRequest, AgentReferenceMutationResult, @@ -72,10 +72,25 @@ export function getOrCreateAgentCatalogService(store: Store): AgentCatalogServic export class AgentCatalogService { private readonly repairTokens = new AgentCatalogRepairTokenRegistry() private readonly referenceIndex = new AgentTombstoneReferenceIndex() + // Reads the index live, so scanners registered after construction still count. + private readonly tombstoneReferenceCounter = createBatchTombstoneReferenceCounter( + this.referenceIndex + ) private readonly changeListeners = new Set<(revision: number) => void>() + private readonly references: AgentReferenceAuthoringService constructor(private readonly store: Store) { registerBuiltInOwnerScanners(this.referenceIndex, this.store) + this.references = new AgentReferenceAuthoringService({ + store, + countTombstoneReferences: this.tombstoneReferenceCounter, + catalogRevision: () => this.getRevision(), + publishCatalogRevision: (revision) => { + for (const listener of this.changeListeners) { + listener(revision) + } + } + }) } /** Later units (worktree pending launches, background attempts, orchestration, @@ -96,9 +111,20 @@ export class AgentCatalogService { } getLocalSnapshot(): LocalAgentCatalogSnapshot { - const snapshot = buildLocalAgentCatalogSnapshot(this.store.getSettings(), this.repairTokens) + const base = buildLocalAgentCatalogSnapshot(this.store.getSettings(), this.repairTokens) const blocked = agentCatalogMigrationBlockedError(this.store) - return blocked ? { ...snapshot, migrationBlockedError: blocked.migrationError } : snapshot + const snapshot = blocked ? { ...base, migrationBlockedError: blocked.migrationError } : base + const tooNew = agentCatalogSchemaTooNewError(this.store) + if (!tooNew) { + return snapshot + } + return { + ...snapshot, + schemaTooNew: { + persistedVersion: tooNew.persistedVersion, + supportedVersion: tooNew.supportedVersion + } + } } getRemoteSnapshot(): ReturnType { @@ -167,37 +193,15 @@ export class AgentCatalogService { } getReferenceRevision(): number { - return this.store.getSettings().agentReferenceRevision ?? 1 + return this.references.getRevision() } - /** Remote (runtime RPC) reference snapshot; typed projection error when over - * the 512 KiB frame budget. */ getRemoteReferenceSnapshot(): AgentReferenceSnapshot | AgentReferenceProjectionError { - const settings = this.store.getSettings() - const snapshot = buildAgentReferenceSnapshot(settings) - const { tooLarge } = measureAgentReferenceProjection(settings) - if (tooLarge) { - return { - version: 1, - revision: snapshot.revision, - code: 'agent_reference_payload_too_large', - maxBytes: 524_288 - } - } - return snapshot + return this.references.getRemoteSnapshot() } - /** Uncapped authoring/repair view over local preload IPC only. */ getLocalReferenceSnapshot(): LocalAgentReferenceSnapshot { - const settings = this.store.getSettings() - const snapshot = buildAgentReferenceSnapshot(settings) - const { bytes, tooLarge } = measureAgentReferenceProjection(settings) - return { - ...snapshot, - projection: tooLarge - ? { status: 'too-large', bytes, maxBytes: 524_288 } - : { status: 'ready', bytes, maxBytes: 524_288 } - } + return this.references.getLocalSnapshot() } mutateReferences( @@ -205,107 +209,30 @@ export class AgentCatalogService { ): | AgentReferenceMutationResult | AgentCatalogMigrationBlockedError + | AgentCatalogSchemaTooNewError | AgentReferenceWriteFailedError { - // The failed-backup invariant is "no v1 write": reference mutations stamp - // agentReferenceRevision, so they are blocked alongside catalog mutations. - const blocked = agentCatalogMigrationBlockedError(this.store) - if (blocked) { - return blocked - } - const settings = this.store.getSettings() - const currentReferenceRevision = settings.agentReferenceRevision ?? 1 - const application = applyAgentReferenceMutation({ - settings, - request, - currentReferenceRevision, - catalog: normalizeCatalogFromSettings(settings) - }) - if (!application.ok) { - return { - ok: false, - code: application.code, - referenceRevision: currentReferenceRevision, - catalogRevision: this.getRevision(), - ...(application.code === 'reference_revision_conflict' - ? { snapshot: this.getLocalReferenceSnapshot() } - : {}), - ...(application.owner ? { owner: application.owner } : {}), - ...(application.field ? { field: application.field } : {}), - ...(application.reason ? { reason: application.reason } : {}) - } - } - // The 512 KiB remote-projection budget is checked on the post-mutation - // snapshot; a non-growing write still commits so an over-budget profile can - // always be edited back under budget (mirrors mutate()'s security-reducing - // allowlist). Why preview: persistence normalizes the patch and derives - // legacy commitMessageAi from sourceControlAi, so the patch as written is - // smaller than the projection that actually ships. - const projected = measureAgentReferenceProjection( - this.store.previewSettingsUpdate(application.patch) - ) - if (projected.tooLarge && projected.bytes > measureAgentReferenceProjection(settings).bytes) { - return { - ok: false, - code: 'agent_reference_payload_too_large', - referenceRevision: currentReferenceRevision, - catalogRevision: this.getRevision() - } - } - // Owner change commits before any prune; a failure between the two leaves - // the tombstone conservatively retained for the next indexed recheck. - if (!commitAuthoringPatchDurable(this.store, application.patch)) { - return { - ok: false, - code: 'agent_reference_write_failed', - referenceRevision: currentReferenceRevision, - catalogRevision: this.getRevision() - } - } - this.pruneUnreferencedTombstonesAfterReferenceRemoval() - return { - ok: true, - referenceRevision: application.newReferenceRevision, - catalogRevision: this.getRevision(), - snapshot: this.getLocalReferenceSnapshot() - } - } - - /** Reference-aware prune run after a reference removal; a prune advances and - * publishes the catalog revision so receivers replace their snapshot. */ - private pruneUnreferencedTombstonesAfterReferenceRemoval(): void { - const settings = this.store.getSettings() - // The patch also strips any persisted row the pruned tombstone suppressed, - // so the prune can never resurrect it. - const prunePatch = buildUnreferencedTombstonePrunePatch( - settings, - normalizeCatalogFromSettings(settings), - (id) => this.referenceIndex.countReferences(id) - ) - if (!prunePatch) { - return - } - const newRevision = (settings.agentCatalogRevision ?? 1) + 1 - // GC only: a rolled-back prune keeps the tombstone retained, and the next - // indexed recheck retries it — the reference mutation is already durable. - if ( - !commitAuthoringPatchDurable(this.store, { ...prunePatch, agentCatalogRevision: newRevision }) - ) { - return - } - for (const listener of this.changeListeners) { - listener(newRevision) - } + return this.references.mutate(request) } mutate( request: AgentCatalogMutationRequest - ): AgentCatalogMutationResult | AgentCatalogMigrationBlockedError | AgentCatalogWriteFailedError { + ): + | AgentCatalogMutationResult + | AgentCatalogMigrationBlockedError + | AgentCatalogSchemaTooNewError + | AgentCatalogWriteFailedError { // A failed pinned pre-v1 backup means no v1 write may land on the profile; // fail closed here so authoring cannot bypass the migration invariant. const blocked = agentCatalogMigrationBlockedError(this.store) if (blocked) { return blocked } + // Persistence refuses the write anyway; reporting it up front keeps a + // non-retryable read-only profile from looking like a disk failure. + const tooNew = agentCatalogSchemaTooNewError(this.store) + if (tooNew) { + return tooNew + } const settings = this.store.getSettings() const currentRevision = settings.agentCatalogRevision ?? 1 const application = applyAgentCatalogMutation({ @@ -313,7 +240,7 @@ export class AgentCatalogService { request, currentRevision, repairTokens: this.repairTokens, - countTombstoneReferences: (id) => this.referenceIndex.countReferences(id) + countTombstoneReferences: this.tombstoneReferenceCounter }) if (!application.ok) { return { diff --git a/src/main/agent-launch/agent-catalog-tombstone-prune-batching.test.ts b/src/main/agent-launch/agent-catalog-tombstone-prune-batching.test.ts new file mode 100644 index 00000000000..70219ed1150 --- /dev/null +++ b/src/main/agent-launch/agent-catalog-tombstone-prune-batching.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from 'vitest' +import type { CustomTuiAgentId, DeletedCustomTuiAgent } from '../../shared/types' +import { pruneTombstones, type TombstoneReferenceCounter } from './agent-catalog-tombstone-gc' + +const UUID_A = '01234567-89ab-4cde-8f01-23456789abcd' +const UUID_B = 'fedcba98-7654-4321-8fed-cba987654321' + +function customId(base: string, uuid = UUID_A): CustomTuiAgentId { + return `custom-agent:${base}:${uuid}` as CustomTuiAgentId +} + +function sequentialId(index: number): CustomTuiAgentId { + return customId('codex', `${index}`.padStart(8, '0') + UUID_A.slice(8)) +} + +function tombstonesFor(ids: readonly CustomTuiAgentId[]): DeletedCustomTuiAgent[] { + return ids.map((id, index) => ({ id, baseAgent: 'codex', label: `T${index}`, deletedAt: index })) +} + +type OwnerFixture = { referencedIds: readonly string[]; readable: boolean } + +/** Pre-fix shape: one full owner sweep per tombstone. */ +function perIdCounter(owners: readonly OwnerFixture[], sweeps: { count: number }) { + return (id: CustomTuiAgentId): number | 'unknown' => { + let total = 0 + for (const owner of owners) { + sweeps.count += 1 + if (!owner.readable) { + return 'unknown' + } + total += owner.referencedIds.filter((value) => value === id).length + } + return total + } +} + +/** Post-fix shape: owners indexed once, then a single pass over tombstones. */ +function batchCounter( + owners: readonly OwnerFixture[], + sweeps: { count: number } +): TombstoneReferenceCounter { + return { + countForIds: (ids) => { + const wanted = new Set(ids) + const matched = new Map() + let complete = true + for (const owner of owners) { + sweeps.count += 1 + if (!owner.readable) { + complete = false + continue + } + for (const value of owner.referencedIds) { + if (wanted.has(value)) { + matched.set(value, (matched.get(value) ?? 0) + 1) + } + } + } + const counts = new Map() + for (const id of ids) { + counts.set(id, complete ? (matched.get(id) ?? 0) : 'unknown') + } + return counts + } + } +} + +describe('pruneTombstones batch counting (P1-9)', () => { + const unreferenced = customId('codex', UUID_A) + const referencedOnce = customId('claude', UUID_B) + const referencedTwice = customId('gemini', UUID_A) + // Mixed: unreferenced (twice over, so a duplicate id is covered), single and + // multi reference, plus owner values that match no tombstone. + const mixed = [unreferenced, referencedOnce, referencedTwice, unreferenced] + + const owners: OwnerFixture[] = [ + { referencedIds: [referencedOnce, 'auto'], readable: true }, + { referencedIds: [], readable: true }, + { referencedIds: [referencedTwice, referencedTwice, 'claude'], readable: true } + ] + + it('produces identical retain/prune decisions for both counter shapes', () => { + const tombstones = tombstonesFor(mixed) + const perId = pruneTombstones(tombstones, perIdCounter(owners, { count: 0 })) + const batch = pruneTombstones(tombstones, batchCounter(owners, { count: 0 })) + expect(batch).toEqual(perId) + expect(perId.prunedIds).toEqual([unreferenced, unreferenced]) + expect(perId.retained.map((entry) => entry.id)).toEqual([referencedOnce, referencedTwice]) + }) + + it('retains everything when an owner is unreadable, under both counter shapes', () => { + const tombstones = tombstonesFor(mixed) + const withFailure = [...owners, { referencedIds: [], readable: false }] + const perId = pruneTombstones(tombstones, perIdCounter(withFailure, { count: 0 })) + const batch = pruneTombstones(tombstones, batchCounter(withFailure, { count: 0 })) + expect(batch).toEqual(perId) + expect(perId.prunedIds).toEqual([]) + expect(perId.retained).toHaveLength(mixed.length) + }) + + it('retains a tombstone the counter omits rather than treating it as unreferenced', () => { + const tombstones = tombstonesFor([unreferenced]) + const result = pruneTombstones(tombstones, { countForIds: () => new Map() }) + expect(result.prunedIds).toEqual([]) + expect(result.retained).toEqual(tombstones) + }) + + it('sweeps every owner once for the whole batch instead of once per tombstone', () => { + const tombstones = tombstonesFor(Array.from({ length: 200 }, (_, index) => sequentialId(index))) + const batchSweeps = { count: 0 } + pruneTombstones(tombstones, batchCounter(owners, batchSweeps)) + expect(batchSweeps.count).toBe(owners.length) + + const perIdSweeps = { count: 0 } + pruneTombstones(tombstones, perIdCounter(owners, perIdSweeps)) + expect(perIdSweeps.count).toBe(owners.length * tombstones.length) + }) + + it('stays linear on a 3,500-tombstone catalog', () => { + const ids = Array.from({ length: 3500 }, (_, index) => sequentialId(index)) + const bigOwner: OwnerFixture[] = [{ referencedIds: ids.slice(0, 1750), readable: true }] + const started = performance.now() + const result = pruneTombstones(tombstonesFor(ids), batchCounter(bigOwner, { count: 0 })) + expect(performance.now() - started).toBeLessThan(150) + expect(result.prunedIds).toHaveLength(1750) + expect(result.retained).toHaveLength(1750) + }) +}) diff --git a/src/main/agent-launch/agent-catalog-write-policy.ts b/src/main/agent-launch/agent-catalog-write-policy.ts index f35dbfdc459..976d0f96a74 100644 --- a/src/main/agent-launch/agent-catalog-write-policy.ts +++ b/src/main/agent-launch/agent-catalog-write-policy.ts @@ -3,6 +3,7 @@ // that blocks every v1 write while the pinned pre-v1 backup is failing. import type { AgentCatalogMutationRequest } from '../../shared/agent-catalog-snapshot' +import type { AgentCatalogSchemaTooNew } from '../../shared/data-recovery' /** Mutations that reduce risk/size and stay allowed while a payload budget is * already exceeded; they must never add arbitrary user text or a reference. */ @@ -44,3 +45,28 @@ export function agentCatalogMigrationBlockedError(store: { } return { ok: false, code: 'agent_catalog_migration_blocked', migrationError } } + +/** Returned while the persisted catalog schema is newer than this build: authoring + * would clobber fields this build cannot represent, and nothing here is retryable + * — only a newer Orca clears it. */ +export type AgentCatalogSchemaTooNewError = { + ok: false + code: 'agent_catalog_schema_too_new' + persistedVersion: number + supportedVersion: number +} + +export function agentCatalogSchemaTooNewError(store: { + getAgentCatalogSchemaTooNew(): AgentCatalogSchemaTooNew | null +}): AgentCatalogSchemaTooNewError | null { + const schemaTooNew = store.getAgentCatalogSchemaTooNew() + if (schemaTooNew === null) { + return null + } + return { + ok: false, + code: 'agent_catalog_schema_too_new', + persistedVersion: schemaTooNew.persistedVersion, + supportedVersion: schemaTooNew.supportedVersion + } +} diff --git a/src/main/agent-launch/agent-launch-host-state.test.ts b/src/main/agent-launch/agent-launch-host-state.test.ts index 7ecd70dd33a..9b2c557e285 100644 --- a/src/main/agent-launch/agent-launch-host-state.test.ts +++ b/src/main/agent-launch/agent-launch-host-state.test.ts @@ -220,14 +220,23 @@ describe('describeSpawnExecutionHost', () => { }) // A WSL UNC cwd runs a Linux userland; win32 here picks the Windows executable - // variant and PowerShell-quotes a bash command line. - it('describes a WSL UNC cwd as a linux local target with no Windows shell', () => { + // variant and PowerShell-quotes a bash command line. Classifying it 'local' + // would also host-id it `local`, leaving UNC/drive paths in the Linux argv. + it('describes a WSL UNC cwd as its own wsl host with no Windows shell', () => { const descriptor = describeSpawnExecutionHost({ connectionId: null, cwd: '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo', terminalWindowsShell: 'powershell' }) - expect(descriptor).toEqual({ kind: 'local', platform: 'linux' }) + expect(descriptor).toEqual({ kind: 'wsl', distro: 'Ubuntu' }) + expect(platformForDescriptor(descriptor)).toBe('linux') + expect(executionHostIdForDescriptor(descriptor)).toBe('wsl:Ubuntu') + }) + + it('describes a legacy \\\\wsl$ UNC cwd as the same wsl host', () => { + expect( + describeSpawnExecutionHost({ connectionId: null, cwd: '\\\\wsl$\\Debian\\srv\\app' }) + ).toEqual({ kind: 'wsl', distro: 'Debian' }) }) }) diff --git a/src/main/agent-launch/agent-launch-operation-store.test.ts b/src/main/agent-launch/agent-launch-operation-store.test.ts index fc023e9ed2b..2482f7909eb 100644 --- a/src/main/agent-launch/agent-launch-operation-store.test.ts +++ b/src/main/agent-launch/agent-launch-operation-store.test.ts @@ -7,6 +7,7 @@ import type { AgentLaunchSnapshot } from '../../shared/agent-launch-host-contrac import { AgentLaunchOperationStore, MAX_SETTLED_OPERATIONS_PER_SCOPE, + MAX_SETTLED_OPERATION_SCOPES, agentLaunchIdempotencyKey, canonicalPayloadDigest, mintAgentLaunchOperationId, @@ -225,3 +226,57 @@ describe('settled ledger', () => { expect(bucket.at(-1)?.operationId).toBe(`op-${MAX_SETTLED_OPERATIONS_PER_SCOPE + 2}`) }) }) + +describe('settled scope bound', () => { + it('evicts the least-recently-settled scopes past the global bound', () => { + const store = new AgentLaunchOperationStore() + const overflow = 5 + for (let index = 0; index < MAX_SETTLED_OPERATION_SCOPES + overflow; index += 1) { + store.recordSettled( + settled({ scope: `wt-${index}`, operationId: `op-${index}`, idempotencyKey: `k-${index}` }) + ) + } + for (let index = 0; index < overflow; index += 1) { + expect(store.settledForScope(`wt-${index}`)).toHaveLength(0) + } + expect(store.settledForScope(`wt-${overflow}`)).toHaveLength(1) + expect(store.settledForScope(`wt-${MAX_SETTLED_OPERATION_SCOPES + overflow - 1}`)).toHaveLength( + 1 + ) + expect(store.durableState().settled).toHaveLength(MAX_SETTLED_OPERATION_SCOPES) + }) + + it('re-settling an old scope keeps it alive as newest', () => { + const store = new AgentLaunchOperationStore() + store.recordSettled( + settled({ scope: 'wt-old', operationId: 'op-old', idempotencyKey: 'k-old' }) + ) + for (let index = 0; index < MAX_SETTLED_OPERATION_SCOPES; index += 1) { + store.recordSettled( + settled({ scope: `wt-${index}`, operationId: `op-${index}`, idempotencyKey: `k-${index}` }) + ) + // Touching wt-old on every append keeps it out of the eviction window. + store.recordSettled( + settled({ scope: 'wt-old', operationId: `op-old-${index}`, idempotencyKey: `k-o-${index}` }) + ) + } + expect(store.settledForScope('wt-old').length).toBeGreaterThan(0) + }) + + it('never evicts a scope an in-flight launch still needs', () => { + const store = new AgentLaunchOperationStore() + // The pending launch lands FIRST, so its scope is the oldest by insertion — + // exactly the entry a naive oldest-first eviction would drop. + store.recordSettled( + settled({ scope: 'wt-live', operationId: 'op-live', idempotencyKey: 'k-live' }) + ) + store.beginPending(pending({ scope: 'wt-live', launchToken: 'token-live' })) + for (let index = 0; index < MAX_SETTLED_OPERATION_SCOPES + 10; index += 1) { + store.recordSettled( + settled({ scope: `wt-${index}`, operationId: `op-${index}`, idempotencyKey: `k-${index}` }) + ) + } + expect(store.settledForScope('wt-live')).toHaveLength(1) + expect(store.findSettledByIdempotencyKey('wt-live', 'k-live')?.operationId).toBe('op-live') + }) +}) diff --git a/src/main/agent-launch/agent-launch-reconcile-runtime-deps.test.ts b/src/main/agent-launch/agent-launch-reconcile-runtime-deps.test.ts index d95d7206d6b..cc1e6c8bb20 100644 --- a/src/main/agent-launch/agent-launch-reconcile-runtime-deps.test.ts +++ b/src/main/agent-launch/agent-launch-reconcile-runtime-deps.test.ts @@ -80,6 +80,10 @@ function buildDeps( operationStore: store, liveTerminalByToken: overrides.liveTerminalByToken ?? (() => null), isHostAuthoritative: overrides.isHostAuthoritative ?? ((id) => id === 'local'), + isHostTokenAuthoritative: overrides.isHostTokenAuthoritative ?? (() => true), + ...(overrides.identifyLaunchWithoutTokenEcho + ? { identifyLaunchWithoutTokenEcho: overrides.identifyLaunchWithoutTokenEcho } + : {}), expectedWorktreeId: overrides.expectedWorktreeId ?? ((p) => p.scope), arms: overrides.arms ?? { worktree: noopArm, @@ -244,6 +248,114 @@ describe('buildReconcileAgentLaunchDeps liveness', () => { expect(arm.calls).toEqual(['failed']) }) + it('settles absent on a missing token echo when the host echoes launch tokens', () => { + const arm = spyArm() + const { store, deps } = buildDeps({ + isHostAuthoritative: () => true, + isHostTokenAuthoritative: () => true, + identifyLaunchWithoutTokenEcho: () => 'inconclusive', + arms: { + worktree: () => arm, + automation: () => arm, + orchestration: () => arm, + background: () => arm + } + }) + const entry = pending({ launchToken: 'token-v34' }, 'ssh:host-a') + store.beginPending(entry) + + const outcome = reconcileOnePendingAgentLaunch(deps, entry) + + expect(outcome).toEqual({ kind: 'spawn_failed' }) + expect(arm.calls).toEqual(['failed']) + }) + + it('never settles absent on a missing echo from a host that cannot echo tokens', () => { + // Regression (P1-4): a pre-v34 daemon / old relay accepts launchToken and + // drops it, so its listing can never echo one. Settling spawn_failed here + // would let Retry spawn a DUPLICATE beside the agent it is still running. + const arm = spyArm() + const { store, deps } = buildDeps({ + isHostAuthoritative: () => true, + isHostTokenAuthoritative: () => false, + identifyLaunchWithoutTokenEcho: () => 'inconclusive', + arms: { + worktree: () => arm, + automation: () => arm, + orchestration: () => arm, + background: () => arm + } + }) + const entry = pending({ launchToken: 'token-legacy' }, 'ssh:host-a') + store.beginPending(entry) + + const outcome = reconcileOnePendingAgentLaunch(deps, entry) + + expect(outcome).toEqual({ kind: 'launch_state_unknown' }) + expect(arm.calls).toEqual(['unknown']) + // Held, not settled: the reservation survives and Retry stays gated. + expect(store.getPending('token-legacy')).not.toBeNull() + }) + + it('holds a non-token-authoritative launch pending when no fallback is wired', () => { + const arm = spyArm() + const { store, deps } = buildDeps({ + isHostAuthoritative: () => true, + isHostTokenAuthoritative: () => false, + arms: { + worktree: () => arm, + automation: () => arm, + orchestration: () => arm, + background: () => arm + } + }) + const entry = pending({ launchToken: 'token-nofallback' }, 'ssh:host-a') + store.beginPending(entry) + + expect(reconcileOnePendingAgentLaunch(deps, entry)).toEqual({ kind: 'launch_state_unknown' }) + expect(store.getPending('token-nofallback')).not.toBeNull() + }) + + it('settles absent on a non-echoing host once the fallback proves the terminal gone', () => { + const arm = spyArm() + const { store, deps } = buildDeps({ + isHostAuthoritative: () => true, + isHostTokenAuthoritative: () => false, + identifyLaunchWithoutTokenEcho: () => 'absent', + arms: { + worktree: () => arm, + automation: () => arm, + orchestration: () => arm, + background: () => arm + } + }) + const entry = pending({ launchToken: 'token-proven' }, 'ssh:host-a') + store.beginPending(entry) + + expect(reconcileOnePendingAgentLaunch(deps, entry)).toEqual({ kind: 'spawn_failed' }) + expect(arm.calls).toEqual(['failed']) + }) + + it('keeps a live token match launched even on a non-token-authoritative host', () => { + const arm = spyArm() + const { store, deps } = buildDeps({ + liveTerminalByToken: () => ({ ptyId: 'term-live', worktreeId: 'wt-1' }), + isHostAuthoritative: () => true, + isHostTokenAuthoritative: () => false, + identifyLaunchWithoutTokenEcho: () => 'absent', + arms: { + worktree: () => arm, + automation: () => arm, + orchestration: () => arm, + background: () => arm + } + }) + const entry = pending({ launchToken: 'token-live' }, 'ssh:host-a') + store.beginPending(entry) + + expect(reconcileOnePendingAgentLaunch(deps, entry)).toEqual({ kind: 'launched' }) + }) + it('routes a background pending to the background store keyed by attempt id', () => { const background = new BackgroundAgentLaunchStore({ now: () => 1000 }) background.create({ diff --git a/src/main/agent-launch/agent-launch-reconcile-runtime-deps.ts b/src/main/agent-launch/agent-launch-reconcile-runtime-deps.ts index fc6b0019eb8..940811dd9cc 100644 --- a/src/main/agent-launch/agent-launch-reconcile-runtime-deps.ts +++ b/src/main/agent-launch/agent-launch-reconcile-runtime-deps.ts @@ -15,6 +15,13 @@ // until its own terminal-list/reconnect event re-probes. `isHostAuthoritative` // encodes which hosts a given reconcile pass can speak for, so a daemon/SSH // survivor is never falsely settled `absent` before its provider reconnects. +// - Listing authority is ANDed with TOKEN authority: a peer that predates the +// launch-token echo (pre-v34 daemon, old SSH relay) accepts the token on +// create and silently drops it, so its listing can NEVER carry one. Reading +// that missing echo as absence settles spawn_failed for a live agent and the +// user's Retry then spawns a duplicate beside it. Such hosts fall back to the +// pre-launchToken identification, and hold the launch pending when that is +// inconclusive. import type { AgentLaunchExecutionHostId } from '../../shared/agent-launch-host-contract' import { @@ -41,6 +48,11 @@ import type { * the launch's own terminal is alive, so it must never resolve `absent`. */ export type LiveTerminalForToken = { ptyId: string; worktreeId: string | null } +/** Verdict of the pre-launchToken identification used for hosts that cannot echo + * tokens: `absent` only when the re-listed terminals PROVE the launch's terminal + * is gone. Anything less is `inconclusive` and holds the launch pending. */ +export type TokenlessLaunchLiveness = 'absent' | 'inconclusive' + export type ReconcileRuntimeDeps = { operationStore: AgentLaunchOperationStore /** The live terminal holding a launch token, or null if none is live. */ @@ -48,6 +60,12 @@ export type ReconcileRuntimeDeps = { /** Whether a non-live pending's host can be spoken for authoritatively in this * reconcile pass (→ `absent`); false leaves it `unknown`. */ isHostAuthoritative: (executionHostId: AgentLaunchExecutionHostId) => boolean + /** Whether a MISSING launchToken in this host's listing is absence proof. False + * for peers that predate the echo and drop the token they were handed. */ + isHostTokenAuthoritative: (executionHostId: AgentLaunchExecutionHostId) => boolean + /** Pre-launchToken identification, consulted only for non-token-authoritative + * hosts. Omitted (or `inconclusive`) holds the launch pending. */ + identifyLaunchWithoutTokenEcho?: (pending: PendingAgentLaunchSnapshot) => TokenlessLaunchLiveness /** The worktree a live token must belong to for attribution: the scope for a * worktree launch, the attempt's worktree for a background launch, or null when * the intent has no worktree to compare (attribution then trusts the token). */ @@ -105,7 +123,17 @@ function resolveLiveness( } } const host = pending.snapshot.target.executionHostId - return deps.isHostAuthoritative(host) ? { kind: 'absent' } : { kind: 'unknown' } + if (!deps.isHostAuthoritative(host)) { + return { kind: 'unknown' } + } + if (!deps.isHostTokenAuthoritative(host)) { + // A peer that never echoes tokens can only be identified the pre-token way; + // absent needs positive proof there, or Retry duplicates a running agent. + return deps.identifyLaunchWithoutTokenEcho?.(pending) === 'absent' + ? { kind: 'absent' } + : { kind: 'unknown' } + } + return { kind: 'absent' } } export function buildReconcileAgentLaunchDeps( diff --git a/src/main/agent-launch/agent-launch-spawn.test.ts b/src/main/agent-launch/agent-launch-spawn.test.ts index 361377a802d..357eff7558b 100644 --- a/src/main/agent-launch/agent-launch-spawn.test.ts +++ b/src/main/agent-launch/agent-launch-spawn.test.ts @@ -182,6 +182,94 @@ describe('resolveAgentLaunchSpawn', () => { }) }) + // P1-24: a "Don't save" launch sends the edited args with the locator; the + // stored recipe snapshot is stale and must not win. + it('prefers client unsaved args over the recipe stored agentArgs (P1-24)', async () => { + const resolve = vi.fn((_request: ResolveAgentLaunchRequest) => ({ + ok: true as const, + launch: makeLaunch() + })) + const deps = makeDeps(resolve) + await resolveAgentLaunchSpawn( + deps, + baseInput({ + request: { + selection: { kind: 'agent', agent: 'claude' }, + prompt: 'x', + sourceRecord: { owner: 'source-control-recipe', id: 'fixChecks' }, + unsavedAgentArgs: '--edited two' + }, + recipeRepo: { + sourceControlAi: { actionOverrides: { fixChecks: { agentArgs: '--recipe one' } } } + } + }) + ) + expect(resolve.mock.calls[0]![0].perLaunchArgs).toBe('--edited two') + }) + + it('treats empty unsaved args as a real "no args" edit (P1-24)', async () => { + const resolve = vi.fn((_request: ResolveAgentLaunchRequest) => ({ + ok: true as const, + launch: makeLaunch() + })) + const deps = makeDeps(resolve) + await resolveAgentLaunchSpawn( + deps, + baseInput({ + request: { + selection: { kind: 'agent', agent: 'claude' }, + prompt: 'x', + sourceRecord: { owner: 'source-control-recipe', id: 'fixChecks' }, + unsavedAgentArgs: '' + }, + recipeRepo: { + sourceControlAi: { actionOverrides: { fixChecks: { agentArgs: '--recipe one' } } } + } + }) + ) + expect(resolve.mock.calls[0]![0].perLaunchArgs).toBe('') + }) + + it('ignores unsaved args without a source-control-recipe owner (P1-24)', async () => { + const resolve = vi.fn((_request: ResolveAgentLaunchRequest) => ({ + ok: true as const, + launch: makeLaunch() + })) + const deps = makeDeps(resolve) + await resolveAgentLaunchSpawn( + deps, + baseInput({ + request: { + selection: { kind: 'agent', agent: 'claude' }, + prompt: 'x', + unsavedAgentArgs: '--sneaky' + } + }) + ) + expect('perLaunchArgs' in resolve.mock.calls[0]![0]).toBe(false) + }) + + it('still rejects an unknown recipe id even with unsaved args (P1-24)', async () => { + const resolve = vi.fn((_request: ResolveAgentLaunchRequest) => ({ + ok: true as const, + launch: makeLaunch() + })) + const deps = makeDeps(resolve) + const result = await resolveAgentLaunchSpawn( + deps, + baseInput({ + request: { + selection: { kind: 'agent', agent: 'claude' }, + prompt: 'x', + sourceRecord: { owner: 'source-control-recipe', id: 'not-a-real-action' }, + unsavedAgentArgs: '--edited' + } + }) + ) + expect(result).toEqual({ ok: false, requestError: { code: 'untrusted_reference' } }) + expect(resolve).not.toHaveBeenCalled() + }) + it('rejects an unknown recipe action id with untrusted_reference and never resolves (U7)', async () => { const resolve = vi.fn((_request: ResolveAgentLaunchRequest) => ({ ok: true as const, diff --git a/src/main/agent-launch/agent-launch-spawn.ts b/src/main/agent-launch/agent-launch-spawn.ts index 9a7089c402d..3e63e9c3626 100644 --- a/src/main/agent-launch/agent-launch-spawn.ts +++ b/src/main/agent-launch/agent-launch-spawn.ts @@ -130,7 +130,10 @@ function referenceFor(request: AgentLaunchSpawnRequest): AgentReferenceAuthority * a source-control-recipe owner contributes args today: the host validates the id * is a real action id (unknown/mismatched → untrusted_reference, no PTY), then * reads the recipe's stored agentArgs from repo-scoped settings (global fallback - * when the repo id is absent). Clients never send args — only the recipe id. */ + * when the repo id is absent). A client may substitute UNSAVED edits of those + * args (`unsavedAgentArgs`) for this one launch — still bounded text the resolver + * tokenizes itself, never a resolved argv — and the stored recipe stays the + * fallback. Clients never send anything else about the launch. */ function resolvePerLaunchArgs( request: AgentLaunchSpawnRequest, recipeRepo: Pick | null | undefined, @@ -143,6 +146,11 @@ function resolvePerLaunchArgs( if (!sourceRecord.id || !isSourceControlActionId(sourceRecord.id)) { return { ok: false, requestError: { code: 'untrusted_reference' } } } + // Why: the unsaved edit IS the args the user launched with; the stored recipe + // is a stale snapshot of them. An empty string is a real "no args" edit. + if (request.unsavedAgentArgs !== undefined) { + return { ok: true, perLaunchArgs: request.unsavedAgentArgs } + } const recipe = resolveSourceControlActionRecipe({ settings, repo: recipeRepo, diff --git a/src/main/agent-launch/agent-launch-worktree-resolution.test.ts b/src/main/agent-launch/agent-launch-worktree-resolution.test.ts index 0845ea813eb..66769d3f3ca 100644 --- a/src/main/agent-launch/agent-launch-worktree-resolution.test.ts +++ b/src/main/agent-launch/agent-launch-worktree-resolution.test.ts @@ -231,6 +231,44 @@ describe('two-stage worktree agent-launch resolution', () => { expect(store.pendingForWorktree('attempt-1')).toBe(0) }) + it('frees the hold when a stage-2 host read rejects, so repeated races keep capacity', async () => { + const resolve = vi + .fn<(request: ResolveAgentLaunchRequest) => ResolveAgentLaunchOutcome>() + .mockReturnValue({ ok: true, launch: makeLaunch('fp-real', 'sd-1', '/wt-real') }) + const { deps, store } = makeSetup(resolve) + + // More rounds than MAX_PENDING_LAUNCHES_PER_PRINCIPAL: a leaked hold per round + // exhausts the principal's capacity until restart. + for (let round = 0; round < 70; round++) { + deps.resolveTargetHomePath = async () => '/home/dev' + const prepared = await prepareWorktreeAgentLaunch(deps, CONTEXT, { + repoPath: '/repo', + worktreePath: '/wt-provisional' + }) + expect(prepared.ok).toBe(true) + if (!prepared.ok) { + return + } + // The SSH connection drops between git creating the worktree and stage 2. + deps.resolveTargetHomePath = async () => { + throw new Error('ssh channel closed') + } + const executed = await executeWorktreeAgentLaunch( + deps, + CONTEXT, + { repoPath: '/repo', worktreePath: '/wt-real' }, + { reservationId: prepared.reservationId, expectedStableInputDigest: 'sd-1' } + ) + expect(executed.ok).toBe(false) + if (executed.ok) { + return + } + expect('failure' in executed && executed.failure.code).toBe('spawn_failed') + expect(store.pendingForPrincipal(LOCAL)).toBe(0) + expect(store.pendingCount()).toBe(0) + } + }) + it('takes no reservation when pre-git resolution fails', async () => { const resolve = vi .fn<(request: ResolveAgentLaunchRequest) => ResolveAgentLaunchOutcome>() diff --git a/src/main/agent-launch/agent-launch-worktree-resolution.ts b/src/main/agent-launch/agent-launch-worktree-resolution.ts index 88814241a41..a5449ca7a23 100644 --- a/src/main/agent-launch/agent-launch-worktree-resolution.ts +++ b/src/main/agent-launch/agent-launch-worktree-resolution.ts @@ -136,7 +136,7 @@ export async function prepareWorktreeAgentLaunch( /** Stage 2: with the authoritative worktree path and the pinned reservation, * re-resolve, recheck the config-only digest, and convert the reservation into - * a startup plan + receipt (or release it on any failure). Creates no PTY: the + * a startup plan + receipt (or release it on any rejection). Creates no PTY: the * caller persists the pending record, then spawns and settles. */ export async function executeWorktreeAgentLaunch( deps: WorktreeAgentLaunchDeps, @@ -144,50 +144,60 @@ export async function executeWorktreeAgentLaunch( authoritativePaths: { repoPath: string | null; worktreePath: string | null }, reservation: { reservationId: string; expectedStableInputDigest: string } ): Promise { - const hostState = await deriveAgentLaunchHostState( - { - getSettings: deps.getSettings, - getCatalogRevision: deps.getCatalogRevision, - detectStockBaseAgents: deps.detectStockBaseAgents, - resolveTargetHomePath: deps.resolveTargetHomePath, - ...(deps.resolveTransportConfidentiality - ? { resolveTransportConfidentiality: deps.resolveTransportConfidentiality } - : {}) - }, - context.descriptor, - authoritativePaths - ) - const resolve = buildHostStateResolve(toSpawnDeps(deps), { - request: context.request, - intent: context.intent, - target: hostState.target, - variables: hostState.variables, - scope: context.scope, - principal: context.principal - }) - return deps.boundary.executeReservedAgentLaunch({ - scope: context.scope, - // Stage 2 holds the authoritative worktree; a background intent names it - // even when the caller did not thread context.worktreeId explicitly. - worktreeId: - context.worktreeId !== undefined - ? context.worktreeId - : context.intent.kind === 'background' - ? context.intent.worktreeId - : null, - principal: context.principal, - resolve, - prompt: context.request.prompt ?? '', - ...(context.request.allowEmptyPromptLaunch !== undefined - ? { allowEmptyPromptLaunch: context.request.allowEmptyPromptLaunch } - : {}), - ...(context.request.promptDelivery !== undefined - ? { promptDelivery: context.request.promptDelivery } - : {}), - maxInlineDraftChars: STARTUP_COMMAND_TEXT_MAX_CHARS, - ...(deps.markWorkspaceTrusted ? { preflight: deps.markWorkspaceTrusted } : {}), - ...(deps.prepareEnv ? { prepareEnv: deps.prepareEnv } : {}), - reservationId: reservation.reservationId, - expectedStableInputDigest: reservation.expectedStableInputDigest - }) + try { + const hostState = await deriveAgentLaunchHostState( + { + getSettings: deps.getSettings, + getCatalogRevision: deps.getCatalogRevision, + detectStockBaseAgents: deps.detectStockBaseAgents, + resolveTargetHomePath: deps.resolveTargetHomePath, + ...(deps.resolveTransportConfidentiality + ? { resolveTransportConfidentiality: deps.resolveTransportConfidentiality } + : {}) + }, + context.descriptor, + authoritativePaths + ) + const resolve = buildHostStateResolve(toSpawnDeps(deps), { + request: context.request, + intent: context.intent, + target: hostState.target, + variables: hostState.variables, + scope: context.scope, + principal: context.principal + }) + return await deps.boundary.executeReservedAgentLaunch({ + scope: context.scope, + // Stage 2 holds the authoritative worktree; a background intent names it + // even when the caller did not thread context.worktreeId explicitly. + worktreeId: + context.worktreeId !== undefined + ? context.worktreeId + : context.intent.kind === 'background' + ? context.intent.worktreeId + : null, + principal: context.principal, + resolve, + prompt: context.request.prompt ?? '', + ...(context.request.allowEmptyPromptLaunch !== undefined + ? { allowEmptyPromptLaunch: context.request.allowEmptyPromptLaunch } + : {}), + ...(context.request.promptDelivery !== undefined + ? { promptDelivery: context.request.promptDelivery } + : {}), + maxInlineDraftChars: STARTUP_COMMAND_TEXT_MAX_CHARS, + ...(deps.markWorkspaceTrusted ? { preflight: deps.markWorkspaceTrusted } : {}), + ...(deps.prepareEnv ? { prepareEnv: deps.prepareEnv } : {}), + reservationId: reservation.reservationId, + expectedStableInputDigest: reservation.expectedStableInputDigest + }) + } catch { + // The stage-2 host reads (SSH/WSL detection + home probe) happen before the + // boundary's guarded release path, so a disconnect rejecting here would + // strand the pre-git hold until restart and repeated races would exhaust + // capacity. Releasing is idempotent, so a hold the boundary already freed is + // unaffected. + deps.boundary.releaseReservedAgentLaunch(reservation.reservationId) + return { ok: false, failure: { code: 'spawn_failed' } } + } } diff --git a/src/main/agent-launch/agent-reference-authoring-service.ts b/src/main/agent-launch/agent-reference-authoring-service.ts new file mode 100644 index 00000000000..15c5804a182 --- /dev/null +++ b/src/main/agent-launch/agent-reference-authoring-service.ts @@ -0,0 +1,182 @@ +// Reference-side authoring authority (owner references over agent tombstones): +// snapshots, the reference mutation path, and the reference-driven tombstone GC. +// Split out of AgentCatalogService, which composes it and delegates. + +import type { Store } from '../persistence' +import { buildUnreferencedTombstonePrunePatch } from './agent-catalog-tombstone-gc' +import { normalizeCatalogFromSettings } from './agent-catalog-projections' +import { + buildAgentReferenceSnapshot, + measureAgentReferenceProjection +} from './agent-reference-snapshot-projection' +import { + agentCatalogMigrationBlockedError, + agentCatalogSchemaTooNewError, + type AgentCatalogMigrationBlockedError, + type AgentCatalogSchemaTooNewError +} from './agent-catalog-write-policy' +import { + commitAuthoringPatchDurable, + type AgentReferenceWriteFailedError +} from './agent-catalog-durable-write' +import { applyAgentReferenceMutation } from './agent-reference-mutations' +import type { + AgentReferenceMutationRequest, + AgentReferenceMutationResult, + AgentReferenceProjectionError, + AgentReferenceSnapshot, + LocalAgentReferenceSnapshot +} from '../../shared/agent-reference-snapshot' + +const MAX_REFERENCE_PROJECTION_BYTES = 524_288 + +export type AgentReferenceAuthoringDeps = { + store: Store + countTombstoneReferences: Parameters[2] + catalogRevision: () => number + publishCatalogRevision: (revision: number) => void +} + +export class AgentReferenceAuthoringService { + constructor(private readonly deps: AgentReferenceAuthoringDeps) {} + + private get store(): Store { + return this.deps.store + } + + getRevision(): number { + return this.store.getSettings().agentReferenceRevision ?? 1 + } + + /** Remote (runtime RPC) reference snapshot; typed projection error when over + * the 512 KiB frame budget. */ + getRemoteSnapshot(): AgentReferenceSnapshot | AgentReferenceProjectionError { + const settings = this.store.getSettings() + const snapshot = buildAgentReferenceSnapshot(settings) + const { tooLarge } = measureAgentReferenceProjection(settings) + if (tooLarge) { + return { + version: 1, + revision: snapshot.revision, + code: 'agent_reference_payload_too_large', + maxBytes: MAX_REFERENCE_PROJECTION_BYTES + } + } + return snapshot + } + + /** Uncapped authoring/repair view over local preload IPC only. */ + getLocalSnapshot(): LocalAgentReferenceSnapshot { + const settings = this.store.getSettings() + const snapshot = buildAgentReferenceSnapshot(settings) + const { bytes, tooLarge } = measureAgentReferenceProjection(settings) + return { + ...snapshot, + projection: tooLarge + ? { status: 'too-large', bytes, maxBytes: MAX_REFERENCE_PROJECTION_BYTES } + : { status: 'ready', bytes, maxBytes: MAX_REFERENCE_PROJECTION_BYTES } + } + } + + mutate( + request: AgentReferenceMutationRequest + ): + | AgentReferenceMutationResult + | AgentCatalogMigrationBlockedError + | AgentCatalogSchemaTooNewError + | AgentReferenceWriteFailedError { + // The failed-backup invariant is "no v1 write": reference mutations stamp + // agentReferenceRevision, so they are blocked alongside catalog mutations. + const blocked = agentCatalogMigrationBlockedError(this.store) + if (blocked) { + return blocked + } + // Persistence refuses the write anyway; reporting it up front keeps a + // non-retryable read-only profile from looking like a disk failure. + const tooNew = agentCatalogSchemaTooNewError(this.store) + if (tooNew) { + return tooNew + } + const settings = this.store.getSettings() + const currentReferenceRevision = settings.agentReferenceRevision ?? 1 + const application = applyAgentReferenceMutation({ + settings, + request, + currentReferenceRevision, + catalog: normalizeCatalogFromSettings(settings) + }) + if (!application.ok) { + return { + ok: false, + code: application.code, + referenceRevision: currentReferenceRevision, + catalogRevision: this.deps.catalogRevision(), + ...(application.code === 'reference_revision_conflict' + ? { snapshot: this.getLocalSnapshot() } + : {}), + ...(application.owner ? { owner: application.owner } : {}), + ...(application.field ? { field: application.field } : {}), + ...(application.reason ? { reason: application.reason } : {}) + } + } + // The 512 KiB remote-projection budget is checked on the post-mutation + // snapshot; a non-growing write still commits so an over-budget profile can + // always be edited back under budget (mirrors mutate()'s security-reducing + // allowlist). Why preview: persistence normalizes the patch and derives + // legacy commitMessageAi from sourceControlAi, so the patch as written is + // smaller than the projection that actually ships. + const projected = measureAgentReferenceProjection( + this.store.previewSettingsUpdate(application.patch) + ) + if (projected.tooLarge && projected.bytes > measureAgentReferenceProjection(settings).bytes) { + return { + ok: false, + code: 'agent_reference_payload_too_large', + referenceRevision: currentReferenceRevision, + catalogRevision: this.deps.catalogRevision() + } + } + // Owner change commits before any prune; a failure between the two leaves + // the tombstone conservatively retained for the next indexed recheck. + if (!commitAuthoringPatchDurable(this.store, application.patch)) { + return { + ok: false, + code: 'agent_reference_write_failed', + referenceRevision: currentReferenceRevision, + catalogRevision: this.deps.catalogRevision() + } + } + this.pruneUnreferencedTombstones() + return { + ok: true, + referenceRevision: application.newReferenceRevision, + catalogRevision: this.deps.catalogRevision(), + snapshot: this.getLocalSnapshot() + } + } + + /** Reference-aware prune run after a reference removal; a prune advances and + * publishes the catalog revision so receivers replace their snapshot. */ + private pruneUnreferencedTombstones(): void { + const settings = this.store.getSettings() + // The patch also strips any persisted row the pruned tombstone suppressed, + // so the prune can never resurrect it. + const prunePatch = buildUnreferencedTombstonePrunePatch( + settings, + normalizeCatalogFromSettings(settings), + this.deps.countTombstoneReferences + ) + if (!prunePatch) { + return + } + const newRevision = (settings.agentCatalogRevision ?? 1) + 1 + // GC only: a rolled-back prune keeps the tombstone retained, and the next + // indexed recheck retries it — the reference mutation is already durable. + if ( + !commitAuthoringPatchDurable(this.store, { ...prunePatch, agentCatalogRevision: newRevision }) + ) { + return + } + this.deps.publishCatalogRevision(newRevision) + } +} diff --git a/src/main/agent-launch/agent-session-record-store.test.ts b/src/main/agent-launch/agent-session-record-store.test.ts index 98dda7740a5..35e61535e62 100644 --- a/src/main/agent-launch/agent-session-record-store.test.ts +++ b/src/main/agent-launch/agent-session-record-store.test.ts @@ -2,7 +2,7 @@ // staging, provider-session bind (by launch token) → durable resume record, // ownership-key resolution, incompatible/non-resumable bind rejection, spawn- // failure rollback, dispose-keeps-record, and the one-time legacy handoff. -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import type { AgentLaunchSnapshot } from '../../shared/agent-launch-host-contract' import type { TuiAgent } from '../../shared/types' import { @@ -16,6 +16,7 @@ import { type AgentSessionRecordStoreDurableState, type StagedLaunchRegistration } from './agent-session-record-store' +import { MAX_SESSION_RECORDS } from './agent-session-record-retention' function snapshot(overrides: Partial = {}): AgentLaunchSnapshot { return { @@ -191,6 +192,51 @@ describe('AgentSessionRecordStore lifecycle', () => { expect(store.bindProviderSessionByToken('token-a', SESSION)).toBeNull() }) + it('dispose by terminal clears staging a pane-less surface registered', () => { + const store = new AgentSessionRecordStore() + // Runtime/mobile launches carry a terminal id but never a stable pane key, + // so the pane teardown can never reach them. + store.register(registration({ paneKey: undefined, terminalId: 'term-a' })) + store.disposeStagingForPane('pane-a') + store.disposeStagingForTerminal('term-a') + expect(store.bindProviderSessionByToken('token-a', SESSION)).toBeNull() + }) + + it('bounds retained durable records, least-recently-updated first', () => { + let clock = 0 + const store = new AgentSessionRecordStore({ now: () => (clock += 1) }) + const total = MAX_SESSION_RECORDS + 4 + for (let index = 0; index < total; index += 1) { + store.register(registration({ launchToken: `token-${index}`, paneKey: `pane-${index}` })) + store.bindProviderSessionByToken(`token-${index}`, { key: 'session_id', id: `sess-${index}` }) + } + expect(store.durableState().records).toHaveLength(MAX_SESSION_RECORDS) + for (let index = 0; index < 4; index += 1) { + expect( + store.resolveByOwnershipKey({ ...OWNERSHIP, providerSessionId: `sess-${index}` }) + ).toBeNull() + } + // The record the last bind just created is never the eviction candidate. + expect( + store.resolveByOwnershipKey({ ...OWNERSHIP, providerSessionId: `sess-${total - 1}` }) + ).not.toBeNull() + }) + + it('trims a pre-bound record file at rehydrate without a write', () => { + let clock = 0 + const seed = new AgentSessionRecordStore({ now: () => (clock += 1) }) + for (let index = 0; index < MAX_SESSION_RECORDS + 3; index += 1) { + seed.register(registration({ launchToken: `token-${index}` })) + seed.bindProviderSessionByToken(`token-${index}`, { key: 'session_id', id: `sess-${index}` }) + } + const store = new AgentSessionRecordStore() + const sink = vi.fn() + store.setDurablePersistence(sink) + store.rebuildRecordsFrom(seed.durableState().records) + expect(store.durableState().records).toHaveLength(MAX_SESSION_RECORDS) + expect(sink).not.toHaveBeenCalled() + }) + it('two custom ids on one base/provider session resolve to one owner record', () => { const store = new AgentSessionRecordStore() store.register( diff --git a/src/main/agent-launch/background-agent-launch-store.test.ts b/src/main/agent-launch/background-agent-launch-store.test.ts index bf45fbdd952..6faf87e6e9b 100644 --- a/src/main/agent-launch/background-agent-launch-store.test.ts +++ b/src/main/agent-launch/background-agent-launch-store.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it, vi } from 'vitest' -import { BackgroundAgentLaunchStore } from './background-agent-launch-store' +import { + BackgroundAgentLaunchStore, + MAX_SETTLED_BACKGROUND_ATTEMPTS +} from './background-agent-launch-store' import type { BackgroundAgentLaunchCreateInput } from './background-agent-launch-store' import type { PersistedAgentLaunchFailure } from '../../shared/agent-launch-contract' import { parsePersistedAgentLaunchFailure } from '../../shared/agent-launch-failure-schema' @@ -203,6 +206,54 @@ describe('BackgroundAgentLaunchStore', () => { ) }) + it('bounds retained settled attempts, oldest-settled first', () => { + let clock = 0 + const store = new BackgroundAgentLaunchStore({ now: () => (clock += 1) }) + const total = MAX_SETTLED_BACKGROUND_ATTEMPTS + 5 + for (let index = 0; index < total; index += 1) { + store.create(createInput({ attemptId: `attempt-${index}` })) + store.settleLaunched(`attempt-${index}`) + } + expect(store.all()).toHaveLength(MAX_SETTLED_BACKGROUND_ATTEMPTS) + for (let index = 0; index < 5; index += 1) { + expect(store.get(`attempt-${index}`)).toBeNull() + } + expect(store.get(`attempt-${total - 1}`)?.state).toBe('launched') + }) + + it('never evicts a pending attempt, however old', () => { + let clock = 0 + const store = new BackgroundAgentLaunchStore({ now: () => (clock += 1) }) + // Created first, so it is the oldest record in the ledger and would be the + // first casualty of an age-only bound — but its reservation and private + // snapshot are still live. + store.create(createInput({ attemptId: 'attempt-live' })) + for (let index = 0; index < MAX_SETTLED_BACKGROUND_ATTEMPTS + 20; index += 1) { + store.create(createInput({ attemptId: `attempt-${index}` })) + store.settleFailed(`attempt-${index}`, failure('spawn_failed')) + } + expect(store.get('attempt-live')?.state).toBe('pending') + expect(store.all()).toHaveLength(MAX_SETTLED_BACKGROUND_ATTEMPTS + 1) + }) + + it('trims a pre-bound ledger at rehydrate without a write', () => { + let clock = 0 + const seed = new BackgroundAgentLaunchStore({ now: () => (clock += 1) }) + for (let index = 0; index < MAX_SETTLED_BACKGROUND_ATTEMPTS + 7; index += 1) { + seed.create(createInput({ attemptId: `attempt-${index}` })) + seed.settleLaunched(`attempt-${index}`) + } + const persisted = seed.durableState().attempts + const overSized = [...persisted, { ...persisted[0], attemptId: 'legacy-0', updatedAt: 0 }] + const store = new BackgroundAgentLaunchStore() + const sink = vi.fn() + store.setDurablePersistence(sink) + store.rebuildFrom(overSized) + expect(store.all()).toHaveLength(MAX_SETTLED_BACKGROUND_ATTEMPTS) + expect(store.get('legacy-0')).toBeNull() + expect(sink).not.toHaveBeenCalled() + }) + it('persistenceForAttempt binds the reconcile slice to one attempt', () => { const store = new BackgroundAgentLaunchStore() store.create(createInput()) diff --git a/src/main/agent-launch/compose-agent-launch-env.ts b/src/main/agent-launch/compose-agent-launch-env.ts index 05be4cc0d40..7b95ec35e36 100644 --- a/src/main/agent-launch/compose-agent-launch-env.ts +++ b/src/main/agent-launch/compose-agent-launch-env.ts @@ -130,6 +130,19 @@ export function composeAgentLaunchEnv(input: ComposeAgentLaunchEnvInput): Record return env } +/** The layer a spawned child actually inherits: `process.env` values are typed + * `string | undefined` and an undefined key is absent from the child's block. */ +export function inheritedEnvLayer(source: NodeJS.ProcessEnv): EnvLayer { + const layer = nullProtoEnv() + for (const key of Object.keys(source)) { + const value = source[key] + if (value !== undefined) { + layer[key] = value + } + } + return layer +} + /** Native-Windows environment-block size in UTF-16 code units: each entry is * `key=value\0`, with one extra terminating NUL after the final entry. */ export function measureWindowsEnvironmentBlockCodeUnits(env: EnvLayer): number { diff --git a/src/main/agent-launch/launch-store-durable-write.test.ts b/src/main/agent-launch/launch-store-durable-write.test.ts new file mode 100644 index 00000000000..f063d1acb90 --- /dev/null +++ b/src/main/agent-launch/launch-store-durable-write.test.ts @@ -0,0 +1,187 @@ +// One root cause, three durable launch stores: they wrote through the NON-fsync +// writer and their sinks swallowed every write error, so a launch could report +// success while its idempotency/recovery/resume row never reached the platter +// (or vanished with the page cache on power loss). These tests hold both halves +// of the fix — the fsync'd writer, and a failed write that reaches the caller. + +import type * as SecureFileModule from '../../shared/secure-file' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const durableWrites: string[] = [] +const plainWrites: string[] = [] +const failingWritePaths = new Set() + +vi.mock('../../shared/secure-file', async (importOriginal) => { + const actual = await importOriginal() + const failIfMarked = (path: string): void => { + if (failingWritePaths.has(path)) { + const error = new Error( + `ENOSPC: no space left on device, write '${path}'` + ) as NodeJS.ErrnoException + error.code = 'ENOSPC' + throw error + } + } + return { + ...actual, + writeSecureJsonFile: (path: string, value: unknown) => { + plainWrites.push(path) + failIfMarked(path) + actual.writeSecureJsonFile(path, value) + }, + writeDurableSecureJsonFile: (path: string, value: unknown) => { + durableWrites.push(path) + failIfMarked(path) + actual.writeDurableSecureJsonFile(path, value) + } + } +}) + +vi.mock('electron', () => ({ + safeStorage: { + isEncryptionAvailable: () => false, + encryptString: (value: string) => Buffer.from(value, 'utf-8'), + decryptString: (value: Buffer) => value.toString('utf-8') + } +})) + +import type { AgentLaunchSnapshot } from '../../shared/agent-launch-host-contract' +import { + AgentLaunchOperationStore, + mintAgentLaunchOperationId +} from './agent-launch-operation-store' +import { + agentLaunchOperationStorePath, + initAgentLaunchOperationStorePersistence +} from './agent-launch-operation-store-persistence' +import { AgentSessionRecordStore } from './agent-session-record-store' +import { + agentSessionRecordStorePath, + initAgentSessionRecordStorePersistence, + type AgentSessionRecordCipher +} from './agent-session-record-store-persistence' +import { BackgroundAgentLaunchStore } from './background-agent-launch-store' +import { + backgroundAgentLaunchStorePath, + initBackgroundAgentLaunchStorePersistence +} from './background-agent-launch-store-persistence' + +const plaintextCipher: AgentSessionRecordCipher = { + available: () => false, + encrypt: (plaintext) => Buffer.from(plaintext, 'utf-8'), + decrypt: (ciphertext) => ciphertext.toString('utf-8') +} + +const snapshot: AgentLaunchSnapshot = { + version: 1, + requestedAgent: 'claude', + baseAgent: 'claude', + displayLabel: 'Claude', + mode: 'built-in', + argv: ['claude'], + agentEnv: {}, + capturedEnvPolicy: 'none', + target: { + platform: 'linux', + execution: 'native', + shell: 'posix', + isRemote: false, + executionHostId: 'local' + } +} + +function mutateOperationStore(store: AgentLaunchOperationStore, launchToken: string): void { + store.beginPending({ + operationId: mintAgentLaunchOperationId(), + idempotencyKey: `key-${launchToken}`, + scope: 'wt-1', + clientMutationId: null, + payloadDigest: 'digest-a', + launchToken, + intent: 'interactive', + snapshot + }) +} + +function mutateSessionRecordStore(store: AgentSessionRecordStore, launchToken: string): void { + store.register({ + worktreeId: 'wt-1', + requestedAgent: 'claude', + baseAgent: 'claude', + launchSnapshot: snapshot, + launchToken + }) + store.bindProviderSessionByToken(launchToken, { key: 'session_id', id: `sess-${launchToken}` }) +} + +function mutateBackgroundStore(store: BackgroundAgentLaunchStore, attemptId: string): void { + store.create({ + attemptId, + worktreeId: 'r1::/wt', + operationId: `op-${attemptId}`, + requestedAgent: 'claude', + baseAgent: null + }) +} + +describe('durable launch store writes', () => { + let dir: string + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'launch-store-durable-')) + durableWrites.length = 0 + plainWrites.length = 0 + failingWritePaths.clear() + }) + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }) + }) + + it('persists the operation store through the fsync writer and surfaces failures', () => { + const path = agentLaunchOperationStorePath(dir) + const store = new AgentLaunchOperationStore() + initAgentLaunchOperationStorePersistence(store, path, plaintextCipher, { + rebuildAdmission: () => {}, + worktreeIdForBackgroundScope: () => null + }) + + mutateOperationStore(store, 'tok-ok') + expect(durableWrites).toContain(path) + expect(plainWrites).not.toContain(path) + + failingWritePaths.add(path) + expect(() => mutateOperationStore(store, 'tok-fail')).toThrow(/ENOSPC/) + }) + + it('persists session records through the fsync writer and surfaces failures', () => { + const path = agentSessionRecordStorePath(dir) + const store = new AgentSessionRecordStore() + initAgentSessionRecordStorePersistence(store, path, plaintextCipher) + + mutateSessionRecordStore(store, 'tok-ok') + expect(durableWrites).toContain(path) + expect(plainWrites).not.toContain(path) + + failingWritePaths.add(path) + expect(() => mutateSessionRecordStore(store, 'tok-fail')).toThrow(/ENOSPC/) + }) + + it('persists background attempts through the fsync writer and surfaces failures', () => { + const path = backgroundAgentLaunchStorePath(dir) + const store = new BackgroundAgentLaunchStore() + initBackgroundAgentLaunchStorePersistence(store, path) + + mutateBackgroundStore(store, '0199f7a1-0000-7000-8000-0000000000d1') + expect(durableWrites).toContain(path) + expect(plainWrites).not.toContain(path) + + failingWritePaths.add(path) + expect(() => mutateBackgroundStore(store, '0199f7a1-0000-7000-8000-0000000000d2')).toThrow( + /ENOSPC/ + ) + }) +}) diff --git a/src/main/agent-launch/resolve-agent-launch-admission-fingerprint.test.ts b/src/main/agent-launch/resolve-agent-launch-admission-fingerprint.test.ts new file mode 100644 index 00000000000..bc7d0930ee4 --- /dev/null +++ b/src/main/agent-launch/resolve-agent-launch-admission-fingerprint.test.ts @@ -0,0 +1,137 @@ +// The admission fingerprint must move whenever anything that changes THIS launch +// changes — including the env a mobile/paired remove-only replay actually ships, +// which the coarse capture policy alone cannot distinguish. The config-only +// stable digest must stay blind to the path variables (and to the argv/env values +// they are substituted into) so the two-stage worktree recheck still passes. +import { describe, expect, it } from 'vitest' +import { resolveAgentLaunch } from './resolve-agent-launch' +import { + catalogOf, + customAgent, + customId, + requestOf, + settingsOf +} from './agent-launch-test-catalog' +import type { AgentLaunchSnapshot } from '../../shared/agent-launch-host-contract' +import type { ResolveAgentLaunchOutcome } from './resolve-agent-launch' + +const AGENT_ID = customId('claude', '00000000-0000-4000-8000-0000000f0001') + +const CAPTURED_ENV = { API_KEY: 'v1', REGION: 'eu' } + +function snapshotOf(): AgentLaunchSnapshot { + return { + version: 1, + requestedAgent: AGENT_ID, + baseAgent: 'claude', + displayLabel: 'My Agent', + mode: 'custom', + argv: ['claude'] as unknown as AgentLaunchSnapshot['argv'], + agentEnv: CAPTURED_ENV, + capturedEnvPolicy: 'full', + target: { + platform: 'linux', + execution: 'native', + shell: 'posix', + isRemote: false, + executionHostId: 'local' + } + } +} + +/** A mobile replay of the same snapshot against a live definition whose env may + * have rotated since capture. */ +function mobileReplay(definitionEnv: Record): ResolveAgentLaunchOutcome { + return resolveAgentLaunch( + { + ...requestOf({ selection: { kind: 'agent', agent: AGENT_ID } }), + intent: { kind: 'resume', operation: 'resume', client: 'mobile' }, + reference: { kind: 'persisted', owner: 'session' }, + persistedSnapshot: snapshotOf() + }, + catalogOf({ + customTuiAgents: [ + customAgent({ + id: AGENT_ID, + label: 'My Agent', + env: definitionEnv, + syncEnv: true + }) + ] + }), + settingsOf() + ) +} + +function launchOf(outcome: ResolveAgentLaunchOutcome) { + if (!outcome.ok || !('launch' in outcome)) { + throw new Error('expected the launch to resolve') + } + return outcome.launch +} + +describe('admission fingerprint over replay env authorization', () => { + it('differs when a rotated key is withheld, though every coarse input matches', () => { + const authorized = launchOf(mobileReplay({ API_KEY: 'v1', REGION: 'eu' })) + const rotated = launchOf(mobileReplay({ API_KEY: 'v2', REGION: 'eu' })) + expect(authorized.agentEnv).toEqual(CAPTURED_ENV) + expect(rotated.agentEnv).toEqual({ REGION: 'eu' }) + // Same argv, same capture policy ('full' — one entry survived), same replay + // definition digest: only the shipped env moved, so only it can carry the + // difference. A stale admission would otherwise ship the pre-rotation value. + expect(authorized.policy.env).toBe(rotated.policy.env) + expect(authorized.admissionGuard.fingerprint).not.toBe(rotated.admissionGuard.fingerprint) + }) + + it('stays identical for two replays that ship the same env', () => { + const first = launchOf(mobileReplay({ API_KEY: 'v1', REGION: 'eu' })) + const second = launchOf(mobileReplay({ API_KEY: 'v1', REGION: 'eu' })) + expect(first.admissionGuard.fingerprint).toBe(second.admissionGuard.fingerprint) + }) + + it('differs when a per-launch arg changes the resolved argv', () => { + const argvFingerprint = (args: string): string => { + const outcome = resolveAgentLaunch( + requestOf({ selection: { kind: 'agent', agent: AGENT_ID } }), + catalogOf({ customTuiAgents: [customAgent({ id: AGENT_ID, args })] }), + settingsOf() + ) + return launchOf(outcome).admissionGuard.fingerprint + } + expect(argvFingerprint('--model a')).not.toBe(argvFingerprint('--model b')) + }) +}) + +describe('config-only stable digest', () => { + function resolveAtWorktree(worktreePath: string): ResolveAgentLaunchOutcome { + return resolveAgentLaunch( + requestOf({ + selection: { kind: 'agent', agent: AGENT_ID }, + variables: { repoPath: '/repo', worktreePath } + }), + catalogOf({ + customTuiAgents: [ + customAgent({ + id: AGENT_ID, + args: '{worktreePath}', + env: { WT: '{worktreePath}' } + }) + ] + }), + settingsOf() + ) + } + + it('ignores the paths substituted into argv and env so stage 2 rechecks cleanly', () => { + const provisional = launchOf(resolveAtWorktree('/wt-provisional')) + const authoritative = launchOf(resolveAtWorktree('/wt-real')) + expect(provisional.argv).toContain('/wt-provisional') + expect(authoritative.agentEnv.WT).toBe('/wt-real') + expect(provisional.admissionGuard.fingerprint).not.toBe( + authoritative.admissionGuard.fingerprint + ) + expect(provisional.admissionGuard.stableInputDigest).toBe( + authoritative.admissionGuard.stableInputDigest + ) + }) +}) diff --git a/src/main/agent-launch/resolve-agent-launch-env-block-cap.test.ts b/src/main/agent-launch/resolve-agent-launch-env-block-cap.test.ts new file mode 100644 index 00000000000..5df56dbb2d5 --- /dev/null +++ b/src/main/agent-launch/resolve-agent-launch-env-block-cap.test.ts @@ -0,0 +1,88 @@ +// A native-Windows spawn inherits this process' env, so CreateProcess sizes the +// composed inherited+custom block, not the custom layer the resolver admits. +// Measuring only the custom layer admits a launch that then dies opaquely at +// spawn, so the cap composes the real block for that target and no other. +import { describe, expect, it } from 'vitest' +import { resolveAgentLaunch } from './resolve-agent-launch' +import { catalogOf, requestOf, settingsOf } from './agent-launch-test-catalog' +import type { AgentLaunchExecutionHostId } from '../../shared/agent-launch-host-contract' +import type { ResolveAgentLaunchOutcome } from './resolve-agent-launch' + +const WINDOWS_TARGET = { + platform: 'win32' as NodeJS.Platform, + shell: 'cmd' as const, + targetHomePath: 'C:\\Users\\me' +} + +/** Just past WINDOWS_ENVIRONMENT_BLOCK_MAX_CODE_UNITS once composed. */ +const OVERSIZED_INHERITED: NodeJS.ProcessEnv = { HUGE: 'x'.repeat(33_000) } + +function resolveWith( + overrides: Partial[0]>, + inheritedEnv: NodeJS.ProcessEnv +): ResolveAgentLaunchOutcome { + return resolveAgentLaunch( + requestOf({ selection: { kind: 'agent', agent: 'claude' }, ...overrides }), + catalogOf({}), + settingsOf(), + inheritedEnv + ) +} + +function failureCodeOf(outcome: ResolveAgentLaunchOutcome): string | null { + return !outcome.ok && 'failure' in outcome ? outcome.failure.code : null +} + +describe('native-Windows environment-block cap', () => { + it('rejects a launch whose inherited block already exceeds the ceiling', () => { + const outcome = resolveWith({ ...WINDOWS_TARGET }, OVERSIZED_INHERITED) + expect(failureCodeOf(outcome)).toBe('invalid_agent_env') + if (!outcome.ok && 'failure' in outcome && outcome.failure.code === 'invalid_agent_env') { + expect(outcome.failure.reason).toBe('environment_block_too_large') + expect(outcome.failure.field).toBe('env') + } + }) + + it('admits the same launch under a normal inherited block', () => { + expect(resolveWith({ ...WINDOWS_TARGET }, { PATH: 'C:\\bin' }).ok).toBe(true) + }) + + it('ignores the local inherited block for a remote target', () => { + const outcome = resolveWith( + { + isRemote: true, + targetHomePath: null, + executionHostId: 'ssh:box' as AgentLaunchExecutionHostId + }, + OVERSIZED_INHERITED + ) + expect(outcome.ok).toBe(true) + }) + + it('ignores it for a WSL target, which spawns inside the distro', () => { + const outcome = resolveWith( + { ...WINDOWS_TARGET, executionHostId: 'wsl:Ubuntu' as AgentLaunchExecutionHostId }, + OVERSIZED_INHERITED + ) + expect(outcome.ok).toBe(true) + }) + + it('applies the same ceiling to a snapshot replay', () => { + const admitted = resolveWith({ ...WINDOWS_TARGET }, { PATH: 'C:\\bin' }) + if (!admitted.ok || !('launch' in admitted)) { + throw new Error('expected the baseline launch to resolve') + } + const replay = resolveAgentLaunch( + { + ...requestOf({ selection: { kind: 'agent', agent: 'claude' }, ...WINDOWS_TARGET }), + intent: { kind: 'resume', operation: 'resume', client: 'desktop' }, + reference: { kind: 'persisted', owner: 'session' }, + persistedSnapshot: admitted.launch.snapshot + }, + catalogOf({}), + settingsOf(), + OVERSIZED_INHERITED + ) + expect(failureCodeOf(replay)).toBe('invalid_agent_env') + }) +}) diff --git a/src/main/agent-launch/resolve-agent-launch.performance.test.ts b/src/main/agent-launch/resolve-agent-launch.performance.test.ts index edfb349b58a..f17794def27 100644 --- a/src/main/agent-launch/resolve-agent-launch.performance.test.ts +++ b/src/main/agent-launch/resolve-agent-launch.performance.test.ts @@ -5,7 +5,7 @@ import os from 'node:os' import { performance } from 'node:perf_hooks' -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import type { CustomTuiAgent, CustomTuiAgentId, GlobalSettings } from '../../shared/types' import type { AgentCatalog } from '../../shared/agent-catalog-normalization' import { resolveAgentLaunch, type ResolveAgentLaunchOutcome } from './resolve-agent-launch' @@ -16,7 +16,17 @@ import { requestOf, settingsOf } from './agent-launch-test-catalog' +import { + resolveAgentLaunchSpawn, + type AgentLaunchSpawnDeps, + type AgentLaunchSpawnTarget +} from './agent-launch-spawn' +import { AgentLaunchBoundary } from './agent-launch-boundary' import type * as AgentCatalogProjectionsModule from './agent-catalog-projections' +import { + AgentLaunchAdmissionStore, + LaunchAdmissionCoordinator +} from './agent-launch-admission-store' // The pure-resolver gates below inject a PREBUILT catalog, so they never see the // normalize pass production pays per launch. This counter lets the production- @@ -82,6 +92,27 @@ function buildFixture( return { catalog: catalogOf({ customTuiAgents: agents }), selectedId } } +/** RAW persisted settings — what production actually hands the launch path. The + * host normalizes these per launch, so a prebuilt-catalog fixture measures a + * strictly cheaper pipeline than the one users pay for. */ +function buildSettingsFixture( + size: number, + envEntries: number +): { settings: GlobalSettings; selectedId: CustomTuiAgentId } { + const { agents, selectedId } = buildAgents(size, envEntries) + return { + settings: { + ...settingsOf(), + customTuiAgents: agents, + deletedCustomTuiAgents: [], + disabledTuiAgents: [], + defaultTuiAgent: 'auto', + agentCatalogRevision: 1 + } as unknown as GlobalSettings, + selectedId + } +} + const ARRAY_SCAN_PROPS = new Set([ 'forEach', 'map', @@ -239,3 +270,123 @@ describe('resolve-agent-launch performance budget', () => { expect(okAll).toBe(true) }) }) + +// P1-21: the gates above resolve a PREBUILT catalog, so they never exercise the +// normalize pass the host runs per launch — the production surface (settings → +// normalized catalog → resolver → boundary) is measured here instead. +describe('production launch path performance budget (P1-21)', () => { + const PRODUCTION_TARGET: AgentLaunchSpawnTarget = { + platform: 'linux', + shell: 'posix', + isRemote: false, + executionHostId: 'local', + targetHomePath: '/home/dev', + // null = detection unavailable, the honest host default; never a fabricated set. + detectedStockBaseAgents: null + } + + function productionDeps(getSettings: () => GlobalSettings): AgentLaunchSpawnDeps { + return { + getSettings, + getCatalogRevision: () => getSettings().agentCatalogRevision ?? 1, + boundary: new AgentLaunchBoundary({ + admissionStore: new AgentLaunchAdmissionStore(), + coordinator: new LaunchAdmissionCoordinator() + }) + } + } + + async function launchOnce( + deps: AgentLaunchSpawnDeps, + selectedId: CustomTuiAgentId + ): Promise { + const result = await resolveAgentLaunchSpawn(deps, { + request: { selection: { kind: 'agent', agent: selectedId }, prompt: 'go' }, + intent: { kind: 'interactive', client: 'desktop' }, + target: PRODUCTION_TARGET, + variables: { repoPath: '/repo', worktreePath: '/repo/wt' }, + scope: 'perf-launch', + principal: { kind: 'local' } + }) + if (result.ok) { + // Release the admitted slot so a repeated-launch loop never hits the cap. + deps.boundary.settleAgentLaunch(result.receipt.launchToken, 'failed') + } + return result.ok + } + + beforeEach(() => { + normalizeCatalogCalls.count = 0 + }) + + it('normalizes a 2,500-agent catalog once per launch, not once per resolve pass', async () => { + const { settings, selectedId } = buildSettingsFixture(2500, 8) + const deps = productionDeps(() => settings) + + expect(await launchOnce(deps, selectedId)).toBe(true) + + // The boundary resolves twice (initial view + coordinator re-resolve); both + // passes must share one normalized catalog. + expect(normalizeCatalogCalls.count).toBe(1) + }) + + it('reuses one normalized catalog across launches on an unchanged settings revision', async () => { + const { settings, selectedId } = buildSettingsFixture(2500, 8) + const deps = productionDeps(() => settings) + + for (let i = 0; i < 8; i += 1) { + expect(await launchOnce(deps, selectedId)).toBe(true) + } + expect(normalizeCatalogCalls.count).toBe(1) + }) + + it('re-normalizes after the settings object is replaced (a real catalog edit)', async () => { + const { settings, selectedId } = buildSettingsFixture(2500, 8) + let current = settings + const deps = productionDeps(() => current) + + expect(await launchOnce(deps, selectedId)).toBe(true) + current = { ...settings, agentCatalogRevision: 2 } as GlobalSettings + expect(await launchOnce(deps, selectedId)).toBe(true) + + expect(normalizeCatalogCalls.count).toBe(2) + }) + + it('records production launch throughput as PR evidence (never a CI wall-clock assertion)', async () => { + const { settings, selectedId } = buildSettingsFixture(2500, 8) + let current = settings + const deps = productionDeps(() => current) + let okAll = true + + for (let i = 0; i < 5; i += 1) { + okAll = (await launchOnce(deps, selectedId)) && okAll + } + + const runs = process.env.ORCA_PERF_EVIDENCE ? 50 : 5 + const coldMs: number[] = [] + const warmMs: number[] = [] + for (let r = 0; r < runs; r += 1) { + // Cold: a fresh settings revision, so this launch pays the normalize. + current = { ...settings, agentCatalogRevision: r + 10 } as GlobalSettings + let start = performance.now() + okAll = (await launchOnce(deps, selectedId)) && okAll + coldMs.push(performance.now() - start) + // Warm: same revision, so the normalized catalog is reused. + start = performance.now() + okAll = (await launchOnce(deps, selectedId)) && okAll + warmMs.push(performance.now() - start) + } + const p95 = (samples: number[]): number => { + const sorted = [...samples].sort((a, b) => a - b) + return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95))] ?? 0 + } + + // Evidence only — asserted nowhere (plan §1371: no wall-clock CI threshold). + console.log( + `[agent-launch-spawn.perf] node=${process.version} cpu=${os.cpus()[0]?.model ?? 'unknown'} ` + + `agents=2500 runs=${runs} p95ColdLaunchMs=${p95(coldMs).toFixed(3)} ` + + `p95WarmLaunchMs=${p95(warmMs).toFixed(3)}` + ) + expect(okAll).toBe(true) + }) +}) diff --git a/src/main/agent-launch/resolve-agent-launch.ts b/src/main/agent-launch/resolve-agent-launch.ts index bfdaaf4ec71..eb296609ea3 100644 --- a/src/main/agent-launch/resolve-agent-launch.ts +++ b/src/main/agent-launch/resolve-agent-launch.ts @@ -21,6 +21,7 @@ import { assembleCommand } from './resolve-agent-command' import { interpolateVariables, prepareVariableValues } from './resolve-agent-variables' import { clientOfIntent } from './resolve-agent-env-admission' import { checkCommandTooLong, checkEnvPayloadTooLarge } from './agent-launch-payload-caps' +import { composeAgentLaunchEnv, inheritedEnvLayer } from './compose-agent-launch-env' import { buildResolvedLaunch, type LaunchTarget } from './resolve-agent-launch-result' import { resolveMobileRemoveOnlyReplayEnv, @@ -35,6 +36,31 @@ function hasUserPathOverride(env: Record): boolean { return Object.keys(env).some((key) => key.toLowerCase() === 'path') } +/** The env a native-Windows spawn actually ships. CreateProcess rejects the whole + * inherited+custom block over its ceiling, so measuring the custom layer alone + * admits a launch that then fails opaquely at spawn. Every other target measures + * argv+env bytes instead and composes nothing here. */ +function spawnEnvForPayloadCap( + env: Record, + target: LaunchTarget, + inheritedEnv: NodeJS.ProcessEnv | undefined +): Record { + if (target.platform !== 'win32' || target.execution !== 'native' || target.isRemote) { + return env + } + // Only the machine that will spawn owns the inherited layer; a win32 target + // resolved from anywhere else has no local block to measure. + const source = inheritedEnv ?? (process.platform === 'win32' ? process.env : undefined) + if (!source) { + return env + } + return composeAgentLaunchEnv({ + platform: 'win32', + inherited: inheritedEnvLayer(source), + agentEnv: env + }) +} + function deriveTarget(request: ResolveAgentLaunchRequest): LaunchTarget { return { platform: request.platform, @@ -79,7 +105,8 @@ function replayFromSnapshot( request: ResolveAgentLaunchRequest, target: LaunchTarget, catalog: AgentCatalog, - settings: GlobalSettings + settings: GlobalSettings, + inheritedEnv: NodeJS.ProcessEnv | undefined ): ResolveAgentLaunchOutcome { const snapshot = request.persistedSnapshot if (!snapshot) { @@ -169,7 +196,11 @@ function replayFromSnapshot( if (replayCommandCap) { return { ok: false, failure: replayCommandCap } } - const replayEnvCap = checkEnvPayloadTooLarge(replayArgv, env, target) + const replayEnvCap = checkEnvPayloadTooLarge( + replayArgv, + spawnEnvForPayloadCap(env, target, inheritedEnv), + target + ) if (replayEnvCap) { return { ok: false, failure: replayEnvCap } } @@ -209,11 +240,15 @@ function replayFromSnapshot( } } -/** Resolve a launch request against the normalized catalog and current settings. */ +/** Resolve a launch request against the normalized catalog and current settings. + * `inheritedEnv` is the layer the spawn will inherit, needed only to size a + * native-Windows environment block; it defaults to this process' env, which is + * that layer whenever this host is the one spawning. */ export function resolveAgentLaunch( request: ResolveAgentLaunchRequest, catalog: AgentCatalog, - settings: GlobalSettings + settings: GlobalSettings, + inheritedEnv?: NodeJS.ProcessEnv ): ResolveAgentLaunchOutcome { const selection = resolveSelection(request, catalog) if (selection.kind === 'failure') { @@ -226,7 +261,7 @@ export function resolveAgentLaunch( const target = deriveTarget(request) if (selection.decision.launch === 'replay-snapshot') { - return replayFromSnapshot(request, target, catalog, settings) + return replayFromSnapshot(request, target, catalog, settings, inheritedEnv) } const client = clientOfIntent(request.intent) @@ -297,7 +332,11 @@ export function resolveAgentLaunch( if (commandCap) { return { ok: false, failure: commandCap } } - const envCap = checkEnvPayloadTooLarge(command.argv, resolvedEnv, target) + const envCap = checkEnvPayloadTooLarge( + command.argv, + spawnEnvForPayloadCap(resolvedEnv, target, inheritedEnv), + target + ) if (envCap) { return { ok: false, failure: envCap } } diff --git a/src/main/agent-launch/resolved-agent-startup-plan.test.ts b/src/main/agent-launch/resolved-agent-startup-plan.test.ts index c7dac9fff5b..dfcf7b97d3f 100644 --- a/src/main/agent-launch/resolved-agent-startup-plan.test.ts +++ b/src/main/agent-launch/resolved-agent-startup-plan.test.ts @@ -332,4 +332,85 @@ describe('buildAgentStartupPlanFromResolvedLaunch', () => { expect(submitted?.draftPrompt).toBeUndefined() }) }) + + describe('final assembled command ceiling', () => { + // The resolver caps only the RESOLVED argv; the RPC admits a 100k-char prompt + // that is appended here, so the ceiling has to hold for the final command. + it('moves an argv prompt off a cmd command line over the 8191-char ceiling', () => { + const launch = resolvedLaunch({ + request: { platform: 'win32', shell: 'cmd', targetHomePath: 'C:\\Users\\me' } + }) + const bigPrompt = 'x'.repeat(20_000) + const plan = buildAgentStartupPlanFromResolvedLaunch({ launch, prompt: bigPrompt }) + expect(plan?.launchCommand).toBe(`"codex"`) + expect(plan?.launchCommand.length).toBeLessThanOrEqual(8191) + // The FULL prompt is retained — never truncated or dropped. + expect(plan?.followupPrompt).toBe(bigPrompt) + }) + + it('moves an oversized draft flag prompt to the paste path with no inline ceiling given', () => { + const launch = resolvedLaunch({ + agent: 'claude', + request: { platform: 'win32', shell: 'cmd', targetHomePath: 'C:\\Users\\me' } + }) + const bigDraft = 'x'.repeat(20_000) + const plan = buildAgentStartupPlanFromResolvedLaunch({ + launch, + prompt: bigDraft, + promptDelivery: 'draft' + }) + expect(plan?.launchCommand).toBe(`"claude"`) + expect(plan?.draftPrompt).toBe(bigDraft) + }) + + it('moves an env-var draft off argv when its cleanup clause crosses the cmd ceiling', () => { + const agent = customId('pi', '00000000-0000-4000-8000-0000000f0002') + // Base command sits just under 8191; the ` & set "ORCA_PI_PREFILL="` clause + // the draft path appends is what pushes the final command over. + const launch = resolvedLaunch({ + agent, + catalog: catalogOf({ + customTuiAgents: [customAgent({ id: agent, baseAgent: 'pi', args: 'y'.repeat(8_173) })] + }), + request: { platform: 'win32', shell: 'cmd', targetHomePath: 'C:\\Users\\me' } + }) + const plan = buildAgentStartupPlanFromResolvedLaunch({ + launch, + prompt: 'do it', + promptDelivery: 'draft' + }) + expect(plan?.launchCommand.length).toBeLessThanOrEqual(8191) + expect(plan?.launchCommand).not.toContain('ORCA_PI_PREFILL') + expect(plan?.env).toBeUndefined() + expect(plan?.draftPrompt).toBe('do it') + }) + + it('applies the encoded-length ceiling on a powershell target', () => { + const launch = resolvedLaunch({ + request: { platform: 'win32', shell: 'powershell', targetHomePath: 'C:\\Users\\me' } + }) + const bigPrompt = 'x'.repeat(30_000) + const plan = buildAgentStartupPlanFromResolvedLaunch({ launch, prompt: bigPrompt }) + expect(plan?.launchCommand).toBe(`& 'codex'`) + expect(plan?.followupPrompt).toBe(bigPrompt) + }) + + it('applies the byte ceiling on a posix target', () => { + const launch = resolvedLaunch() + const bigPrompt = 'x'.repeat(200_000) + const plan = buildAgentStartupPlanFromResolvedLaunch({ launch, prompt: bigPrompt }) + expect(plan?.launchCommand).toBe(`'codex'`) + expect(plan?.followupPrompt).toBe(bigPrompt) + }) + + it('still inlines a prompt that fits the target ceiling', () => { + const launch = resolvedLaunch({ + request: { platform: 'win32', shell: 'cmd', targetHomePath: 'C:\\Users\\me' } + }) + const prompt = 'x'.repeat(4_000) + const plan = buildAgentStartupPlanFromResolvedLaunch({ launch, prompt }) + expect(plan?.launchCommand).toBe(`"codex" "${prompt}"`) + expect(plan?.followupPrompt).toBeNull() + }) + }) }) diff --git a/src/main/daemon/degraded-daemon-pty-provider.test.ts b/src/main/daemon/degraded-daemon-pty-provider.test.ts index ccf3def0b69..1cce38d8461 100644 --- a/src/main/daemon/degraded-daemon-pty-provider.test.ts +++ b/src/main/daemon/degraded-daemon-pty-provider.test.ts @@ -342,6 +342,38 @@ describe('DegradedDaemonPtyProvider', () => { expect(provider.providesAgentSessionOwnerListings('unknown-session')).toBe(false) }) + it('withholds launch-token listing authority unless every daemon route echoes tokens', () => { + const echoing = (label: string, echoes: boolean): DaemonPtyAdapter & ProviderMock => { + const adapter = createDaemonAdapter(label) + adapter.providesLaunchTokenListings = vi.fn(() => echoes) + return adapter + } + const fallback = (): ProviderMock => createProvider('fallback') + + expect( + new DegradedDaemonPtyProvider({ + current: echoing('daemon', true), + legacy: [echoing('legacy', true)], + fallback: fallback() + }).providesLaunchTokenListings() + ).toBe(true) + // A pre-v34 legacy daemon still owning the launched agent poisons the whole host. + expect( + new DegradedDaemonPtyProvider({ + current: echoing('daemon', true), + legacy: [echoing('legacy', false)], + fallback: fallback() + }).providesLaunchTokenListings() + ).toBe(false) + expect( + new DegradedDaemonPtyProvider({ + current: createDaemonAdapter('unanswering'), + legacy: [], + fallback: fallback() + }).providesLaunchTokenListings() + ).toBe(false) + }) + it('routes fresh foreground confirmation to the session owner', async () => { const current = createDaemonAdapter('daemon', ['daemon-session']) const fallback = createProvider('fallback') diff --git a/src/main/daemon/degraded-daemon-pty-provider.ts b/src/main/daemon/degraded-daemon-pty-provider.ts index f0e183bcb20..bfd160adc69 100644 --- a/src/main/daemon/degraded-daemon-pty-provider.ts +++ b/src/main/daemon/degraded-daemon-pty-provider.ts @@ -118,6 +118,13 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { ? await provider.writeWithSettlement(id, data) : provider.write(id, data) !== false } + // Why every daemon route (and why an adapter that cannot answer withholds): one + // preserved pre-v34 daemon may own the very agent a pending launch is looking for and + // will never echo its token, so a missing echo must not settle the launch failed. + // Daemon adapters only — the in-process fallback has main's own token registry. + providesLaunchTokenListings = (): boolean => + this.allDaemonAdapters().every((adapter) => adapter.providesLaunchTokenListings?.() === true) + } resize(id: string, cols: number, rows: number): void { this.providerFor(id).resize(id, cols, rows) diff --git a/src/main/data-recovery/recovery-points.test.ts b/src/main/data-recovery/recovery-points.test.ts index a0056079b7b..42c6bbb0d5b 100644 --- a/src/main/data-recovery/recovery-points.test.ts +++ b/src/main/data-recovery/recovery-points.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { chmodSync, existsSync, + mkdirSync, mkdtempSync, readFileSync, rmSync, @@ -61,6 +62,21 @@ describe('data recovery points', () => { expect(points[0].createdAtMs).toBeGreaterThan(0) expect(JSON.stringify(points)).not.toContain(dir) expect(JSON.stringify(points)).not.toContain('settings') + expect(points[0].restorable).toBe(true) + }) + + it('lists an unreadable pinned backup as not restorable', () => { + // A directory at the backup path reproduces EISDIR everywhere; existence alone + // would otherwise advertise a rollback the restore path always rejects. + mkdirSync(`${dataFile}${PINNED_SUFFIX}`) + const points = listRecoveryPoints(dataFile) + expect(points).toHaveLength(1) + expect(points[0].restorable).toBe(false) + }) + + it('lists a torn pinned backup as not restorable', () => { + writeFileSync(`${dataFile}${PINNED_SUFFIX}`, '{"settings":') + expect(listRecoveryPoints(dataFile)[0].restorable).toBe(false) }) it('restores atomically: freeze before replace, safety copy kept, point preserved', async () => { diff --git a/src/main/data-recovery/recovery-points.ts b/src/main/data-recovery/recovery-points.ts index ea7537588bf..44bdab46195 100644 --- a/src/main/data-recovery/recovery-points.ts +++ b/src/main/data-recovery/recovery-points.ts @@ -3,7 +3,10 @@ // filesystem path or raw backup contents; restore/retry are main-owned. import { existsSync, readFileSync, renameSync, statSync } from 'node:fs' -import { pinnedPreV1BackupPath } from '../agent-launch/agent-catalog-pre-v1-backup' +import { + classifyPinnedPreV1Backup, + pinnedPreV1BackupPath +} from '../agent-launch/agent-catalog-pre-v1-backup' import { durableWriteTempPath, writeFileDurableSync } from '../durable-file-write' import type { RecoveryPointDto, RecoveryPointId } from '../../shared/data-recovery' @@ -38,13 +41,15 @@ export function listRecoveryPoints(dataFile: string): RecoveryPointDto[] { createdAtMs = stat.birthtimeMs > 0 ? stat.birthtimeMs : stat.mtimeMs sizeBytes = stat.size } catch { - // Metadata is best-effort; the point is still restorable. + // Metadata is best-effort; readability is decided below, not here. } points.push({ id: 'agent-catalog-pre-v1', compatibility: 'previous-binary', createdAtMs, - sizeBytes + sizeBytes, + // Existence alone would advertise a rollback nobody can actually read. + restorable: classifyPinnedPreV1Backup(pinned).state === 'usable' }) } return points diff --git a/src/main/ipc/data-recovery.test.ts b/src/main/ipc/data-recovery.test.ts new file mode 100644 index 00000000000..55cde285820 --- /dev/null +++ b/src/main/ipc/data-recovery.test.ts @@ -0,0 +1,54 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { handleMock } = vi.hoisted(() => ({ handleMock: vi.fn() })) + +vi.mock('electron', () => ({ + app: { quit: vi.fn() }, + ipcMain: { handle: handleMock } +})) + +vi.mock('../data-recovery/recovery-points', () => ({ + listRecoveryPoints: vi.fn(() => []), + restoreRecoveryPoint: vi.fn(async () => ({ ok: true })) +})) + +import { registerDataRecoveryHandlers } from './data-recovery' +import type { DataRecoveryMigrationStatus } from '../../shared/data-recovery' + +function migrationStatusFor(store: { + getAgentCatalogMigrationError(): string | null + getAgentCatalogSchemaTooNew(): { persistedVersion: number; supportedVersion: number } | null +}): DataRecoveryMigrationStatus { + registerDataRecoveryHandlers({ ...store, getDataFilePath: () => '/tmp/orca.json' } as never) + const handler = handleMock.mock.calls.find( + (call) => call[0] === 'dataRecovery:migrationStatus' + )?.[1] as () => DataRecoveryMigrationStatus + return handler() +} + +describe('dataRecovery:migrationStatus', () => { + beforeEach(() => { + handleMock.mockClear() + }) + + it('reports a read-only profile stamped by a newer build', () => { + expect( + migrationStatusFor({ + getAgentCatalogMigrationError: () => null, + getAgentCatalogSchemaTooNew: () => ({ persistedVersion: 2, supportedVersion: 1 }) + }) + ).toEqual({ + agentCatalogMigrationError: null, + agentCatalogSchemaTooNew: { persistedVersion: 2, supportedVersion: 1 } + }) + }) + + it('reports null for a healthy profile so the renderer shows no notice', () => { + expect( + migrationStatusFor({ + getAgentCatalogMigrationError: () => null, + getAgentCatalogSchemaTooNew: () => null + }) + ).toEqual({ agentCatalogMigrationError: null, agentCatalogSchemaTooNew: null }) + }) +}) diff --git a/src/main/ipc/data-recovery.ts b/src/main/ipc/data-recovery.ts index 13e7b3d578f..6a27ad11428 100644 --- a/src/main/ipc/data-recovery.ts +++ b/src/main/ipc/data-recovery.ts @@ -11,7 +11,8 @@ export function registerDataRecoveryHandlers(store: Store): void { ipcMain.handle( 'dataRecovery:migrationStatus', (): DataRecoveryMigrationStatus => ({ - agentCatalogMigrationError: store.getAgentCatalogMigrationError() + agentCatalogMigrationError: store.getAgentCatalogMigrationError(), + agentCatalogSchemaTooNew: store.getAgentCatalogSchemaTooNew() }) ) diff --git a/src/main/persistence-agent-catalog-schema-too-new.test.ts b/src/main/persistence-agent-catalog-schema-too-new.test.ts new file mode 100644 index 00000000000..63de326e39b --- /dev/null +++ b/src/main/persistence-agent-catalog-schema-too-new.test.ts @@ -0,0 +1,128 @@ +// A profile stamped by a newer build must stay read-only: the Store latches the +// too-new state at load, refuses durable authoring writes, and never turns it +// into a retryable pinned-backup failure. +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { chmodSync, existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' + +const testState = { dir: '' } + +vi.mock('electron', () => ({ + app: { + getPath: () => testState.dir + }, + safeStorage: { + isEncryptionAvailable: () => false, + encryptString: (plaintext: string) => Buffer.from(plaintext, 'utf-8'), + decryptString: (ciphertext: Buffer) => ciphertext.toString('utf-8') + } +})) + +vi.mock('./telemetry/client', () => ({ track: vi.fn() })) +vi.mock('./telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn() })) +vi.mock('./ssh/ssh-config-parser', () => ({ + loadUserSshConfig: vi.fn(() => null), + sshConfigHostsToTargets: vi.fn(() => []) +})) + +async function createStore(dataFile: string) { + vi.resetModules() + const { Store } = await import('./persistence') + return new Store({ dataFile }) +} + +function writeProfile(dataFile: string, settings: Record): void { + writeFileSync(dataFile, JSON.stringify({ settings }), { mode: 0o600 }) +} + +const PINNED_BACKUP_SUFFIX = '.pre-agent-catalog-v1.backup' + +describe('agent-catalog schema newer than this build', () => { + let dir = '' + let dataFile = '' + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'orca-agent-catalog-too-new-')) + testState.dir = dir + dataFile = join(dir, 'orca-data.json') + vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + afterEach(() => { + chmodSync(dir, 0o755) + rmSync(dir, { recursive: true, force: true }) + vi.restoreAllMocks() + }) + + it('latches the too-new state at load without a retryable migration error', async () => { + writeProfile(dataFile, { + defaultTuiAgent: 'codex', + agentCatalogSchemaVersion: 2, + agentCatalogRevision: 7 + }) + const store = await createStore(dataFile) + expect(store.getAgentCatalogSchemaTooNew()).toEqual({ + persistedVersion: 2, + supportedVersion: 1 + }) + // A too-new profile is not a blocked migration: retrying a backup fixes nothing. + expect(store.getAgentCatalogMigrationError()).toBeNull() + expect(store.retryAgentCatalogMigration()).toEqual({ ok: true }) + expect(store.getSettings().agentCatalogSchemaVersion).toBe(2) + expect(store.getSettings().agentCatalogRevision).toBe(7) + expect(existsSync(`${dataFile}${PINNED_BACKUP_SUFFIX}`)).toBe(false) + }) + + it('refuses durable authoring writes while the profile is too new', async () => { + writeProfile(dataFile, { + agentCatalogSchemaVersion: 3, + defaultTuiAgent: 'codex' + }) + const store = await createStore(dataFile) + expect(() => store.updateSettingsDurable({ defaultTuiAgent: 'auto' })).toThrow( + /newer than this build supports/ + ) + expect(store.getSettings().defaultTuiAgent).toBe('codex') + }) + + it('allows durable authoring writes on a supported profile', async () => { + writeProfile(dataFile, { + defaultTuiAgent: 'codex', + agentCatalogSchemaVersion: 1, + agentCatalogRevision: 1, + agentReferenceRevision: 1 + }) + const store = await createStore(dataFile) + expect(store.getAgentCatalogSchemaTooNew()).toBeNull() + expect(store.updateSettingsDurable({ defaultTuiAgent: 'auto' }).defaultTuiAgent).toBe('auto') + }) + + // Why skip on win32: chmod cannot make a directory read-only on Windows. + it.skipIf(process.platform === 'win32')( + 'converts a blocked migration retry into read-only when the profile turned out newer', + async () => { + writeFileSync(dataFile, JSON.stringify({ settings: { defaultTuiAgent: null } }), { + mode: 0o600 + }) + chmodSync(dir, 0o500) + const store = await createStore(dataFile) + chmodSync(dir, 0o755) + expect(store.getAgentCatalogMigrationError()).not.toBeNull() + + // A newer build's stamp reached this session (e.g. a synced profile) while + // the pinned backup was still owed. + store.updateSettings({ agentCatalogSchemaVersion: 4 }) + const retry = store.retryAgentCatalogMigration() + expect(retry.ok).toBe(false) + expect(retry.ok === false && retry.error).toMatch(/read-only/) + expect(store.getAgentCatalogSchemaTooNew()).toEqual({ + persistedVersion: 4, + supportedVersion: 1 + }) + // The retryable error is dropped: nothing about a backup can make it writable. + expect(store.getAgentCatalogMigrationError()).toBeNull() + expect(existsSync(`${dataFile}${PINNED_BACKUP_SUFFIX}`)).toBe(false) + } + ) +}) diff --git a/src/main/providers/ssh-pty-provider-launch-token-echo.test.ts b/src/main/providers/ssh-pty-provider-launch-token-echo.test.ts new file mode 100644 index 00000000000..4fcc4d79594 --- /dev/null +++ b/src/main/providers/ssh-pty-provider-launch-token-echo.test.ts @@ -0,0 +1,71 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { LAUNCH_TOKEN_ECHO_PROTOCOL_VERSION } from '../../shared/agent-launch-token-echo-protocol' +import { SshPtyProvider } from './ssh-pty-provider' + +describe('SSH launch-token echo negotiation', () => { + const request = vi.fn() + let provider: SshPtyProvider + + beforeEach(() => { + request.mockReset() + provider = new SshPtyProvider('conn-1', { + request, + notify: vi.fn(), + onNotification: vi.fn(), + dispose: vi.fn(), + isDisposed: vi.fn(() => false) + } as never) + }) + + function spawnParams(): Record { + const call = request.mock.calls.find(([method]) => method === 'pty.spawn') + return (call?.[1] ?? {}) as Record + } + + // New main + old relay: the relay accepts launchToken and never re-lists it, so a + // crash-recovery re-list would read the live agent as absent and Retry would duplicate it. + it('withholds the token from a relay that cannot echo it', async () => { + request.mockImplementation(async (method: string) => + method === 'pty.getCapabilities' ? {} : { id: 'pty-old', incarnationId: 'inc-old' } + ) + + await provider.spawn({ cols: 80, rows: 24, command: 'claude', launchToken: 'tok-1' }) + + expect('launchToken' in spawnParams()).toBe(false) + expect(provider.providesLaunchTokenListings()).toBe(false) + }) + + it('sends the token once the relay advertises the echo', async () => { + request.mockImplementation(async (method: string) => + method === 'pty.getCapabilities' + ? { launchTokenEchoVersion: LAUNCH_TOKEN_ECHO_PROTOCOL_VERSION } + : { id: 'pty-new', incarnationId: 'inc-new' } + ) + + await provider.spawn({ cols: 80, rows: 24, command: 'claude', launchToken: 'tok-2' }) + + expect(spawnParams().launchToken).toBe('tok-2') + expect(provider.providesLaunchTokenListings()).toBe(true) + }) + + // Old main + new relay: the advertisement is purely additive, so a tokenless spawn + // (all an old main ever sends) stays byte-for-byte what it was and probes nothing. + it('leaves a tokenless spawn unprobed and unchanged', async () => { + request.mockResolvedValue({ id: 'pty-plain', incarnationId: 'inc-plain' }) + + await provider.spawn({ cols: 80, rows: 24, command: 'claude' }) + + expect(request.mock.calls.map(([method]) => method)).toEqual(['pty.spawn']) + expect('launchToken' in spawnParams()).toBe(false) + }) + + it('re-probes a negative echo capability after an in-place relay upgrade', async () => { + request.mockResolvedValueOnce({}).mockResolvedValueOnce({ + launchTokenEchoVersion: LAUNCH_TOKEN_ECHO_PROTOCOL_VERSION + }) + + await expect(provider.supportsLaunchTokenEcho()).resolves.toBe(false) + await expect(provider.supportsLaunchTokenEcho()).resolves.toBe(true) + expect(request).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/main/runtime/mobile-rpc-allowlist.test.ts b/src/main/runtime/mobile-rpc-allowlist.test.ts index adf9223b39a..134afc530ce 100644 --- a/src/main/runtime/mobile-rpc-allowlist.test.ts +++ b/src/main/runtime/mobile-rpc-allowlist.test.ts @@ -11,6 +11,9 @@ const MOBILE_DYNAMIC_RPC_METHODS = [ 'accounts.selectCodexForTarget', 'terminal.createAgentSession', 'terminal.ensureAgentSession', + // agent-catalog-sync passes the dedicated read and its legacy settings.get + // fallback through one sender, so neither is a literal sendRequest argument. + 'settings.agentCatalog.get', 'github.updateIssue', 'github.updatePRState', 'gitlab.updateIssue', diff --git a/src/main/runtime/orca-runtime-agent-launch-device-principal.test.ts b/src/main/runtime/orca-runtime-agent-launch-device-principal.test.ts new file mode 100644 index 00000000000..f6e5a59f4bd Binary files /dev/null and b/src/main/runtime/orca-runtime-agent-launch-device-principal.test.ts differ diff --git a/src/main/runtime/orca-runtime-agent-launch.test.ts b/src/main/runtime/orca-runtime-agent-launch.test.ts index fa99e6872e8..6d4c2993731 100644 --- a/src/main/runtime/orca-runtime-agent-launch.test.ts +++ b/src/main/runtime/orca-runtime-agent-launch.test.ts @@ -7,6 +7,11 @@ import { OrcaRuntimeService } from './orca-runtime' import { resolveTerminalAgentLaunch } from './terminal-agent-launch-resolution' import { buildClaudeAgentTeamsLaunchPlan } from './claude-agent-teams-shim-env' import { getHostAgentLaunchBoundary } from '../agent-launch/agent-launch-boundary-host' +import { + executionHostIdForDescriptor, + platformForDescriptor, + type AgentLaunchHostDescriptor +} from '../agent-launch/agent-launch-host-state' vi.mock('electron', () => ({ BrowserWindow: { fromId: vi.fn(() => null) }, @@ -158,3 +163,53 @@ describe('createTerminal host-resolved agentLaunch', () => { }) }) }) + +// A WSL surface classified native/local host-ids as `local`, which resolves +// execution 'native' and leaves Windows/UNC {repoPath} values in the Linux argv. +describe('WSL execution-host classification', () => { + type DescriptorInternals = { + buildTerminalAgentLaunchDescriptor: ( + workspace: Record + ) => AgentLaunchHostDescriptor + buildRepoAgentLaunchDescriptor: (repo: Record) => AgentLaunchHostDescriptor + } + + function makeDescriptorRuntime(): DescriptorInternals { + const runtime = new OrcaRuntimeService({ + getSettings: () => ({}), + getProjects: () => [] + } as never) + return runtime as unknown as DescriptorInternals + } + + function workspace(path: string): Record { + return { id: 'wt-1', path, connectionId: null, repo: null, folderWorkspace: null } + } + + it('describes a WSL UNC workspace as its own wsl host, never local', () => { + const descriptor = makeDescriptorRuntime().buildTerminalAgentLaunchDescriptor( + workspace('\\\\wsl.localhost\\Ubuntu\\home\\me\\app') + ) + + expect(descriptor).toEqual({ kind: 'wsl', distro: 'Ubuntu' }) + expect(executionHostIdForDescriptor(descriptor)).toBe('wsl:Ubuntu') + expect(platformForDescriptor(descriptor)).toBe('linux') + }) + + it('describes a WSL UNC repo as a wsl host at worktree-create time', () => { + const descriptor = makeDescriptorRuntime().buildRepoAgentLaunchDescriptor({ + id: 'r1', + path: '\\\\wsl$\\Debian\\srv\\app', + connectionId: null + }) + + expect(descriptor).toEqual({ kind: 'wsl', distro: 'Debian' }) + expect(executionHostIdForDescriptor(descriptor)).toBe('wsl:Debian') + }) + + it('keeps an ordinary local workspace on the local host', () => { + expect( + makeDescriptorRuntime().buildTerminalAgentLaunchDescriptor(workspace('/repo/app')) + ).toMatchObject({ kind: 'local' }) + }) +}) diff --git a/src/main/runtime/orca-runtime-launch-reconcile-liveness.test.ts b/src/main/runtime/orca-runtime-launch-reconcile-liveness.test.ts index 83652cc776e..c9a6e01114d 100644 --- a/src/main/runtime/orca-runtime-launch-reconcile-liveness.test.ts +++ b/src/main/runtime/orca-runtime-launch-reconcile-liveness.test.ts @@ -62,9 +62,19 @@ type Internals = { filter?: (pending: PendingAgentLaunchSnapshot) => boolean, relistedTokenPtyIds?: ReadonlyMap ) => void + refreshPtyWorktreeRecordsWithControllerInventory: ( + resolvedWorktrees: unknown[], + targetWorktreeId: string | null, + deadline?: number, + connectionId?: string | null + ) => Promise } -function makeRuntime(): { internals: Internals; metaWrites: Record[] } { +function makeRuntime(): { + runtime: OrcaRuntimeService + internals: Internals + metaWrites: Record[] +} { const runtime = new OrcaRuntimeService() const internals = runtime as unknown as Internals const metaWrites: Record[] = [] @@ -77,7 +87,7 @@ function makeRuntime(): { internals: Internals; metaWrites: Record ({}) } - return { internals, metaWrites } + return { runtime, internals, metaWrites } } afterEach(() => { @@ -141,3 +151,57 @@ describe('reconcilePendingAgentLaunches liveness composition', () => { expect(opStore.isSpawnInFlight('tok-spawning')).toBe(false) }) }) + +// A scoped SSH re-list that SUCCEEDS speaks for its own connection even when it +// returns zero sessions. Deriving the authoritative scope from the response rows +// instead left such a host "unknown" forever: its pending held capacity and could +// never be retried. +describe('scoped SSH re-list authority', () => { + function makeSshRuntime(listProcesses: () => Promise) { + const made = makeRuntime() + made.runtime.setPtyController({ + listProcesses, + hasPty: () => false + } as never) + return made + } + + it('settles a pending on a successful but empty scoped re-list', async () => { + const opStore = getHostAgentLaunchOperationStore() + opStore.rebuildPendingFrom([pending('tok-ssh-empty')]) + const { internals } = makeSshRuntime(async () => []) + + await internals.refreshPtyWorktreeRecordsWithControllerInventory([], null, undefined, 'host-a') + + expect(opStore.getPending('tok-ssh-empty')).toBeNull() + expect(opStore.findSettledByIdempotencyKey(WORKTREE_ID, 'key-tok-ssh-empty')).toMatchObject({ + status: 'failed' + }) + }) + + it('leaves the pending untouched when the scoped re-list fails', async () => { + const opStore = getHostAgentLaunchOperationStore() + opStore.rebuildPendingFrom([pending('tok-ssh-failed')]) + const { internals, metaWrites } = makeSshRuntime(async () => { + throw new Error('relay down') + }) + + await internals.refreshPtyWorktreeRecordsWithControllerInventory([], null, undefined, 'host-a') + + // "Re-list failed" is not evidence of absence: retryable, still holding its slot. + expect(opStore.getPending('tok-ssh-failed')).not.toBeNull() + expect(opStore.findSettledByIdempotencyKey(WORKTREE_ID, 'key-tok-ssh-failed')).toBeNull() + expect(metaWrites).toEqual([]) + }) + + it('does not let one connection speak for another SSH host', async () => { + const opStore = getHostAgentLaunchOperationStore() + opStore.rebuildPendingFrom([pending('tok-other-host')]) + const { internals } = makeSshRuntime(async () => []) + + await internals.refreshPtyWorktreeRecordsWithControllerInventory([], null, undefined, 'host-b') + + // The pending targets ssh:host-a; host-b's empty list says nothing about it. + expect(opStore.getPending('tok-other-host')).not.toBeNull() + }) +}) diff --git a/src/main/runtime/orca-runtime-launch-token-echo-authority.test.ts b/src/main/runtime/orca-runtime-launch-token-echo-authority.test.ts new file mode 100644 index 00000000000..fe2ff68f1b6 --- /dev/null +++ b/src/main/runtime/orca-runtime-launch-token-echo-authority.test.ts @@ -0,0 +1,205 @@ +// P1-4: a peer that predates the launch-token echo (pre-v34 daemon, old SSH +// relay) accepts `launchToken` on create and silently drops it, so its re-list +// can NEVER carry one. Reading that missing echo as absence settled spawn_failed +// for a launch the peer was still running, and the user's Retry then spawned a +// DUPLICATE agent beside it. The runtime must therefore AND its re-list +// authority with the provider's token-echo authority. + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' +import { getHostAgentLaunchOperationStore } from '../agent-launch/agent-launch-operation-store-host' +import { retryRecoveryGateForFailureCode } from '../agent-launch/agent-launch-reconciliation' +import type { PendingAgentLaunchSnapshot } from '../agent-launch/agent-launch-operation-store' +import type { AgentLaunchSnapshot } from '../../shared/agent-launch-host-contract' +import type { IPtyProvider } from '../providers/types' + +vi.mock('electron', () => ({ + BrowserWindow: { fromId: vi.fn(() => null) }, + webContents: { fromId: vi.fn(() => null) }, + ipcMain: { on: vi.fn(), removeListener: vi.fn() }, + app: { getPath: vi.fn(() => '/tmp') } +})) + +const WORKTREE_ID = 'r1::/wt-a' +const CONNECTION_ID = 'host-a' +const LIVE_SESSION_ID = `ssh:${CONNECTION_ID}@@pty-1` + +function snapshot(): AgentLaunchSnapshot { + return { + version: 1, + requestedAgent: 'claude', + baseAgent: 'claude', + displayLabel: 'Claude', + mode: 'built-in', + argv: ['claude'], + agentEnv: {}, + capturedEnvPolicy: 'none', + target: { + platform: 'linux', + execution: 'native', + shell: 'posix', + isRemote: true, + executionHostId: `ssh:${CONNECTION_ID}` + } + } +} + +function pending(token: string): PendingAgentLaunchSnapshot { + return { + operationId: `op-${token}`, + idempotencyKey: `key-${token}`, + scope: WORKTREE_ID, + clientMutationId: null, + payloadDigest: 'digest-1', + launchToken: token, + intent: 'interactive', + principal: { kind: 'local' }, + snapshot: snapshot() + } +} + +type Internals = { + store: unknown + refreshPtyWorktreeRecordsWithControllerInventory: ( + resolvedWorktrees: unknown[], + targetWorktreeId: string | null, + deadline?: number, + connectionId?: string | null + ) => Promise +} + +/** A relay re-list that returns `sessions`, fronted by an SSH provider that does + * or does not echo launch tokens back in its listings. */ +function makeRuntime(args: { echoesLaunchTokens: boolean; sessions: unknown[] }): { + internals: Internals + metaWrites: Record[] +} { + const provider = { + providesLaunchTokenListings: () => args.echoesLaunchTokens + } as unknown as IPtyProvider + const runtime = new OrcaRuntimeService(null, undefined, { + getSshProvider: () => provider + }) + const internals = runtime as unknown as Internals + const metaWrites: Record[] = [] + internals.store = { + getWorktreeMeta: () => ({}), + setWorkspaceSession: () => {}, + setWorktreeMeta: (_id: string, meta: Record) => { + metaWrites.push(meta) + return meta + }, + getSettings: () => ({}) + } + runtime.setPtyController({ + listProcesses: async () => args.sessions, + hasPty: () => false, + getForegroundProcess: async () => null + } as never) + return { internals, metaWrites } +} + +/** One live session the peer lists WITHOUT a launchToken (it dropped the one it + * was handed) — the surviving agent the pending launch belongs to. */ +function tokenlessLiveSession(worktreeId: string | null): Record { + return { + id: LIVE_SESSION_ID, + cwd: '/remote/wt-a', + title: 'claude', + ...(worktreeId ? { worktreeId } : {}) + } +} + +afterEach(() => { + // The host operation store is a singleton; leave nothing behind for other suites. + getHostAgentLaunchOperationStore().rebuildPendingFrom([]) + getHostAgentLaunchOperationStore().rebuildSettledFrom([]) +}) + +describe('launch-token echo authority in reconcile', () => { + it('holds the launch pending when the host cannot echo tokens (no duplicate on Retry)', async () => { + const opStore = getHostAgentLaunchOperationStore() + opStore.rebuildPendingFrom([pending('tok-legacy')]) + const { internals, metaWrites } = makeRuntime({ + echoesLaunchTokens: false, + sessions: [tokenlessLiveSession(WORKTREE_ID)] + }) + + await internals.refreshPtyWorktreeRecordsWithControllerInventory( + [], + null, + undefined, + CONNECTION_ID + ) + + // Never settled: the reservation and private snapshot survive for a real proof. + expect(opStore.getPending('tok-legacy')).not.toBeNull() + expect(opStore.findSettledByIdempotencyKey(WORKTREE_ID, 'key-tok-legacy')).toBeNull() + const failure = metaWrites.at(-1)?.agentLaunchFailure as { code?: string } | undefined + expect(failure?.code).toBe('launch_state_unknown') + // The Retry that duplicated the live agent is gated behind an explicit Forget. + expect(retryRecoveryGateForFailureCode('launch_state_unknown')).toEqual({ + kind: 'launch_state_unknown' + }) + }) + + it('settles absent on the same re-list when the host DOES echo tokens', async () => { + const opStore = getHostAgentLaunchOperationStore() + opStore.rebuildPendingFrom([pending('tok-v34')]) + const { internals } = makeRuntime({ + echoesLaunchTokens: true, + sessions: [tokenlessLiveSession(WORKTREE_ID)] + }) + + await internals.refreshPtyWorktreeRecordsWithControllerInventory( + [], + null, + undefined, + CONNECTION_ID + ) + + // A v34 peer lists every token it holds, so a missing echo IS absence proof. + expect(opStore.getPending('tok-v34')).toBeNull() + expect(opStore.findSettledByIdempotencyKey(WORKTREE_ID, 'key-tok-v34')).toMatchObject({ + status: 'failed' + }) + }) + + it('still settles a non-echoing host absent when nothing live remains in the worktree', async () => { + const opStore = getHostAgentLaunchOperationStore() + opStore.rebuildPendingFrom([pending('tok-gone')]) + const { internals } = makeRuntime({ echoesLaunchTokens: false, sessions: [] }) + + await internals.refreshPtyWorktreeRecordsWithControllerInventory( + [], + null, + undefined, + CONNECTION_ID + ) + + // Pre-token identification still proves absence, so recovery is not stranded. + expect(opStore.findSettledByIdempotencyKey(WORKTREE_ID, 'key-tok-gone')).toMatchObject({ + status: 'failed' + }) + }) + + it('holds the launch when a non-echoing host lists a session it cannot attribute', async () => { + const opStore = getHostAgentLaunchOperationStore() + opStore.rebuildPendingFrom([pending('tok-unattributed')]) + const { internals } = makeRuntime({ + echoesLaunchTokens: false, + sessions: [tokenlessLiveSession(null)] + }) + + await internals.refreshPtyWorktreeRecordsWithControllerInventory( + [], + null, + undefined, + CONNECTION_ID + ) + + // The session never enters ptysById and carries no token: it may BE this launch. + expect(opStore.getPending('tok-unattributed')).not.toBeNull() + expect(opStore.findSettledByIdempotencyKey(WORKTREE_ID, 'key-tok-unattributed')).toBeNull() + }) +}) diff --git a/src/main/runtime/orca-runtime-terminal-launch-device-principal.test.ts b/src/main/runtime/orca-runtime-terminal-launch-device-principal.test.ts new file mode 100644 index 00000000000..5a22e38747b --- /dev/null +++ b/src/main/runtime/orca-runtime-terminal-launch-device-principal.test.ts @@ -0,0 +1,158 @@ +// P1-14 (ninth principal site): a terminal-create agentLaunch resolves against +// the PAIRED DEVICE's admission principal, so two phones cannot spend one +// launch-capacity bucket. An unpaired/local caller keeps the coarse principal — +// the bucket pre-device persisted rows were counted in. +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' +import { resolveTerminalAgentLaunch } from './terminal-agent-launch-resolution' +import { + AgentLaunchAdmissionStore, + MAX_PENDING_LAUNCHES_PER_PRINCIPAL, + admissionPrincipalOwns, + type AdmissionPrincipal +} from '../agent-launch/agent-launch-admission-store' +import type { AgentLaunchSnapshot } from '../../shared/agent-launch-host-contract' + +vi.mock('electron', () => ({ + BrowserWindow: { fromId: vi.fn(() => null) }, + webContents: { fromId: vi.fn(() => null) }, + ipcMain: { on: vi.fn(), removeListener: vi.fn() }, + app: { getPath: vi.fn(() => '/tmp') } +})) + +vi.mock('./terminal-agent-launch-resolution', async (importOriginal) => { + const actual = await importOriginal>() + return { + ...actual, + // A typed pre-spawn failure short-circuits both create paths after the + // principal has already been built — all this test cares about. + resolveTerminalAgentLaunch: vi.fn(async () => ({ + kind: 'failed', + outcome: { + status: 'failed', + failure: { code: 'base_agent_disabled', baseAgent: 'claude' } + } + })) + } +}) + +const resolveMock = vi.mocked(resolveTerminalAgentLaunch) + +type Internals = { + resolveAgentTerminalCreateOptions: (workspace: unknown, opts: unknown) => Promise + resolveMobileTerminalStartup: (workspace: unknown, opts: unknown) => Promise +} + +const WORKSPACE = { id: 'wt-1', path: '/wt', connectionId: null, repo: null } +const AGENT_LAUNCH = { selection: { kind: 'agent', agent: 'claude' } } + +function makeRuntime(): Internals { + const runtime = new OrcaRuntimeService() + ;(runtime as unknown as { store: unknown }).store = { + getSettings: () => ({}) + } + return runtime as unknown as Internals +} + +async function principalFor( + path: 'terminal' | 'mobile', + opts: { clientKind?: 'mobile' | 'runtime'; deviceId?: string } +): Promise { + resolveMock.mockClear() + const internals = makeRuntime() + const createOpts = { agentLaunch: AGENT_LAUNCH, ...opts } + await (path === 'terminal' + ? internals.resolveAgentTerminalCreateOptions(WORKSPACE, createOpts) + : internals.resolveMobileTerminalStartup(WORKSPACE, createOpts)) + return (resolveMock.mock.calls[0]![1] as { principal: AdmissionPrincipal }).principal +} + +function snapshot(): AgentLaunchSnapshot { + return { + baseAgent: 'claude', + target: { executionHostId: 'local' } + } as unknown as AgentLaunchSnapshot +} + +function admit( + store: AgentLaunchAdmissionStore, + principal: AdmissionPrincipal, + scope: string +): boolean { + return store.admit({ + principal, + intent: 'interactive', + scope, + worktreeId: null, + fingerprint: 'fp', + snapshot: snapshot(), + admittedAt: 1 + }).ok +} + +describe.each(['terminal', 'mobile'] as const)( + '%s terminal-create agentLaunch admission principal', + (path) => { + it('scopes the principal to the authenticated paired device', async () => { + expect( + await principalFor(path, { + clientKind: 'mobile', + deviceId: 'device-a' + }) + ).toEqual({ + kind: 'remote', + id: 'mobile', + deviceId: 'device-a' + }) + }) + + it('keeps the coarse principal when the transport carries no paired device', async () => { + // Legacy/pre-device rows were persisted under this exact key; an + // `undefined`-valued deviceId would fork it. + expect(await principalFor(path, { clientKind: 'mobile' })).toEqual({ + kind: 'remote', + id: 'mobile' + }) + }) + + it('is local for an in-process desktop caller', async () => { + expect(await principalFor(path, {})).toEqual({ kind: 'local' }) + }) + + it('gives two paired devices isolated capacity buckets', async () => { + const deviceA = await principalFor(path, { + clientKind: 'mobile', + deviceId: 'device-a' + }) + const deviceB = await principalFor(path, { + clientKind: 'mobile', + deviceId: 'device-b' + }) + const store = new AgentLaunchAdmissionStore() + + for (let i = 0; i < MAX_PENDING_LAUNCHES_PER_PRINCIPAL; i += 1) { + expect(admit(store, deviceA, `wt-a-${i}`)).toBe(true) + } + + expect(admit(store, deviceA, 'wt-a-over')).toBe(false) + // Before the device id was threaded, both phones shared `remote:mobile` + // and the second device was rejected here. + expect(admit(store, deviceB, 'wt-b-1')).toBe(true) + }) + + it('still owns a legacy coarse-principal row admitted before device scoping', async () => { + const device = await principalFor(path, { + clientKind: 'mobile', + deviceId: 'device-a' + }) + const store = new AgentLaunchAdmissionStore() + const legacy: AdmissionPrincipal = { kind: 'remote', id: 'mobile' } + expect(admit(store, legacy, 'wt-legacy')).toBe(true) + + // Forget/recovery surfaces join through ownership, so a pre-upgrade row + // must stay visible and claimable by any device of its kind. + expect(admissionPrincipalOwns(device, legacy)).toBe(true) + expect(store.capacitySummaryFor(device).map((row) => row.scope)).toContain('wt-legacy') + }) + } +) diff --git a/src/main/runtime/rpc/methods/agent-launch-schema.test.ts b/src/main/runtime/rpc/methods/agent-launch-schema.test.ts index 318575db860..5b7031e8416 100644 --- a/src/main/runtime/rpc/methods/agent-launch-schema.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-schema.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { CreateTerminalTab } from './session-tabs-schemas' import { AgentLaunchInputSchema, AgentLaunchSpawnRequestSchema } from './agent-launch-spawn-schema' +import { MAX_AGENT_ARGS_CODE_UNITS } from '../../../../shared/custom-tui-agent-fields' const CUSTOM_ID = 'custom-agent:claude:11111111-2222-4333-8444-555555555555' @@ -88,6 +89,50 @@ describe('agentLaunch unattended declaration', () => { }) }) +// P1-24: unsaved recipe arg edits ride the launch as client text, so they must +// survive parsing AND be length-bounded like the stored recipe args. +describe('agentLaunch unsaved recipe args', () => { + it('preserves client-supplied unsaved args instead of stripping them', () => { + const parsed = AgentLaunchSpawnRequestSchema.safeParse({ + selection: { kind: 'agent', agent: 'claude' }, + sourceRecord: { owner: 'source-control-recipe', id: 'fixChecks' }, + unsavedAgentArgs: '--model sonnet' + }) + expect(parsed.success).toBe(true) + expect(parsed.success && parsed.data.unsavedAgentArgs).toBe('--model sonnet') + }) + + it('accepts an empty string (the user cleared the args)', () => { + const parsed = AgentLaunchSpawnRequestSchema.safeParse({ + selection: { kind: 'agent', agent: 'claude' }, + sourceRecord: { owner: 'source-control-recipe', id: 'fixChecks' }, + unsavedAgentArgs: '' + }) + expect(parsed.success).toBe(true) + expect(parsed.success && parsed.data.unsavedAgentArgs).toBe('') + }) + + it('rejects oversized unsaved args', () => { + const parsed = AgentLaunchSpawnRequestSchema.safeParse({ + selection: { kind: 'agent', agent: 'claude' }, + sourceRecord: { owner: 'source-control-recipe', id: 'fixChecks' }, + unsavedAgentArgs: 'a'.repeat(MAX_AGENT_ARGS_CODE_UNITS + 1) + }) + expect(parsed.success).toBe(false) + }) + + it('rejects a non-string on the CreateTerminalTab agentLaunch field', () => { + const parsed = CreateTerminalTab.safeParse({ + worktree: 'w1', + agentLaunch: { + selection: { kind: 'agent', agent: 'claude' }, + unsavedAgentArgs: ['--model', 'sonnet'] + } + }) + expect(parsed.success).toBe(false) + }) +}) + describe('agentLaunch resume/fork variant', () => { const validKey = { worktreeId: 'wt-1', baseAgent: 'claude', providerSessionId: 'sess-1' } diff --git a/src/main/runtime/rpc/methods/agent-launch-spawn-schema.ts b/src/main/runtime/rpc/methods/agent-launch-spawn-schema.ts index 70a016d64ee..19b543d36e4 100644 --- a/src/main/runtime/rpc/methods/agent-launch-spawn-schema.ts +++ b/src/main/runtime/rpc/methods/agent-launch-spawn-schema.ts @@ -19,6 +19,7 @@ import { type ResumableTuiAgent } from '../../../../shared/agent-session-resume' import { AI_VAULT_AGENTS, type AiVaultAgent } from '../../../../shared/ai-vault-types' +import { MAX_AGENT_ARGS_CODE_UNITS } from '../../../../shared/custom-tui-agent-fields' import { parseExecutionHostId } from '../../../../shared/execution-host' const AgentLaunchSelection = z.union([ @@ -47,6 +48,9 @@ export const AgentLaunchSpawnRequestSchema: z.ZodType = allowEmptyPromptLaunch: z.boolean().optional(), promptDelivery: z.enum(['submit', 'draft']).optional(), sourceRecord: AgentLaunchSourceRecord.optional(), + // Untrusted client text that substitutes for the stored recipe args, so it gets + // the same length ceiling those args are save-validated against. + unsavedAgentArgs: z.string().max(MAX_AGENT_ARGS_CODE_UNITS).optional(), // Why: without this, an unrecognized key would be silently stripped by Zod's // default object parsing, downgrading a caller's declared unattended/ // background launch to interactive instead of failing closed. diff --git a/src/main/runtime/rpc/methods/client-ui-agent-authoring-boundary.test.ts b/src/main/runtime/rpc/methods/client-ui-agent-authoring-boundary.test.ts index 301a61017eb..12a99d263a9 100644 --- a/src/main/runtime/rpc/methods/client-ui-agent-authoring-boundary.test.ts +++ b/src/main/runtime/rpc/methods/client-ui-agent-authoring-boundary.test.ts @@ -101,6 +101,8 @@ describe('client UI settings agent-authoring boundary', () => { const names = CLIENT_UI_METHODS.map((method) => method.name) const settingsMethods = names.filter((name) => name.startsWith('settings.')) expect(settingsMethods.sort()).toEqual([ + // Read-only catalog fetch; authoring stays desktop preload IPC. + 'settings.agentCatalog.get', 'settings.agentReferences.get', 'settings.get', // Terminal quick commands are plain settings state, not agent authoring. @@ -174,4 +176,56 @@ describe('client UI settings agent-authoring boundary', () => { expect(strings).not.toContain('SECRET_TOKEN') expect(strings).not.toContain('super-secret-value') }) + + it('omits the catalog from settings.get when the client opts out, and serves it standalone', async () => { + const catalogSettings = { + customTuiAgents: [ + { + id: 'custom-agent:codex:01234567-89ab-4cde-8f01-23456789abcd', + baseAgent: 'codex', + label: 'Secret Codex', + args: '', + env: { SECRET_TOKEN: 'super-secret-value' }, + syncEnv: true + } + ], + agentCatalogRevision: 7 + } as unknown as GlobalSettings + const runtime = { + getRuntimeId: () => 'test-runtime', + getClientSettings: vi.fn(() => ({ defaultTaskSource: 'github' })), + getAgentCatalogSnapshot: vi.fn(() => buildAgentCatalogSnapshot(catalogSettings)), + getAgentReferenceRevision: vi.fn(() => 4) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: CLIENT_UI_METHODS }) + + const opted = await dispatcher.dispatch( + makeRequest('settings.get', { includeAgentCatalog: false }) + ) + expect(opted.ok).toBe(true) + const optedResult = (opted as { result: Record }).result + expect(optedResult).not.toHaveProperty('agentCatalog') + expect(optedResult.agentReferences).toEqual({ version: 1, revision: 4 }) + expect(runtime.getAgentCatalogSnapshot).not.toHaveBeenCalled() + + // Explicit true and omitted both keep the legacy piggyback for old clients. + const included = await dispatcher.dispatch( + makeRequest('settings.get', { includeAgentCatalog: true }) + ) + expect((included as { result: Record }).result.agentCatalog).toMatchObject({ + version: 1, + revision: 7 + }) + + const standalone = await dispatcher.dispatch(makeRequest('settings.agentCatalog.get')) + expect(standalone.ok).toBe(true) + const standaloneResult = (standalone as { result: Record }).result + expect(standaloneResult.agentCatalog).toMatchObject({ version: 1, revision: 7 }) + expect(standaloneResult).not.toHaveProperty('settings') + + const strings: string[] = [] + collectStringsAndKeys(standaloneResult, strings) + expect(strings).not.toContain('SECRET_TOKEN') + expect(strings).not.toContain('super-secret-value') + }) }) diff --git a/src/main/runtime/rpc/methods/client-ui.ts b/src/main/runtime/rpc/methods/client-ui.ts index 27a2c16ed2e..cb78b1be360 100644 --- a/src/main/runtime/rpc/methods/client-ui.ts +++ b/src/main/runtime/rpc/methods/client-ui.ts @@ -1,8 +1,14 @@ import { omitPairingLocalUiFields } from '../../../../shared/pairing-local-ui-fields' +import { z } from 'zod' import type { PersistedUIState } from '../../../../shared/persisted-ui-state-types' import { defineMethod, type RpcMethod } from '../core' -import { PRBotAuthorOverrideUpdate, SettingsUpdate } from './client-settings-schemas' -import { FeatureInteractionIdParam, UiUpdate } from './client-ui-schemas' +import { OptionalBoolean } from '../schemas' +import { + FeatureInteractionIdParam, + PRBotAuthorOverrideUpdate, + SettingsUpdate, + UiUpdate +} from './client-ui-schemas' // Type-only side effect: keeps the schema/PersistedUIState parity assertions in // the typecheck graph so drift fails the build instead of a paired client. @@ -30,18 +36,33 @@ const AGENT_REJECTED_SETTINGS_UPDATE_KEYS = [ 'sourceControlAi' ] as const +// Why: the catalog projection is budgeted at 512 KiB, so a client that only wants +// settings opts out and fetches it from settings.agentCatalog.get instead. Omitted +// keeps the legacy piggyback: an old client has no other way to read the catalog. +const SettingsGet = z.object({ includeAgentCatalog: OptionalBoolean }).strict() + export const CLIENT_UI_METHODS: RpcMethod[] = [ defineMethod({ name: 'settings.get', - params: null, - handler: (_params, { runtime }) => ({ + params: SettingsGet, + handler: (params, { runtime }) => ({ settings: runtime.getClientSettings(), - agentCatalog: runtime.getAgentCatalogSnapshot(), + ...(params.includeAgentCatalog === false + ? {} + : { agentCatalog: runtime.getAgentCatalogSnapshot() }), // Small capability descriptor; the full snapshot ships from // settings.agentReferences.get so the two never compete under one frame. agentReferences: { version: 1 as const, revision: runtime.getAgentReferenceRevision() } }) }), + defineMethod({ + name: 'settings.agentCatalog.get', + params: null, + // Why: dedicated read so the catalog rides its own frame instead of every + // unrelated settings.get; a client that gets method_not_found here is talking + // to an old host and falls back to the settings.get piggyback. + handler: (_params, { runtime }) => ({ agentCatalog: runtime.getAgentCatalogSnapshot() }) + }), defineMethod({ name: 'settings.agentReferences.get', params: null, diff --git a/src/main/runtime/rpc/methods/session-tabs.ts b/src/main/runtime/rpc/methods/session-tabs.ts index 903701c81e4..4e4fef1aaae 100644 --- a/src/main/runtime/rpc/methods/session-tabs.ts +++ b/src/main/runtime/rpc/methods/session-tabs.ts @@ -71,7 +71,9 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [ ...(params.launchAgent ? { launchAgent: params.launchAgent } : {}), ...(params.viewMode ? { viewMode: params.viewMode } : {}), // Authenticated RPC scope for host-resolved launches; never client JSON. + // The paired device narrows the admission principal to this device. clientKind, + ...(pairedDeviceId ? { deviceId: pairedDeviceId } : {}), activate: params.activate, select: params.select, clientNavigationId: pairedDeviceId, diff --git a/src/main/runtime/rpc/methods/terminal-create-device-scope.test.ts b/src/main/runtime/rpc/methods/terminal-create-device-scope.test.ts new file mode 100644 index 00000000000..adcbc0589de --- /dev/null +++ b/src/main/runtime/rpc/methods/terminal-create-device-scope.test.ts @@ -0,0 +1,97 @@ +// P1-14: both terminal-create surfaces must carry the AUTHENTICATED paired +// device into the runtime create options, or two phones share one launch +// admission principal (and one capacity bucket). The device id comes from the +// envelope only — never from params. +import { describe, expect, it, vi } from 'vitest' +import { isStreamingMethod, type RpcAnyMethod, type RpcContext, type RpcMethod } from '../core' +import { TERMINAL_METHODS } from './terminal' +import { SESSION_TAB_METHODS } from './session-tabs' + +const AGENT_LAUNCH = { + selection: { kind: 'agent', agent: 'claude' }, + prompt: 'hi' +} + +function method(methods: readonly RpcAnyMethod[], name: string): RpcMethod { + const found = methods.find((candidate) => candidate.name === name) + if (!found || isStreamingMethod(found)) { + throw new Error(`${name} method missing`) + } + return found +} + +async function createTerminalOpts( + ctx: Partial, + params: Record = {} +): Promise> { + const createTerminal = vi.fn(async (_worktree: unknown, opts: Record) => ({ + handle: 'terminal-1', + worktreeId: 'wt-1', + opts + })) + await method(TERMINAL_METHODS, 'terminal.create').handler( + { worktree: 'id:wt-1', agentLaunch: AGENT_LAUNCH, ...params }, + { runtime: { createTerminal }, ...ctx } as unknown as RpcContext + ) + return createTerminal.mock.calls[0]![1] +} + +async function createMobileTabOpts( + ctx: Partial, + params: Record = {} +): Promise> { + const createMobileSessionTerminal = vi.fn( + async (_worktree: unknown, opts: Record) => ({ + tab: { id: 'tab-1::leaf-1' }, + opts + }) + ) + await method(SESSION_TAB_METHODS, 'session.tabs.createTerminal').handler( + { worktree: 'id:wt-1', agentLaunch: AGENT_LAUNCH, ...params }, + { + runtime: { createMobileSessionTerminal }, + ...ctx + } as unknown as RpcContext + ) + return createMobileSessionTerminal.mock.calls[0]![1] +} + +const PATHS: [ + string, + (ctx: Partial, params?: Record) => Promise> +][] = [ + ['terminal.create', createTerminalOpts], + ['session.tabs.createTerminal', createMobileTabOpts] +] + +describe.each(PATHS)('%s device-scoped agent-launch admission', (_name, optsFor) => { + it('forwards the authenticated paired device alongside clientKind', async () => { + const opts = await optsFor({ + clientKind: 'mobile', + pairedDeviceId: 'device-a' + }) + + expect(opts).toMatchObject({ + clientKind: 'mobile', + deviceId: 'device-a' + }) + }) + + it('omits deviceId (never undefined-valued) when no device is paired', async () => { + // An omitted id keeps the coarse principal pre-device rows were admitted + // under, so they stay forgettable after the upgrade. + const opts = await optsFor({ clientKind: 'mobile' }) + + expect(opts.clientKind).toBe('mobile') + expect('deviceId' in opts).toBe(false) + }) + + it('takes the device id from the envelope, never from client params', async () => { + const opts = await optsFor( + { clientKind: 'mobile', pairedDeviceId: 'device-a' }, + { deviceId: 'device-victim' } + ) + + expect(opts.deviceId).toBe('device-a') + }) +}) diff --git a/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts b/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts index fb95c19ddb4..6bf77ed4929 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts @@ -49,6 +49,7 @@ export const TERMINAL_LIFECYCLE_METHODS: RpcAnyMethod[] = [ ? { terminalColorQueryReplies: params.terminalColorQueryReplies } : {}), clientKind, + ...(pairedDeviceId ? { deviceId: pairedDeviceId } : {}), title: params.title, focus: params.focus === true, rendererBacked: params.rendererBacked === true, diff --git a/src/main/runtime/rpc/methods/worktree-agent-launch-recovery-device-scope.test.ts b/src/main/runtime/rpc/methods/worktree-agent-launch-recovery-device-scope.test.ts new file mode 100644 index 00000000000..ef120fd1d77 --- /dev/null +++ b/src/main/runtime/rpc/methods/worktree-agent-launch-recovery-device-scope.test.ts @@ -0,0 +1,103 @@ +// P1-14: every agent-launch recovery method forwards the AUTHENTICATED +// pairedDeviceId alongside clientKind, so the runtime can scope caps, recovery +// rows, and idempotency to the calling device rather than to its client kind. +// The device id comes from the authenticated envelope only — never from params. +import { describe, expect, it, vi } from 'vitest' +import type { RpcContext } from '../core' +import { WORKTREE_AGENT_LAUNCH_RECOVERY_METHODS } from './worktree-agent-launch-recovery-methods' + +const CANONICAL_UUID = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee' + +function handler(name: string): (params: unknown, ctx: RpcContext) => Promise { + const method = WORKTREE_AGENT_LAUNCH_RECOVERY_METHODS.find((m) => m.name === name) + if (!method) { + throw new Error(`${name} not registered`) + } + return method.handler as (params: unknown, ctx: RpcContext) => Promise +} + +// [rpc method, runtime method, params, trailing args before (clientKind, deviceId)] +const CASES: [string, string, Record][] = [ + [ + 'worktree.retryAgentLaunch', + 'retryWorktreeAgentLaunch', + { + worktree: 'id:wt-1', + expectedFailureId: 'f1', + clientMutationId: CANONICAL_UUID, + action: { kind: 'same' } + } + ], + [ + 'worktree.forgetAgentLaunch', + 'forgetUnknownWorktreeAgentLaunch', + { + worktree: 'id:wt-1', + expectedOperationId: 'op-1', + clientMutationId: CANONICAL_UUID + } + ], + [ + 'worktree.retryBackgroundAgentLaunch', + 'retryBackgroundAgentLaunch', + { + attemptId: 'att-1', + expectedFailureId: 'f1', + clientMutationId: CANONICAL_UUID, + action: { kind: 'same' } + } + ], + [ + 'worktree.forgetBackgroundAgentLaunch', + 'forgetBackgroundAgentLaunch', + { + attemptId: 'att-1', + expectedOperationId: 'op-1', + clientMutationId: CANONICAL_UUID + } + ], + ['worktree.pendingAgentLaunchSummary', 'pendingAgentLaunchSummary', {}], + [ + 'worktree.unknownAgentLaunchSiblingCount', + 'unknownWorktreeAgentLaunchSiblingCount', + { worktree: 'id:wt-1' } + ], + [ + 'worktree.forgetUnknownAgentLaunchSiblings', + 'forgetUnknownWorktreeAgentLaunchSiblings', + { worktree: 'id:wt-1' } + ] +] + +describe('worktree agent-launch recovery methods forward the paired device', () => { + it.each(CASES)('%s passes pairedDeviceId to %s', async (rpcName, runtimeName, params) => { + const spy = vi.fn().mockResolvedValue({}) + const runtime = { + [runtimeName]: spy + } as unknown as RpcContext['runtime'] + + await handler(rpcName)(params, { + runtime, + clientKind: 'mobile', + pairedDeviceId: 'device-a' + }) + + const args = spy.mock.calls[0] + expect(args.slice(-2)).toEqual(['mobile', 'device-a']) + }) + + it.each(CASES)( + '%s falls back to the coarse principal when no device is paired', + async (rpcName, runtimeName, params) => { + const spy = vi.fn().mockResolvedValue({}) + const runtime = { + [runtimeName]: spy + } as unknown as RpcContext['runtime'] + + // In-process/local dispatch: no clientKind and no paired device id. + await handler(rpcName)(params, { runtime }) + + expect(spy.mock.calls[0].slice(-2)).toEqual([undefined, undefined]) + } + ) +}) diff --git a/src/main/runtime/rpc/methods/worktree-agent-launch-recovery-methods.ts b/src/main/runtime/rpc/methods/worktree-agent-launch-recovery-methods.ts index 2b59cd63b00..36764659884 100644 --- a/src/main/runtime/rpc/methods/worktree-agent-launch-recovery-methods.ts +++ b/src/main/runtime/rpc/methods/worktree-agent-launch-recovery-methods.ts @@ -2,8 +2,11 @@ // worktree launch failure, retry/forget a generic background attempt, and the // redacted capacity-recovery summary. Split out of worktree.ts to keep that file // under the max-lines limit. Every method scopes admission/idempotency from the -// authenticated clientKind — never from client JSON — and treats the -// expectedFailureId/expectedOperationId fields as anti-race guards, not secrets. +// authenticated clientKind AND the authenticated pairedDeviceId — never from +// client JSON — so two paired devices get isolated caps/recovery rows and neither +// can forget the other's launch. A connection with no paired device id (in-process +// runtime callers) falls back to the pre-device coarse principal. The +// expectedFailureId/expectedOperationId fields stay anti-race guards, not secrets. import { defineMethod, type RpcMethod } from '../core' import { @@ -22,7 +25,7 @@ export const WORKTREE_AGENT_LAUNCH_RECOVERY_METHODS: RpcMethod[] = [ params: WorktreeRetryAgentLaunch, // Authorization is authenticated worktree access, the same boundary as every // other worktree mutation. - handler: async (params, { runtime, clientKind }) => + handler: async (params, { runtime, clientKind, pairedDeviceId }) => runtime.retryWorktreeAgentLaunch( params.worktree, { @@ -30,27 +33,29 @@ export const WORKTREE_AGENT_LAUNCH_RECOVERY_METHODS: RpcMethod[] = [ clientMutationId: params.clientMutationId, action: params.action }, - clientKind + clientKind, + pairedDeviceId ) }), defineMethod({ name: 'worktree.forgetAgentLaunch', params: WorktreeForgetAgentLaunch, - handler: async (params, { runtime, clientKind }) => + handler: async (params, { runtime, clientKind, pairedDeviceId }) => runtime.forgetUnknownWorktreeAgentLaunch( params.worktree, { expectedOperationId: params.expectedOperationId, clientMutationId: params.clientMutationId }, - clientKind + clientKind, + pairedDeviceId ) }), defineMethod({ name: 'worktree.retryBackgroundAgentLaunch', params: WorktreeRetryBackgroundAgentLaunch, // Authorization is authenticated access to the attempt's worktree. - handler: async (params, { runtime, clientKind }) => + handler: async (params, { runtime, clientKind, pairedDeviceId }) => runtime.retryBackgroundAgentLaunch( { attemptId: params.attemptId, @@ -58,37 +63,43 @@ export const WORKTREE_AGENT_LAUNCH_RECOVERY_METHODS: RpcMethod[] = [ clientMutationId: params.clientMutationId, action: params.action }, - clientKind + clientKind, + pairedDeviceId ) }), defineMethod({ name: 'worktree.forgetBackgroundAgentLaunch', params: WorktreeForgetBackgroundAgentLaunch, - handler: async (params, { runtime, clientKind }) => + handler: async (params, { runtime, clientKind, pairedDeviceId }) => runtime.forgetBackgroundAgentLaunch( { attemptId: params.attemptId, expectedOperationId: params.expectedOperationId, clientMutationId: params.clientMutationId }, - clientKind + clientKind, + pairedDeviceId ) }), defineMethod({ name: 'worktree.pendingAgentLaunchSummary', params: WorktreePendingAgentLaunchSummary, - // clientKind scopes the admission principal (own rows only). The redacted rows - // are secret-free and carry no token. - handler: async (_params, { runtime, clientKind }) => - runtime.pendingAgentLaunchSummary(clientKind) + // clientKind + pairedDeviceId scope the admission principal (own rows only). + // The redacted rows are secret-free and carry no token. + handler: async (_params, { runtime, clientKind, pairedDeviceId }) => + runtime.pendingAgentLaunchSummary(clientKind, pairedDeviceId) }), defineMethod({ name: 'worktree.unknownAgentLaunchSiblingCount', params: WorktreeUnknownAgentLaunchSiblingCount, // Lazy preflight for the ":498 Also forget N other stranded launches" affordance; - // clientKind scopes the principal, siblings are host-derived, no secrets cross. - handler: async (params, { runtime, clientKind }) => ({ - count: await runtime.unknownWorktreeAgentLaunchSiblingCount(params.worktree, clientKind) + // the principal is device-scoped, siblings are host-derived, no secrets cross. + handler: async (params, { runtime, clientKind, pairedDeviceId }) => ({ + count: await runtime.unknownWorktreeAgentLaunchSiblingCount( + params.worktree, + clientKind, + pairedDeviceId + ) }) }), defineMethod({ @@ -96,7 +107,7 @@ export const WORKTREE_AGENT_LAUNCH_RECOVERY_METHODS: RpcMethod[] = [ params: WorktreeForgetUnknownAgentLaunchSiblings, // Same-principal bulk forget on the anchor's disconnected host. Never kills or // spawns; each sibling settles only its own reservation and self-guards. - handler: async (params, { runtime, clientKind }) => - runtime.forgetUnknownWorktreeAgentLaunchSiblings(params.worktree, clientKind) + handler: async (params, { runtime, clientKind, pairedDeviceId }) => + runtime.forgetUnknownWorktreeAgentLaunchSiblings(params.worktree, clientKind, pairedDeviceId) }) ] diff --git a/src/main/runtime/rpc/methods/worktree-background-agent-launch.test.ts b/src/main/runtime/rpc/methods/worktree-background-agent-launch.test.ts index fe5d0852451..d8ddcf4d4a0 100644 --- a/src/main/runtime/rpc/methods/worktree-background-agent-launch.test.ts +++ b/src/main/runtime/rpc/methods/worktree-background-agent-launch.test.ts @@ -57,8 +57,8 @@ describe('worktree.retryBackgroundAgentLaunch RPC', () => { ) expect(response).toMatchObject({ ok: true, result: { status: 'launched' } }) - // clientKind is undefined for a local dispatch; it scopes the idempotency - // principal and is never derived from the client JSON. + // clientKind and pairedDeviceId are both undefined for a local dispatch; + // together they scope the idempotency principal, never client JSON. expect(retryBackgroundAgentLaunch).toHaveBeenCalledWith( { attemptId: 'attempt-1', @@ -66,6 +66,7 @@ describe('worktree.retryBackgroundAgentLaunch RPC', () => { clientMutationId: CANONICAL_UUID, action: { kind: 'change-agent', agent: 'codex' } }, + undefined, undefined ) }) @@ -115,6 +116,7 @@ describe('worktree.forgetBackgroundAgentLaunch RPC', () => { expectedOperationId: 'op-1', clientMutationId: CANONICAL_UUID }, + undefined, undefined ) }) diff --git a/src/main/runtime/rpc/methods/worktree-create-device-scope.test.ts b/src/main/runtime/rpc/methods/worktree-create-device-scope.test.ts new file mode 100644 index 00000000000..ff1b951331d --- /dev/null +++ b/src/main/runtime/rpc/methods/worktree-create-device-scope.test.ts @@ -0,0 +1,85 @@ +// P1-14: worktree.create's host-atomic agentLaunch must carry the AUTHENTICATED +// paired device into createManagedWorktree, so its pre-git capacity reservation +// and idempotency key are per device rather than per client kind. +import { describe, expect, it, vi } from 'vitest' +import type { RpcContext } from '../core' +import { WORKTREE_METHODS } from './worktree' + +const AGENT_LAUNCH = { + selection: { kind: 'default' as const }, + allowEmptyPromptLaunch: true +} + +function worktreeCreateHandler(): (params: unknown, ctx: RpcContext) => Promise { + const found = WORKTREE_METHODS.find((candidate) => candidate.name === 'worktree.create') + if (!found) { + throw new Error('worktree.create method missing') + } + return found.handler as (params: unknown, ctx: RpcContext) => Promise +} + +async function createArgs( + ctx: Partial, + params: Record = {} +): Promise> { + const createManagedWorktree = vi.fn(async (_args: Record) => ({ + created: true + })) + const runtime = { + getRuntimeId: () => 'test-runtime', + dedupeWorktreeCreate: ( + _repo: string, + _mutationId: string | undefined, + run: () => Promise + ) => run(), + showRepo: vi.fn().mockResolvedValue({ id: 'repo-1', path: '/repo', kind: 'git' }), + createManagedWorktree + } + await worktreeCreateHandler()( + { repo: 'repo-1', name: 'wt', agentLaunch: AGENT_LAUNCH, ...params }, + { runtime, ...ctx } as unknown as RpcContext + ) + return createManagedWorktree.mock.calls[0]![0] +} + +describe('worktree.create device-scoped agent launch', () => { + it('forwards the authenticated paired device alongside the client kind', async () => { + const args = await createArgs({ + clientKind: 'mobile', + pairedDeviceId: 'device-a' + }) + + expect(args).toMatchObject({ + agentLaunch: AGENT_LAUNCH, + agentLaunchClientKind: 'mobile', + agentLaunchDeviceId: 'device-a' + }) + }) + + it('omits the device id (never undefined-valued) for an in-process caller', async () => { + // Keeps the coarse/local principal that pre-device persisted rows were + // reserved under, so they stay forgettable after the upgrade. + const args = await createArgs({}) + + expect(args.agentLaunchClientKind).toBeUndefined() + expect('agentLaunchDeviceId' in args).toBe(false) + }) + + it('takes the device id from the envelope, never from client params', async () => { + const args = await createArgs( + { clientKind: 'mobile', pairedDeviceId: 'device-a' }, + { agentLaunchDeviceId: 'device-victim' } + ) + + expect(args.agentLaunchDeviceId).toBe('device-a') + }) + + it('sends no device id on the legacy (no agentLaunch) create path', async () => { + const args = await createArgs( + { clientKind: 'mobile', pairedDeviceId: 'device-a' }, + { agentLaunch: undefined } + ) + + expect('agentLaunchDeviceId' in args).toBe(false) + }) +}) diff --git a/src/main/runtime/rpc/methods/worktree-create-method.ts b/src/main/runtime/rpc/methods/worktree-create-method.ts index a5e0d3e525d..83b35cd9cdc 100644 --- a/src/main/runtime/rpc/methods/worktree-create-method.ts +++ b/src/main/runtime/rpc/methods/worktree-create-method.ts @@ -15,7 +15,7 @@ export const WORKTREE_CREATE_METHOD: RpcMethod = defineMethod({ name: 'worktree.create', params: WorktreeCreate, handler: async (params, context) => { - const { runtime, clientKind } = context + const { runtime, clientKind, pairedDeviceId } = context // U7: a remote client (authenticated clientKind) may not name a custom id on // the legacy built-in create path — it cannot be host-resolved without the // host-atomic agentLaunch request. Reject at the boundary (no worktree), @@ -97,9 +97,15 @@ export const WORKTREE_CREATE_METHOD: RpcMethod = defineMethod({ startupDraft: params.startupDraft, // The host-atomic launch request; when present the host ignores the // client startup/createdWithAgent for the agent terminal. clientKind - // scopes admission/intent and is never derived from client JSON. + // scopes admission/intent and is never derived from client JSON; the + // paired device narrows that principal so one phone's capacity and + // recovery rows are its own. ...(params.agentLaunch - ? { agentLaunch: params.agentLaunch, agentLaunchClientKind: clientKind } + ? { + agentLaunch: params.agentLaunch, + agentLaunchClientKind: clientKind, + ...(pairedDeviceId ? { agentLaunchDeviceId: pairedDeviceId } : {}) + } : {}), ...(params.agentLaunchTelemetry ? { agentLaunchTelemetry: params.agentLaunchTelemetry } diff --git a/src/main/runtime/rpc/methods/worktree-forget-agent-launch.test.ts b/src/main/runtime/rpc/methods/worktree-forget-agent-launch.test.ts index af4b1fd79f5..97fb087b52c 100644 --- a/src/main/runtime/rpc/methods/worktree-forget-agent-launch.test.ts +++ b/src/main/runtime/rpc/methods/worktree-forget-agent-launch.test.ts @@ -49,11 +49,12 @@ describe('worktree.forgetAgentLaunch RPC', () => { ) expect(response).toMatchObject({ ok: true, result: { status: 'forgotten' } }) - // clientKind is undefined for an in-process/local dispatch; it scopes the - // idempotency principal and is never derived from the client JSON. + // clientKind and pairedDeviceId are both undefined for an in-process/local + // dispatch; together they scope the idempotency principal, never client JSON. expect(forgetUnknownWorktreeAgentLaunch).toHaveBeenCalledWith( 'id:wt-1', { expectedOperationId: 'op-1', clientMutationId: CANONICAL_UUID }, + undefined, undefined ) }) diff --git a/src/main/runtime/rpc/methods/worktree-pending-agent-launch-summary.test.ts b/src/main/runtime/rpc/methods/worktree-pending-agent-launch-summary.test.ts index 75dea42866d..1640c001297 100644 --- a/src/main/runtime/rpc/methods/worktree-pending-agent-launch-summary.test.ts +++ b/src/main/runtime/rpc/methods/worktree-pending-agent-launch-summary.test.ts @@ -32,8 +32,8 @@ describe('worktree.pendingAgentLaunchSummary RPC', () => { ) expect(response).toMatchObject({ ok: true, result: { rows: [{ liveness: 'live' }] } }) - // clientKind is undefined for an in-process/local dispatch; it scopes the - // admission principal and is never derived from the client JSON. - expect(pendingAgentLaunchSummary).toHaveBeenCalledWith(undefined) + // clientKind and pairedDeviceId are both undefined for an in-process/local + // dispatch; together they scope the admission principal, never client JSON. + expect(pendingAgentLaunchSummary).toHaveBeenCalledWith(undefined, undefined) }) }) diff --git a/src/main/runtime/rpc/methods/worktree-retry-agent-launch.test.ts b/src/main/runtime/rpc/methods/worktree-retry-agent-launch.test.ts index 5a2d6377a1f..286a299243b 100644 --- a/src/main/runtime/rpc/methods/worktree-retry-agent-launch.test.ts +++ b/src/main/runtime/rpc/methods/worktree-retry-agent-launch.test.ts @@ -58,8 +58,8 @@ describe('worktree.retryAgentLaunch RPC', () => { ) expect(response).toMatchObject({ ok: true, result: { status: 'launched' } }) - // clientKind is undefined for an in-process/local dispatch; it scopes the - // idempotency principal and is never derived from the client JSON. + // clientKind and pairedDeviceId are both undefined for an in-process/local + // dispatch; together they scope the idempotency principal, never client JSON. expect(retryWorktreeAgentLaunch).toHaveBeenCalledWith( 'id:wt-1', { @@ -67,6 +67,7 @@ describe('worktree.retryAgentLaunch RPC', () => { clientMutationId: CANONICAL_UUID, action: { kind: 'change-agent', agent: 'codex' } }, + undefined, undefined ) }) diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index e7450ff5720..a5cc97790f5 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -1,23 +1,1818 @@ +/* eslint-disable max-lines -- Why: this file is the single security boundary for the bundled CLI — transport setup, auth-token enforcement, admission control, keepalive framing, and orphan-socket sweeping all co-locate deliberately so a reviewer can audit the boundary in one sitting. Splitting this across files would scatter the invariants without reducing complexity. */ // Why: the single security boundary for the bundled CLI — auth-token enforcement, metadata publication, transport orchestration. -import { RuntimeRpcShutdown } from './runtime-rpc/runtime-rpc-shutdown' -import type { OrcaRuntimeRpcServerOptions } from './runtime-rpc/runtime-rpc-pairing-types' +import { randomBytes } from 'node:crypto' +import { readdirSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import type { RuntimeMetadata, RuntimeTransportMetadata } from '../../shared/runtime-bootstrap' +import type { OrcaRuntimeService } from './orca-runtime' +import { NETWORK_EXPOSURE_FAILED_GUIDANCE } from './network-exposure-guidance' +import { writeRuntimeMetadata } from './runtime-metadata' +import { + RUNTIME_METADATA_OWNERSHIP_POLL_MS, + watchRuntimeMetadataOwnership, + type RuntimeMetadataOwnershipWatch +} from './runtime-metadata-ownership-watch' +import { RpcDispatcher } from './rpc/dispatcher' +import type { RpcRequest, RpcResponse } from './rpc/core' +import { errorResponse } from './rpc/errors' +import { fingerprintAuthenticatedPairingCredential } from './rpc/orchestration-mutation-executor' +import type { RpcMessageContext, RpcTransport } from './rpc/transport' +import { UnixSocketTransport } from './rpc/unix-socket-transport' +import { WebSocketTransport } from './rpc/ws-transport' +import { readWsFallbackPort, writeWsFallbackPort } from './rpc/ws-fallback-port-store' +import type { WebSocket } from 'ws' +import { DeviceRegistry, type DeviceEntry, type DeviceScope } from './device-registry' +import { loadOrCreateE2EEKeypair, type E2EEKeypair } from './e2ee-keypair' +import { UnpairedDeviceAuthThrottle } from './rpc/unpaired-device-auth-throttle' +import { + MobileSocketWiring, + type AuthenticatedMobileSocket, + type MobileSocketTransportMetadata +} from './rpc/mobile-socket-wiring' +import type { PairingRelay } from '../../shared/mobile-relay-pairing-offer' +import type { MobilePairingConnectionMode } from '../../shared/mobile-pairing-connection-mode' +import type { RuntimePairingReach } from '../../shared/runtime-pairing-reach' +import { + mobileRelayMintFailureFromUnknown, + type MobileRelayMintFailure +} from '../../shared/mobile-relay-mint-failure' +import { + RelayRevokeOutbox, + type RelayDeviceBinding, + type RelayRevokeOutboxItem +} from './relay/relay-revoke-outbox' +import type { + DeviceCredentialInstalled, + PairingGetEndpointsParams, + PairingGetEndpointsResult, + PairingProvisionRelayParams +} from '../../shared/mobile-relay-credential-contract' +import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../../shared/pairing' +import { resolveAdvertisedPairingEndpoint } from './pairing-endpoint' +import { + decodeTerminalStreamFrame, + type TerminalStreamFrame +} from '../../shared/terminal-stream-protocol' -export type { - PairingOfferUnavailableReason, - PairingOfferUnavailable -} from './runtime-rpc/runtime-rpc-pairing-types' -export type { MobilePairingConnectionContext } from './runtime-rpc/runtime-rpc-pairing-types' -export type { RuntimeLongPollClass } from './runtime-rpc/runtime-rpc-long-poll' -export { classifyRuntimeLongPoll } from './runtime-rpc/runtime-rpc-long-poll' +const DEFAULT_WS_PORT = 6768 -export class OrcaRuntimeRpcServer extends RuntimeRpcShutdown { - constructor(options: OrcaRuntimeRpcServerOptions) { - super(options) +// Why: STA-2370 — the WS listener defaults to loopback so a desktop with no paired device is not +// reachable from the LAN; it widens to all interfaces only on explicit pairing (or `orca serve`). +const WS_BIND_HOST_LOOPBACK = '127.0.0.1' +const WS_BIND_HOST_ALL_INTERFACES = '0.0.0.0' + +type OrcaRuntimeRpcServerOptions = { + runtime: OrcaRuntimeService + userDataPath: string + pid?: number + platform?: NodeJS.Platform + enableWebSocket?: boolean + wsPort?: number + // Why: true when the caller pinned a port (`orca serve --port`) so bind order prefers it over a stale STA-1511 fallback (#8535). + preferPinnedWsPort?: boolean + // Why: STA-2370 — bind the WS listener to all interfaces at startup instead of loopback-until-paired. + // Only `orca serve` (explicit remote opt-in) and E2E set this; the desktop app widens lazily on pairing. + exposeNetworkByDefault?: boolean + webClientRoot?: string + // Why: test-only overrides for the two constants below; production must not pass these (defaults set by §3.1). + keepaliveIntervalMs?: number + longPollCap?: number + // Why: test-only override for the ownership reclaim cadence. + metadataOwnershipPollMs?: number +} + +export type PairingOfferUnavailableReason = + | 'websocket_unavailable' + | 'device_registry_unavailable' + | 'e2ee_key_unavailable' + | 'invalid_advertised_endpoint' + | 'relay_mint_failed' + | 'network_exposure_failed' + +export type PairingOfferUnavailable = { + available: false + reason: PairingOfferUnavailableReason + guidance: string + /** Present when an Anywhere mint refused to silently fall back to LAN-only. */ + relayFailure?: MobileRelayMintFailure +} + +type MobilePairingOfferAvailable = { + available: true + pairingUrl: string + endpoint: string + deviceId: string + webClientUrl: string | null + /** Mode the offer actually encodes. */ + connectionMode: MobilePairingConnectionMode +} + +type MobilePairingOffer = PairingOfferUnavailable | MobilePairingOfferAvailable + +type PairingIdentityInitialization = + | { ok: true; deviceRegistry: DeviceRegistry; e2eeKeypair: E2EEKeypair } + | { ok: false; failure: PairingOfferUnavailable } + +function pairingUnavailable( + reason: PairingOfferUnavailableReason, + guidance: string +): PairingOfferUnavailable { + return { available: false, reason, guidance } +} + +const DEVICE_REGISTRY_UNAVAILABLE_GUIDANCE = + 'The pairing registry is unavailable. Verify that the Orca data directory is writable.' +const E2EE_KEY_UNAVAILABLE_GUIDANCE = + 'The E2EE identity is unavailable. Verify that the Orca data directory is writable.' + +type MobileRelayPairingProvider = { + createPairingRelay( + relayDeviceId: string + ): Promise<{ relay: PairingRelay; binding: RelayDeviceBinding }> + onDeviceRevokeQueued(item: RelayRevokeOutboxItem): void + onDemandStateChanged?(): void + getEndpoints( + context: MobilePairingConnectionContext, + params: PairingGetEndpointsParams + ): Promise + provisionRelay( + context: MobilePairingConnectionContext, + params: PairingProvisionRelayParams + ): Promise +} + +export type MobilePairingConnectionContext = Readonly<{ + deviceId: string + connectionId: string + transport: MobileSocketTransportMetadata +}> + +// Why: keepalive frames count as socket activity, resetting both idle timers so long-polls outlive the 30s/60s idle caps. See §3.1. +const KEEPALIVE_INTERVAL_MS = 10_000 + +// Why: cap long-polls at half the 32-slot connection budget so they can't starve short RPCs; overflow → runtime_busy. See §7 risk #2. +const LONG_POLL_CAP = 16 + +// Why: orchestration.ask blocks on a human/agent reply for minutes, an order of +// magnitude longer than terminal.wait or check --wait, so a fleet of asking +// workers would otherwise hold every slot and starve the mobile/web/CLI/relay +// clients sharing this runtime. Reserve half the budget for the other classes. +const ASK_LONG_POLL_SHARE = 0.5 + +function createWebClientUrl(endpoint: string, pairingUrl: string): string { + const url = new URL(endpoint) + url.protocol = url.protocol === 'wss:' ? 'https:' : 'http:' + url.pathname = webClientPathForEndpoint(url.pathname) + url.search = '' + // Why: pairing URLs carry full credentials; the fragment keeps them out of proxy logs and Referer headers. + url.hash = `pairing=${encodeURIComponent(pairingUrl)}` + return url.toString() +} + +function webClientPathForEndpoint(pathname: string): string { + if (!pathname || pathname === '/') { + return '/web-index.html' + } + return `${pathname.replace(/\/$/, '')}/web-index.html` +} + +const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ + 'accounts.list', + 'accounts.consumeCodexResetCredit', + 'accounts.selectClaude', + 'accounts.selectCodex', + 'accounts.selectCodexForTarget', + 'accounts.subscribe', + 'accounts.unsubscribe', + 'aiVault.listSessions', + 'aiVault.resolveSessionTitles', + 'aiVault.prepareSessionResume', + 'browser.back', + 'browser.dialogAccept', + 'browser.dialogDismiss', + 'browser.forward', + 'browser.goto', + 'browser.keyboardInsertText', + 'browser.keypress', + 'browser.mouseDown', + 'browser.mouseClick', + 'browser.mouseMove', + 'browser.mouseUp', + 'browser.mouseWheel', + 'browser.reload', + 'browser.screencast', + 'browser.screencast.unsubscribe', + 'browser.tabCreate', + 'browser.viewport', + 'clipboard.abortImageUpload', + 'clipboard.appendImageUploadChunk', + 'clipboard.commitImageUpload', + 'clipboard.saveImageAsTempFile', + 'clipboard.startImageUpload', + 'diagnostics.memory', + 'files.browseServerDir', + 'files.createFile', + 'files.list', + 'files.open', + 'files.openDiff', + 'files.read', + 'files.readChunk', + 'files.readDir', + 'files.readPreview', + 'files.readTerminalArtifact', + 'files.readTerminalArtifactPreview', + 'files.resolveTerminalPath', + 'files.searchPaths', + 'files.writeTerminalArtifact', + 'folderWorkspace.list', + 'git.abortMerge', + 'git.abortRebase', + 'git.bulkStage', + 'git.bulkUnstage', + 'git.branchCompare', + 'git.branchDiff', + 'git.cancelGenerateCommitMessage', + 'git.cancelGeneratePullRequestFields', + 'git.checkout', + 'git.commit', + 'git.commitCompare', + 'git.commitDiff', + 'git.discard', + 'git.discoverCommitMessageModels', + 'git.diff', + 'git.fetch', + 'git.forkSync', + 'git.fastForward', + 'git.generateCommitMessage', + 'git.generatePullRequestFields', + 'git.history', + 'git.localBranches', + 'git.pull', + 'git.push', + 'git.rebaseFromBase', + 'git.stage', + 'git.status', + 'git.unstage', + 'git.upstreamStatus', + 'github.createIssue', + 'github.addIssueComment', + 'github.addPRReviewComment', + 'github.addPRReviewCommentReply', + 'github.countWorkItems', + 'github.listAssignableUsers', + 'github.listLabels', + 'github.listWorkItems', + 'github.mergePR', + 'github.setPRAutoMerge', + 'github.requestPRReviewers', + 'github.removePRReviewers', + 'github.project.listAccessible', + 'github.project.listAssignableUsersBySlug', + 'github.project.listIssueTypesBySlug', + 'github.project.listLabelsBySlug', + 'github.project.listViews', + 'github.project.resolveRef', + 'github.project.addIssueCommentBySlug', + 'github.project.updateIssueCommentBySlug', + 'github.project.deleteIssueCommentBySlug', + 'github.project.clearItemField', + 'github.project.updateIssueBySlug', + 'github.project.updateIssueTypeBySlug', + 'github.project.updateItemField', + 'github.project.updatePullRequestBySlug', + 'github.project.viewTable', + 'github.project.workItemDetailsBySlug', + 'github.prForBranch', + 'github.prFileContents', + 'github.prChecks', + 'github.prCheckDetails', + 'github.rerunPRChecks', + 'github.resolveReviewThread', + 'github.setPRFileViewed', + 'github.updateIssue', + 'github.updatePR', + 'github.updatePRTitle', + 'github.updatePRState', + 'github.repoSlug', + 'github.workItem', + // Cross-repo lookup: lets the mobile Smart picker resolve a pasted github.com URL for a different repo. + 'github.workItemByOwnerRepo', + 'github.workItemDetails', + 'gitlab.createIssue', + 'gitlab.addIssueComment', + 'gitlab.addMRComment', + 'gitlab.listWorkItems', + // Mobile Smart picker: resolve a pasted GitLab URL to an exact issue/MR (MR listing reuses gitlab.listWorkItems). + 'gitlab.workItemByPath', + 'gitlab.mergeMR', + 'gitlab.resolveMRDiscussion', + 'gitlab.todos', + 'gitlab.updateIssue', + 'gitlab.updateMR', + 'gitlab.updateMRState', + 'gitlab.workItemDetails', + 'host.gitBash.isAvailable', + 'host.platform', + 'host.pwsh.isAvailable', + 'host.wsl.isAvailable', + 'host.wsl.listDistros', + 'hostedReview.create', + 'hostedReview.createStacked', + 'hostedReview.forBranch', + 'hostedReview.getCreationEligibility', + 'linear.getCustomView', + 'linear.getIssue', + 'linear.getProject', + 'linear.agentSearchIssues', + 'linear.issueContext', + 'linear.resolveCurrentIssue', + 'linear.addIssueComment', + 'linear.connect', + 'linear.createIssue', + 'linear.createProject', + 'linear.issueComments', + 'linear.listCustomViewIssues', + 'linear.listCustomViewProjects', + 'linear.listCustomViews', + 'linear.listIssues', + 'linear.mcpListIssues', + 'linear.listProjectIssues', + 'linear.listProjects', + 'linear.teamLabels', + 'linear.teamMembers', + 'linear.listTeams', + 'linear.searchIssues', + 'linear.selectWorkspace', + 'linear.status', + 'linear.teamStates', + 'linear.updateIssue', + 'markdown.readTab', + 'markdown.saveTab', + 'notifications.getMissedSince', + 'notifications.subscribe', + 'notifications.unsubscribe', + 'pairing.getEndpoints', + 'pairing.provisionRelay', + 'preflight.check', + 'preflight.detectAgents', + 'preflight.detectRemoteAgents', + 'projectGroup.list', + 'repo.baseRefDefault', + 'repo.gitAvailable', + 'repo.hooks', + 'repo.list', + 'repo.saveSparsePreset', + 'repo.searchRefs', + 'repo.sparsePresets', + 'repo.update', + 'runtime.clientEvents.subscribe', + 'runtime.clientEvents.unsubscribe', + 'session.tabs.activate', + 'session.tabs.close', + 'session.tabs.closeLifecycle', + 'session.tabs.createTerminal', + // Why: the mobile launch-notice banner dismisses host-owned notices; the + // dismissal is launchToken-scoped host-side, so allowlisting is read-safe. + 'session.tabs.dismissLaunchNotice', + 'session.tabs.list', + 'session.tabs.listAll', + 'session.tabs.move', + 'session.tabs.subscribe', + 'session.tabs.subscribeAll', + 'session.tabs.unsubscribe', + 'session.tabs.unsubscribeAll', + 'nativeChat.readSession', + 'nativeChat.subscribe', + 'nativeChat.unsubscribe', + 'settings.get', + 'settings.getTerminalQuickCommands', + // Why: env-free reference snapshot fetch for the mobile agent-reference + // cache; authoring mutations stay desktop-only and are NOT allowlisted. + 'settings.agentReferences.get', + // Why: dedicated env-free catalog read so unrelated mobile settings.get calls + // stop carrying the 512 KiB projection; read-only, authoring stays desktop. + 'settings.agentCatalog.get', + 'settings.update', + 'settings.updateTerminalQuickCommands', + 'ssh.connect', + 'ssh.getState', + 'ssh.listRemovedTargetLabels', + 'ssh.listTargets', + 'ssh.listTargetSummaries', + 'speech.dictation.cancel', + 'speech.dictation.chunk', + 'speech.dictation.finish', + 'speech.dictation.setup', + 'speech.dictation.start', + 'speech.models.delete', + 'speech.models.download', + 'speech.models.list', + 'stats.summary', + 'status.get', + 'agentTeams.prepareLaunch', + 'agentTeams.tmuxCompat', + 'terminal.clearBuffer', + 'terminal.close', + 'terminal.closeTab', + 'terminal.create', + 'terminal.createAgentSession', + 'terminal.ensureAgentSession', + 'terminal.focus', + 'terminal.agentStatus', + 'terminal.adoptOrphans', + 'terminal.getAutoRestoreFit', + 'terminal.isRunningAgent', + 'terminal.list', + 'terminal.multiplex', + 'terminal.read', + 'terminal.rename', + 'terminal.send', + 'terminal.setAutoRestoreFit', + 'terminal.setDisplayMode', + 'terminal.subscribe', + 'terminal.unsubscribe', + 'terminal.updateViewport', + 'terminal.wait', + 'ui.get', + 'ui.recordFeatureInteraction', + 'ui.set', + 'worktree.activate', + 'worktree.create', + 'worktree.forceDeleteBranch', + 'worktree.listRetiredNames', + 'worktree.prefetchCreateBase', + 'worktree.ps', + 'worktree.show', + 'worktree.resolveMrBase', + 'worktree.resolvePrBase', + 'worktree.rm', + 'worktree.set', + 'worktree.sleep' +]) + +// Why: 'ask' is metered separately from 'wait' — same keepalive/abort wiring, its own sub-cap. +type LongPollClass = 'ask' | 'wait' + +// Why: single classifier for long-poll requests (handlers that block on an external event), shared by counter/abort/keepalive. See §3.1. +function longPollClassOf(request: RpcRequest): LongPollClass | null { + if (request.method === 'terminal.wait') { + return 'wait' + } + // Why: orchestration.ask blocks unconditionally (default 600 s) holding the + // RPC open until a reply lands or the deadline passes, so it needs the same + // keepalive as check --wait or the 30 s socket idle timer tears it down. It + // also relies on the abort signal (only wired for long-polls) to release the + // waiter when the asking client disconnects. + if (request.method === 'orchestration.ask') { + return 'ask' + } + if (request.method === 'orchestration.check') { + const params = request.params as { wait?: unknown } | undefined + return params?.wait === true ? 'wait' : null + } + return null +} + +// Why: status.get has no per-connection context in the dispatcher, so stamp the scope here at the transport boundary. +function injectDeviceScope(response: string, scope: DeviceScope): string { + try { + const parsed = JSON.parse(response) as RpcResponse + if (parsed.ok !== true || typeof parsed.result !== 'object' || parsed.result === null) { + return response + } + ;(parsed.result as Record).deviceScope = scope + return JSON.stringify(parsed) + } catch { + return response } } -export { - RUNTIME_SOCKET_NAME_REGEX, - sweepOrphanedRuntimeSockets, - createRuntimeTransportMetadata -} from './runtime-rpc/runtime-rpc-socket-metadata' +export class OrcaRuntimeRpcServer { + private readonly runtime: OrcaRuntimeService + private readonly dispatcher: RpcDispatcher + private readonly userDataPath: string + private readonly pid: number + private readonly platform: NodeJS.Platform + private readonly enableWebSocket: boolean + private readonly wsPort: number + private readonly preferPinnedWsPort: boolean + private readonly exposeNetworkByDefault: boolean + private readonly webClientRoot: string | undefined + // Why: STA-2370 — the host the WS listener is currently bound to, so pairing can widen loopback→all-interfaces once. + private wsBoundHost: string | null = null + // Why: STA-2370 — in-flight widen so concurrent pairing requests share a single rebind. + private networkExposurePromise: Promise | null = null + // Why: STA-2370 — set by stop() so a racing pairing widen can't recreate a live wide listener into the + // cleared transport arrays after shutdown; stop() also awaits any in-flight widen before snapshotting. + private stopping = false + private readonly authToken = randomBytes(24).toString('hex') + private readonly keepaliveIntervalMs: number + private readonly longPollCap: number + private readonly metadataOwnershipPollMs: number + private readonly askLongPollCap: number + private readonly relayRevokeOutbox: RelayRevokeOutbox + private deviceRegistry: DeviceRegistry | null = null + private e2eeKeypair: E2EEKeypair | null = null + private pairingInitializationFailure: PairingOfferUnavailable | null = null + private tlsFingerprint: string | null = null + private activeTransports: RpcTransport[] = [] + private transports: RuntimeTransportMetadata[] = [] + private metadataOwnershipWatch: RuntimeMetadataOwnershipWatch | null = null + private mobileSocketWiring: MobileSocketWiring | null = null + // Why: detaches the current WebSocketTransport from the session wiring so a pairing rebind can swap + // transports under the SAME wiring (see ensureMobileSocketWiring) instead of orphaning relay sockets. + private detachWebSocketWiring: (() => void) | null = null + private mobileRelayPairingProvider: MobileRelayPairingProvider | null = null + private mobileRelayPairingOfferQueue: Promise = Promise.resolve() + private mobileRelayPairingOfferInFlight: { + generation: number + address: string | null + rotate: boolean + request: Promise + } | null = null + private mobilePairingOfferGeneration = 0 + private onUnpairedDeviceAuthFailure: (() => void) | null = null + private unpairedDeviceAuthThrottle: UnpairedDeviceAuthThrottle | null = null + private readonly binaryStreamHandlers = new Map< + string, + Map void> + >() + private readonly wsDispatchAbortStates = new Map< + WebSocket, + { controllers: Set; abortOnClose: () => void } + >() + // Why: separate from server.maxConnections — count only long-running dispatches, not short RPCs. See §3.1 + §7 risk #2. + private activeLongPolls = 0 + // Why: subset of activeLongPolls held by orchestration.ask, fenced by askLongPollCap. + private activeAskLongPolls = 0 + + constructor({ + runtime, + userDataPath, + pid = process.pid, + platform = process.platform, + enableWebSocket = false, + wsPort = DEFAULT_WS_PORT, + preferPinnedWsPort = false, + exposeNetworkByDefault = false, + webClientRoot, + keepaliveIntervalMs = KEEPALIVE_INTERVAL_MS, + longPollCap = LONG_POLL_CAP, + metadataOwnershipPollMs = RUNTIME_METADATA_OWNERSHIP_POLL_MS + }: OrcaRuntimeRpcServerOptions) { + this.runtime = runtime + this.dispatcher = new RpcDispatcher({ runtime }) + this.userDataPath = userDataPath + this.pid = pid + this.platform = platform + this.enableWebSocket = enableWebSocket + this.wsPort = wsPort + this.preferPinnedWsPort = preferPinnedWsPort + this.exposeNetworkByDefault = exposeNetworkByDefault + this.webClientRoot = webClientRoot + this.keepaliveIntervalMs = keepaliveIntervalMs + this.longPollCap = longPollCap + this.metadataOwnershipPollMs = metadataOwnershipPollMs + // Why: derived, not configurable — the reservation must hold for whatever cap a caller picks. + this.askLongPollCap = Math.max(1, Math.floor(longPollCap * ASK_LONG_POLL_SHARE)) + this.relayRevokeOutbox = new RelayRevokeOutbox(userDataPath) + } + + getDeviceRegistry(): DeviceRegistry | null { + return this.deviceRegistry + } + + getTlsFingerprint(): string | null { + return this.tlsFingerprint + } + + getE2EEPublicKey(): string | null { + return this.e2eeKeypair?.publicKeyB64 ?? null + } + + getE2EEKeypair(): E2EEKeypair | null { + return this.e2eeKeypair + } + + getMobileSocketWiring(): MobileSocketWiring | null { + return this.mobileSocketWiring + } + + getRelayRevokeOutbox(): RelayRevokeOutbox { + return this.relayRevokeOutbox + } + + setMobileRelayBinding(deviceId: string, binding: RelayDeviceBinding): boolean { + const current = this.deviceRegistry?.getDevice(deviceId) + if ( + current?.scope !== 'mobile' || + this.deviceRegistry?.getMobilePairingConnectionMode(deviceId) === 'local-only' + ) { + return false + } + if ( + current.relayBinding && + (current.relayBinding.relayHostId !== binding.relayHostId || + current.relayBinding.ownerIdentityKey !== binding.ownerIdentityKey) + ) { + // Why: switching the owning account/host must not strand the old cloud credential family, even if that account is offline. + if (!this.queueRelayDeviceRevoke(current.relayBinding)) { + return false + } + } + const updated = this.deviceRegistry?.setRelayBinding(deviceId, binding) ?? false + if (updated) { + this.mobileRelayPairingProvider?.onDemandStateChanged?.() + } + return updated + } + + // Why: only the desktop shell can surface UI; headless serve leaves this unset. + setOnUnpairedDeviceAuthFailure(callback: (() => void) | null): void { + this.onUnpairedDeviceAuthFailure = callback + } + + setMobileRelayPairingProvider(provider: MobileRelayPairingProvider | null): void { + this.mobileRelayPairingProvider = provider + } + + async revokeMobileDevice(deviceId: string): Promise { + const device = this.deviceRegistry?.getDevice(deviceId) + if (device?.scope !== 'mobile') { + return false + } + if (device.relayBinding) { + if (!this.queueRelayDeviceRevoke(device.relayBinding)) { + return false + } + } + if (!this.deviceRegistry?.removeDevice(deviceId)) { + return false + } + this.mobileRelayPairingProvider?.onDemandStateChanged?.() + this.runtime.forgetClientNavigationState(deviceId) + this.mobileSocketWiring?.terminateDeviceConnections(device.token) + return true + } + + revokeRuntimeAccess(deviceId: string): boolean { + const device = this.deviceRegistry?.getDevice(deviceId) + if (device?.scope !== 'runtime' || !this.deviceRegistry?.removeDevice(deviceId)) { + return false + } + this.runtime.forgetClientNavigationState(deviceId) + this.mobileSocketWiring?.terminateDeviceConnections(device.token) + return true + } + + getWebSocketEndpoint(): string | null { + const ws = this.transports.find((t) => t.kind === 'websocket') + return ws?.endpoint ?? null + } + + createPairingOffer(args: { + address?: string | null + name?: string + rotate?: boolean + scope?: DeviceScope + // Why: STA-2370 — recorded on the grant so a "This computer only" client reconnecting cannot make the + // next launch bind every interface. Defaults to network reach, which is what every other caller means. + reach?: RuntimePairingReach + }): + | PairingOfferUnavailable + | { + available: true + pairingUrl: string + endpoint: string + deviceId: string + webClientUrl: string | null + } { + if (this.pairingInitializationFailure) { + return this.pairingInitializationFailure + } + const rawEndpoint = this.getWebSocketEndpoint() + if (!rawEndpoint) { + return pairingUnavailable( + 'websocket_unavailable', + 'WebSocket pairing is unavailable. Inspect preceding runtime errors and choose an unused --port if the listener failed.' + ) + } + if (!this.deviceRegistry) { + return pairingUnavailable('device_registry_unavailable', DEVICE_REGISTRY_UNAVAILABLE_GUIDANCE) + } + const publicKeyB64 = this.getE2EEPublicKey() + if (!publicKeyB64) { + return pairingUnavailable('e2ee_key_unavailable', E2EE_KEY_UNAVAILABLE_GUIDANCE) + } + + const advertised = resolveAdvertisedPairingEndpoint(rawEndpoint, args.address) + if (!advertised.ok) { + return pairingUnavailable(advertised.reason, advertised.guidance) + } + const endpoint = advertised.endpoint + const deviceName = args.name ?? `CLI ${new Date().toLocaleDateString()}` + const scope = args.scope ?? 'runtime' + let device: DeviceEntry + try { + const reach = args.reach ?? 'network' + device = args.rotate + ? this.deviceRegistry.rotatePendingDevice(deviceName, scope, reach) + : this.deviceRegistry.getOrCreatePendingDevice(deviceName, scope, reach) + } catch (error) { + console.error('[runtime] Failed to persist pairing credential:', error) + return pairingUnavailable('device_registry_unavailable', DEVICE_REGISTRY_UNAVAILABLE_GUIDANCE) + } + const pairingUrl = encodePairingOffer({ + v: PAIRING_OFFER_VERSION, + endpoint, + deviceToken: device.token, + publicKeyB64, + pairedDeviceId: device.deviceId, + scope + }) + return { + available: true, + pairingUrl, + endpoint, + deviceId: device.deviceId, + webClientUrl: + this.webClientRoot && scope === 'runtime' ? createWebClientUrl(endpoint, pairingUrl) : null + } + } + + async createMobilePairingOffer(args: { + address?: string | null + connectionMode?: MobilePairingConnectionMode + name?: string + rotate?: boolean + }): Promise { + // Why: STA-2370 — creating a mobile pairing offer is the user's explicit opt-in to LAN reach, so + // widen the loopback listener before advertising its LAN endpoint in the QR. If the widen fails the + // listener stays on loopback, so report unavailable rather than advertise a dead LAN endpoint. + try { + await this.ensureNetworkExposure() + } catch (error) { + console.error( + '[runtime] Network exposure failed while creating a mobile pairing offer:', + error + ) + return pairingUnavailable('network_exposure_failed', NETWORK_EXPOSURE_FAILED_GUIDANCE) + } + if (args.connectionMode === 'local-only') { + this.mobilePairingOfferGeneration += 1 + return this.createMobilePairingOfferSerial(args, this.mobilePairingOfferGeneration) + } + const address = args.address ?? null + const rotate = args.rotate === true + const inFlight = this.mobileRelayPairingOfferInFlight + if ( + inFlight?.generation === this.mobilePairingOfferGeneration && + inFlight.address === address && + (inFlight.rotate || !rotate) + ) { + return inFlight.request + } + // Why: every request that is not coalesced above supersedes the older one, rotating or not. + const generation = ++this.mobilePairingOfferGeneration + const request = this.mobileRelayPairingOfferQueue.then(() => + generation === this.mobilePairingOfferGeneration + ? this.createMobilePairingOfferSerial(args, generation) + : this.relayPairingRequestSuperseded() + ) + this.mobileRelayPairingOfferQueue = request.then( + () => undefined, + () => undefined + ) + this.mobileRelayPairingOfferInFlight = { generation, address, rotate, request } + void request.then( + () => { + if (this.mobileRelayPairingOfferInFlight?.request === request) { + this.mobileRelayPairingOfferInFlight = null + } + }, + () => { + if (this.mobileRelayPairingOfferInFlight?.request === request) { + this.mobileRelayPairingOfferInFlight = null + } + } + ) + return request + } + + private async createMobilePairingOfferSerial( + args: { + address?: string | null + connectionMode?: MobilePairingConnectionMode + name?: string + rotate?: boolean + }, + generation: number + ): Promise { + // Why: the renderer is outside the trust boundary, so only an explicit local-only value may suppress Relay provisioning. + const connectionMode = args.connectionMode === 'local-only' ? 'local-only' : 'automatic' + const pending = this.deviceRegistry?.getPendingDevice('mobile') + // Why: connection policy is part of the credential, so rotate on any policy switch — an old-policy QR must not pair under the new one. + const switchingPendingMode = + pending != null && + this.deviceRegistry?.getMobilePairingConnectionMode(pending.deviceId) !== connectionMode + if (args.rotate || switchingPendingMode) { + if (pending?.relayBinding) { + // Why: record the durable cloud revoke before rotating the local token so an old relay invite can't outlive the QR. + if (!this.queueRelayDeviceRevoke(pending.relayBinding)) { + return pairingUnavailable( + 'device_registry_unavailable', + 'Could not persist Relay cleanup before rotating the pairing code.' + ) + } + } + } + const direct = this.createPairingOffer({ + ...args, + rotate: args.rotate || switchingPendingMode, + scope: 'mobile' + }) + if (!direct.available) { + return direct + } + const createdNewPendingDevice = pending?.deviceId !== direct.deviceId + let connectionModeStored = false + try { + connectionModeStored = + this.deviceRegistry?.setMobilePairingConnectionMode(direct.deviceId, connectionMode) ?? + false + } catch (error) { + console.error('[runtime] Failed to persist the pairing connection mode:', error) + } + // Why: the mode is part of the credential — a QR whose policy was never stored must not pair under the default one. + if (!connectionModeStored) { + if (createdNewPendingDevice) { + this.discardPendingMobilePairingDevice(direct.deviceId) + } + return pairingUnavailable('device_registry_unavailable', DEVICE_REGISTRY_UNAVAILABLE_GUIDANCE) + } + // Why: explicit LAN path never needs Relay; mint the direct-only offer as selected. + if (connectionMode === 'local-only') { + return { ...direct, connectionMode: 'local-only' } + } + // Why: Anywhere must not silently ship a LAN-only QR under the Relay label. + // Fail closed, drop the unused pending credential, and let the UI offer Use LAN. + const refuseAutomaticWithoutRelay = ( + relayFailure: MobileRelayMintFailure + ): PairingOfferUnavailable => { + if (createdNewPendingDevice) { + this.discardPendingMobilePairingDevice(direct.deviceId) + } + return { + available: false, + reason: 'relay_mint_failed', + guidance: + 'Orca Relay could not create a pairing invite. Use LAN (Tailscale or same Wi‑Fi) or retry Relay.', + relayFailure + } + } + const relayProvider = this.mobileRelayPairingProvider + if (!relayProvider) { + return refuseAutomaticWithoutRelay({ + code: 'relay_provider_unavailable', + stage: 'provider_missing', + message: 'Orca Relay is not available on this desktop' + }) + } + const device = this.deviceRegistry?.getDevice(direct.deviceId) + const publicKeyB64 = this.getE2EEPublicKey() + if (!device || !publicKeyB64) { + return refuseAutomaticWithoutRelay({ + code: 'e2ee_key_unavailable', + stage: 'e2ee_missing', + message: 'E2EE public key unavailable for Relay pairing' + }) + } + let relayPairing: Awaited> + try { + relayPairing = await relayProvider.createPairingRelay(device.deviceId) + } catch (error) { + // Why: the raw provider error can carry request metadata or credentials — log only the validated code. + const relayFailure = mobileRelayMintFailureFromUnknown({ + stage: 'create_pairing_relay', + error, + fallbackCode: 'relay_mint_failed', + fallbackMessage: 'Relay pairing invite request failed' + }) + console.warn(`[runtime] Failed to create Relay pairing invite: ${relayFailure.code}`) + return refuseAutomaticWithoutRelay(relayFailure) + } + const currentDevice = this.deviceRegistry?.getDevice(device.deviceId) + if ( + generation !== this.mobilePairingOfferGeneration || + relayProvider !== this.mobileRelayPairingProvider || + currentDevice?.token !== device.token || + this.deviceRegistry?.getMobilePairingConnectionMode(device.deviceId) !== 'automatic' + ) { + this.queueOrRetainRelayDeviceRevoke(device.deviceId, relayPairing.binding) + if (createdNewPendingDevice) { + this.discardPendingMobilePairingDevice(direct.deviceId) + } + return this.relayPairingRequestSuperseded() + } + try { + if (!this.setMobileRelayBinding(device.deviceId, relayPairing.binding)) { + this.queueOrRetainRelayDeviceRevoke(device.deviceId, relayPairing.binding) + return refuseAutomaticWithoutRelay({ + code: 'relay_binding_failed', + stage: 'binding_failed', + message: 'Could not store Relay binding for the pairing device' + }) + } + } catch (error) { + console.warn('[runtime] Failed to persist Relay pairing binding:', error) + this.queueOrRetainRelayDeviceRevoke(device.deviceId, relayPairing.binding) + return refuseAutomaticWithoutRelay({ + code: 'relay_binding_failed', + stage: 'binding_failed', + message: 'Could not store Relay binding for the pairing device' + }) + } + return { + ...direct, + connectionMode: 'automatic', + pairingUrl: encodePairingOffer({ + v: PAIRING_OFFER_VERSION, + endpoint: direct.endpoint, + deviceToken: device.token, + publicKeyB64, + pairedDeviceId: device.deviceId, + scope: 'mobile', + relay: relayPairing.relay + }) + } + } + + private relayPairingRequestSuperseded(): PairingOfferUnavailable { + return { + available: false, + reason: 'relay_mint_failed', + guidance: 'The Relay pairing request was replaced by a newer connection choice.', + relayFailure: { + code: 'relay_request_superseded', + stage: 'binding_failed', + message: 'Relay pairing request superseded' + } + } + } + + /** Drop a never-scanned mobile pending credential after a failed Anywhere mint. */ + private discardPendingMobilePairingDevice(deviceId: string): void { + const device = this.deviceRegistry?.getDevice(deviceId) + if (!device || device.scope !== 'mobile' || device.lastSeenAt !== 0) { + return + } + if (device.relayBinding) { + if (!this.queueRelayDeviceRevoke(device.relayBinding)) { + return + } + } + try { + this.deviceRegistry?.removeDevice(deviceId) + } catch (error) { + console.error('[runtime] Failed to drop an unused mobile pairing credential:', error) + } + } + + /** + * Why: the outbox is the only durable cleanup record for a minted invite. When it can't be + * written, keep the binding on the device so cleanup keeps a reference instead of orphaning it. + */ + private queueOrRetainRelayDeviceRevoke(deviceId: string, binding: RelayDeviceBinding): void { + if (this.queueRelayDeviceRevoke(binding)) { + return + } + try { + this.deviceRegistry?.setRelayBinding(deviceId, binding) + } catch (error) { + console.error('[runtime] Failed to retain an unrevoked Relay binding:', error) + } + } + + private queueRelayDeviceRevoke(binding: RelayDeviceBinding): boolean { + let item: RelayRevokeOutboxItem + try { + item = this.relayRevokeOutbox.enqueue(binding) + } catch (error) { + console.error('[runtime] Failed to persist Relay device cleanup:', error) + return false + } + try { + this.mobileRelayPairingProvider?.onDeviceRevokeQueued(item) + } catch (error) { + console.warn('[runtime] Failed to notify Relay cleanup worker:', error) + } + return true + } + + private registerBinaryStreamHandler( + connectionId: string | undefined, + streamId: number, + handler: (frame: TerminalStreamFrame) => void + ): () => void { + if (!connectionId || !Number.isInteger(streamId) || streamId < 0) { + return () => {} + } + let handlers = this.binaryStreamHandlers.get(connectionId) + if (!handlers) { + handlers = new Map() + this.binaryStreamHandlers.set(connectionId, handlers) + } + handlers.set(streamId, handler) + return () => { + const current = this.binaryStreamHandlers.get(connectionId) + if (!current || current.get(streamId) !== handler) { + return + } + current.delete(streamId) + if (current.size === 0) { + this.binaryStreamHandlers.delete(connectionId) + } + } + } + + private handleWebSocketBinaryMessage(bytes: Uint8Array, ws: WebSocket): void { + const connectionId = this.mobileSocketWiring?.getConnectionId(ws) + if (!connectionId) { + return + } + const frame = decodeTerminalStreamFrame(bytes) + if (!frame) { + return + } + this.binaryStreamHandlers.get(connectionId)?.get(frame.streamId)?.(frame) + } + + private registerWebSocketDispatchAbort(ws: WebSocket): { + signal: AbortSignal + dispose: () => void + } { + const abortController = new AbortController() + if (ws.readyState !== ws.OPEN) { + abortController.abort() + return { signal: abortController.signal, dispose: () => {} } + } + + let state = this.wsDispatchAbortStates.get(ws) + if (!state) { + state = { + controllers: new Set(), + abortOnClose: () => this.abortWebSocketDispatches(ws) + } + this.wsDispatchAbortStates.set(ws, state) + // Why: many streaming RPCs share one WebSocket; one socket-level abort fan-out avoids MaxListenersExceededWarning. + ws.on('close', state.abortOnClose) + ws.on('error', state.abortOnClose) + } + state.controllers.add(abortController) + + return { + signal: abortController.signal, + dispose: () => { + const current = this.wsDispatchAbortStates.get(ws) + if (!current) { + return + } + current.controllers.delete(abortController) + if (current.controllers.size > 0) { + return + } + this.wsDispatchAbortStates.delete(ws) + ws.off('close', current.abortOnClose) + ws.off('error', current.abortOnClose) + } + } + } + + private abortWebSocketDispatches(ws: WebSocket): void { + const state = this.wsDispatchAbortStates.get(ws) + if (!state) { + return + } + this.wsDispatchAbortStates.delete(ws) + ws.off('close', state.abortOnClose) + ws.off('error', state.abortOnClose) + for (const controller of state.controllers) { + controller.abort() + } + state.controllers.clear() + } + + private initializePairingIdentity(): PairingIdentityInitialization { + let deviceRegistry: DeviceRegistry + try { + deviceRegistry = new DeviceRegistry(this.userDataPath) + } catch (error) { + console.error('[runtime] Failed to initialize pairing registry:', error) + return { + ok: false, + failure: pairingUnavailable( + 'device_registry_unavailable', + DEVICE_REGISTRY_UNAVAILABLE_GUIDANCE + ) + } + } + let e2eeKeypair: E2EEKeypair + try { + e2eeKeypair = loadOrCreateE2EEKeypair(this.userDataPath) + } catch (error) { + console.error('[runtime] Failed to initialize E2EE identity:', error) + return { + ok: false, + failure: pairingUnavailable('e2ee_key_unavailable', E2EE_KEY_UNAVAILABLE_GUIDANCE) + } + } + return { ok: true, deviceRegistry, e2eeKeypair } + } + + async start(): Promise { + if (this.activeTransports.length > 0) { + return + } + + // Why: SIGKILL/OOM skip stop(), orphaning `o--*.sock` files; sweep them. Skipped on Windows: named pipes leave no filesystem entries. + if (this.platform !== 'win32') { + sweepOrphanedRuntimeSockets(this.userDataPath, this.pid) + } + + const transportMeta = createRuntimeTransportMetadata( + this.userDataPath, + this.pid, + this.platform, + this.runtime.getRuntimeId() + ) + + const socketTransport = new UnixSocketTransport({ + endpoint: transportMeta.endpoint, + kind: transportMeta.kind as 'unix' | 'named-pipe', + keepaliveIntervalMs: this.keepaliveIntervalMs + }) + + // Why: the `.catch` guarantees reply() always fires so a throw can't strand the client or leak the AbortController. + socketTransport.onMessage((msg, reply, context) => { + void this.handleMessage(msg, context) + .then((response) => { + reply(JSON.stringify(response)) + }) + .catch((error) => { + const message = error instanceof Error ? error.message : String(error) + // Why: best-effort id recovery so the client can correlate the error frame to its pending request. + let id = 'unknown' + try { + const parsed = JSON.parse(msg) as { id?: unknown } + if (typeof parsed.id === 'string' && parsed.id.length > 0) { + id = parsed.id + } + } catch { + // ignore — fall through with id='unknown' + } + reply(JSON.stringify(this.buildError(id, 'internal_error', message))) + }) + }) + + await socketTransport.start() + + const activeTransports: RpcTransport[] = [socketTransport] + const transportsMeta: RuntimeTransportMetadata[] = [transportMeta] + + // Why: WebSocket uses per-device tokens + E2EE (tweetnacl) instead of TLS since React Native can't pin self-signed certs. + if (this.enableWebSocket) { + // Why: land any deferred lastSeen write before a replacement registry reads the same file. + this.deviceRegistry?.flushPendingLastSeen() + const pairingIdentity = this.initializePairingIdentity() + if (!pairingIdentity.ok) { + this.deviceRegistry = null + this.e2eeKeypair = null + this.pairingInitializationFailure = pairingIdentity.failure + } else { + this.deviceRegistry = pairingIdentity.deviceRegistry + this.e2eeKeypair = pairingIdentity.e2eeKeypair + this.pairingInitializationFailure = null + try { + const host = this.resolveInitialWebSocketBindHost() + const { transport, endpoint } = await this.startWebSocketTransport({ + host, + port: this.wsPort, + preferPinnedPort: this.preferPinnedWsPort, + // Why: stable fallback port across restarts keeps paired devices' endpoints valid (STA-1511); wsPort 0 = random (E2E). + ...(this.wsPort !== 0 ? { fallbackPort: readWsFallbackPort(this.userDataPath) } : {}) + }) + if (this.wsPort !== 0 && transport.resolvedPort !== this.wsPort) { + writeWsFallbackPort(this.userDataPath, transport.resolvedPort) + } + activeTransports.push(transport) + transportsMeta.push({ kind: 'websocket', endpoint }) + } catch (error) { + // Why: WebSocket transport is supplementary; on failure (e.g. port in use) continue with Unix socket only. + console.error('[runtime] Failed to start WebSocket transport:', error) + this.mobileSocketWiring = null + } + } + } + + // Why: set in-memory transport state before writing metadata so the bootstrap file has the real endpoint/token pair. + this.activeTransports = activeTransports + this.transports = transportsMeta + + try { + this.writeMetadata() + } catch (error) { + // Why: a runtime that can't publish metadata is invisible to the CLI — close transports rather than run undiscoverable. + this.activeTransports = [] + this.transports = [] + await Promise.all(activeTransports.map((t) => t.stop().catch(() => {}))).catch(() => {}) + throw error + } + + this.metadataOwnershipWatch = watchRuntimeMetadataOwnership({ + userDataPath: this.userDataPath, + ownedPid: this.pid, + ownedRuntimeId: this.runtime.getRuntimeId(), + pollIntervalMs: this.metadataOwnershipPollMs, + republish: () => { + // Why: never advertise endpoints we already tore down. + if (this.activeTransports.length === 0) { + return + } + this.writeMetadata() + }, + onReclaim: (previous) => { + console.warn( + `[runtime] Reclaimed orca-runtime.json from a dead runtime (pid ${previous?.pid ?? 'none'}); republished pid ${this.pid}.` + ) + } + }) + } + + // Why: STA-2370 — a desktop with no previously-connected device stays on loopback until the user + // explicitly pairs; `orca serve`/E2E (exposeNetworkByDefault) and a reconnecting paired device bind wide. + // A grant minted for "This computer only" is excluded: its client is a browser on this machine, so + // counting it would republish the runtime on every interface one restart after the user declined that. + private resolveInitialWebSocketBindHost(): string { + if (this.exposeNetworkByDefault) { + return WS_BIND_HOST_ALL_INTERFACES + } + const hasConnectedNetworkDevice = + this.deviceRegistry + ?.listDevices() + .some((device) => device.lastSeenAt > 0 && device.pairingReach !== 'this-computer') ?? false + return hasConnectedNetworkDevice ? WS_BIND_HOST_ALL_INTERFACES : WS_BIND_HOST_LOOPBACK + } + + // Why: builds and starts a WS transport bound to `host`, wiring the session-scoped mobile socket + // handlers. Shared by initial start() and the pairing-time widen so both paths stay identical. + private async startWebSocketTransport(options: { + host: string + port: number + preferPinnedPort: boolean + fallbackPort?: number + }): Promise<{ transport: WebSocketTransport; endpoint: string }> { + const deviceRegistry = this.deviceRegistry + const e2eeKeypair = this.e2eeKeypair + if (!deviceRegistry || !e2eeKeypair) { + throw new Error('WebSocket transport requires an initialized pairing identity') + } + const wsTransport = new WebSocketTransport({ + host: options.host, + port: options.port, + staticRoot: this.webClientRoot, + ...(options.fallbackPort !== undefined ? { fallbackPort: options.fallbackPort } : {}), + ...(options.preferPinnedPort ? { preferPinnedPort: true } : {}) + }) + const mobileSocketWiring = this.ensureMobileSocketWiring(deviceRegistry, e2eeKeypair) + this.detachWebSocketWiring = mobileSocketWiring.attachTransport(wsTransport) + + try { + await wsTransport.start() + } catch (error) { + // Why: a listener that never bound must not stay attached to the session wiring (it would leak into + // terminateDeviceConnections); detach before propagating so the wiring only tracks live transports. + this.detachWebSocketWiring?.() + this.detachWebSocketWiring = null + throw error + } + this.wsBoundHost = options.host + return { + transport: wsTransport, + endpoint: `ws://${options.host}:${wsTransport.resolvedPort}` + } + } + + // Why: one MobileSocketWiring per server session. Direct WS and cloud relay both attach to it, and + // DesktopRelayService captures it once at construction, so a loopback→wide pairing rebind must swap the + // transport under the SAME wiring — replacing the wiring would strand relay sockets (lost connection IDs, + // binary handling, and revocation targeting) on a dead object. + private ensureMobileSocketWiring( + deviceRegistry: DeviceRegistry, + e2eeKeypair: E2EEKeypair + ): MobileSocketWiring { + if (this.mobileSocketWiring) { + return this.mobileSocketWiring + } + // Why: session-scoped so each desktop launch may notify once (a mid-session rebind must not reset it). + this.unpairedDeviceAuthThrottle = new UnpairedDeviceAuthThrottle({ + onTrigger: () => this.onUnpairedDeviceAuthFailure?.() + }) + const mobileSocketWiring = new MobileSocketWiring({ + deviceRegistry, + e2eeKeypair, + onText: (socket, plaintext, reply, sendBinary) => { + void this.handleWebSocketMessage( + plaintext, + reply, + sendBinary, + undefined, + socket.ws, + socket.device.deviceToken, + socket + ) + }, + onBinary: (socket, bytes) => this.handleWebSocketBinaryMessage(bytes, socket.ws), + onReady: () => { + // Why: first authenticated mobile/remote client (direct WS and + // cloud relay both attach here) starts path-candidate tracking. + // Activation is a local-host concern: candidate buffers live on the + // buffer-owning host's runtime, so a remote runtime proxy may + // legitimately lack this method (its own server activates it). + this.runtime.activateRecentPtyPathCandidateTracking?.() + this.mobileRelayPairingProvider?.onDemandStateChanged?.() + }, + onClose: (socket, hasOtherConnections) => { + if (!socket) { + return + } + this.abortWebSocketDispatches(socket.ws) + // Why: subscriptions and binary streams are socket-scoped, but disconnect state is device-scoped across transports. + this.runtime.cleanupSubscriptionsForConnection(socket.connectionId) + this.runtime.cancelMobileDictationForConnection(socket.connectionId) + this.binaryStreamHandlers.delete(socket.connectionId) + if (!hasOtherConnections) { + this.runtime.onClientDisconnected(socket.device.deviceToken) + } + }, + // Why: relay attempts are authorized upstream; only direct failures should prompt local re-pairing. + onUnpairedDeviceAuthFailure: (metadata) => { + if (metadata.transport === 'direct') { + this.unpairedDeviceAuthThrottle?.recordFailure() + } + } + }) + this.mobileSocketWiring = mobileSocketWiring + return mobileSocketWiring + } + + // Why: STA-2370 — widen the loopback listener to all interfaces so a freshly generated pairing + // offer's advertised LAN endpoint is reachable. Idempotent, but it is no longer confined to the first + // pairing action: a "This computer only" link never widens, so live loopback clients can already be + // connected when a later LAN/QR offer opts in. Rebinding terminates them (ws cannot move a listener), + // so the resolved port is reused — already-issued endpoints stay valid and clients reconnect in place. + async ensureNetworkExposure(): Promise { + if ( + !this.enableWebSocket || + this.stopping || + this.wsBoundHost === WS_BIND_HOST_ALL_INTERFACES + ) { + return + } + // Why: concurrent pairing requests must share one rebind — two simultaneous stop/start races would + // fight for the port and strand the listener on a random one. + if (!this.networkExposurePromise) { + this.networkExposurePromise = this.widenWebSocketBind().finally(() => { + this.networkExposurePromise = null + }) + } + return this.networkExposurePromise + } + + private async widenWebSocketBind(): Promise { + const current = this.activeTransports.find( + (transport): transport is WebSocketTransport => transport instanceof WebSocketTransport + ) + if (!current) { + return + } + const index = this.activeTransports.indexOf(current) + const previousPort = current.resolvedPort + let widened: { transport: WebSocketTransport; endpoint: string } + try { + // Why: detach the loopback listener from the session wiring before stopping it so terminateDevice- + // Connections never iterates a dead transport; the new listener re-attaches to the SAME wiring. + this.detachWebSocketWiring?.() + await current.stop() + widened = await this.startWebSocketTransport({ + host: WS_BIND_HOST_ALL_INTERFACES, + port: previousPort, + preferPinnedPort: true + }) + } catch (error) { + // Why: the wide bind failed after the loopback listener was already stopped. Restore a serving + // loopback listener on the same port (wsBoundHost stays loopback so a later offer retries), then + // propagate — the caller must not advertise a LAN endpoint with no LAN listener behind it (STA-2370). + console.error('[runtime] Failed to widen WebSocket transport for pairing:', error) + await this.recoverWebSocketBindAfterFailedWiden(index, previousPort) + throw error + } + // Why: register the live wide transport BEFORE persisting metadata so a metadata-write failure can + // never orphan a running 0.0.0.0 listener outside activeTransports (and thus outside stop()). + this.activeTransports[index] = widened.transport + const metaIndex = this.transports.findIndex((meta) => meta.kind === 'websocket') + if (metaIndex !== -1) { + this.transports[metaIndex] = { kind: 'websocket', endpoint: widened.endpoint } + } + try { + // Why: a rebind that lands on a different port (same-port bind refused) must be persisted so a + // later reconnect from a device paired to this port matches on the next launch (STA-1511). + if (this.wsPort !== 0 && widened.transport.resolvedPort !== this.wsPort) { + writeWsFallbackPort(this.userDataPath, widened.transport.resolvedPort) + } + this.writeMetadata() + } catch (persistError) { + // Why: the wide listener is live and tracked; a persistence failure must not tear it down. Keep + // serving — the in-memory endpoint is already correct. + console.error( + '[runtime] Widened WebSocket listener but failed to persist pairing metadata:', + persistError + ) + } + } + + // Why: a failed widen already tore down the loopback listener; bring one back on the same host/port so the + // runtime keeps serving locally (wsBoundHost stays loopback, so the next pairing offer retries the widen). + private async recoverWebSocketBindAfterFailedWiden( + index: number, + previousPort: number + ): Promise { + let restored: { transport: WebSocketTransport; endpoint: string } + try { + restored = await this.startWebSocketTransport({ + host: WS_BIND_HOST_LOOPBACK, + port: previousPort, + preferPinnedPort: true + }) + } catch (recoveryError) { + // Why: even the loopback restore failed — drop the dead WebSocket transport so we never advertise an + // endpoint with no listener. The Unix socket keeps serving and a restart re-establishes the listener. + console.error( + '[runtime] Failed to restore WebSocket transport after widen failure:', + recoveryError + ) + this.activeTransports.splice(index, 1) + const metaIndex = this.transports.findIndex((meta) => meta.kind === 'websocket') + if (metaIndex !== -1) { + this.transports.splice(metaIndex, 1) + } + this.wsBoundHost = null + try { + this.writeMetadata() + } catch { + // Why: metadata already reflects the torn-down listener; nothing else to recover here. + } + return + } + // Why: register the restored loopback listener BEFORE persisting metadata so a write failure can never + // orphan a live transport outside activeTransports (and thus outside stop()). + this.activeTransports[index] = restored.transport + const metaIndex = this.transports.findIndex((meta) => meta.kind === 'websocket') + if (metaIndex !== -1) { + this.transports[metaIndex] = { kind: 'websocket', endpoint: restored.endpoint } + } + try { + this.writeMetadata() + } catch (persistError) { + // Why: the loopback listener is live and tracked; a persistence failure must not tear it down. + console.error( + '[runtime] Restored loopback WebSocket listener but failed to persist metadata:', + persistError + ) + } + } + + /** Why: test-only seam — runs one ownership check instead of waiting out the poll interval. */ + checkRuntimeMetadataOwnership(): void { + this.metadataOwnershipWatch?.check() + } + + async stop(): Promise { + // Why: STA-2370 — refuse new widens, then let any in-flight pairing widen settle into the live + // transport arrays before snapshotting them, so a racing rebind can't strand a wide 0.0.0.0 listener + // by writing it back into a cleared array after shutdown (see widenWebSocketBind). + this.stopping = true + const pendingExposure = this.networkExposurePromise + if (pendingExposure) { + await pendingExposure.catch(() => { + // Why: a failed widen already recovered/logged; we only need it to finish mutating the arrays. + }) + } + const transports = this.activeTransports + this.activeTransports = [] + this.transports = [] + this.metadataOwnershipWatch?.stop() + this.metadataOwnershipWatch = null + this.mobileSocketWiring = null + this.detachWebSocketWiring = null + const stopResults = await Promise.allSettled( + transports.map(async (transport) => transport.stop()) + ) + // Why: before-quit fences relay input; direct auth can still refresh lastSeen while these transports close. + this.deviceRegistry?.flushPendingLastSeen() + const failedStop = stopResults.find((result) => result.status === 'rejected') + if (failedStop?.status === 'rejected') { + throw failedStop.reason + } + // Why: leave the metadata file on shutdown — shared userData may host another live runtime whose bootstrap file we'd erase. + } + + // Why: Unix socket dispatch is one-shot and auths via the shared token from the 0o600 metadata file. See §3.1. + private async handleMessage( + rawMessage: string, + context?: RpcMessageContext + ): Promise { + // Why: the transport sends an empty message when a client exceeds max size, then closes the connection. + if (!rawMessage) { + return this.buildError('unknown', 'request_too_large', 'RPC request exceeds the maximum size') + } + + const parsed = this.parseAndAuth(rawMessage) + if ('error' in parsed) { + return parsed.error + } + const request = parsed.request + + // Why: long-poll admission fence; short RPCs bypass the counter. See §7 risk #2. + const longPoll = longPollClassOf(request) + const rejection = this.admitLongPoll(longPoll) + if (rejection) { + return this.buildError(request.id, 'runtime_busy', rejection) + } + if (longPoll) { + // Why: arm keepalive only for long-polls; short RPCs never create the setInterval. See §3.1. + context?.startKeepalive() + } + + try { + return await this.dispatcher.dispatch(request, { + signal: longPoll ? context?.signal : undefined + }) + } finally { + this.releaseLongPoll(longPoll) + } + } + + // Why: one fence for both transports — the total cap protects short RPCs, the ask + // sub-cap protects terminal.wait / check --wait from slow reply-blocked asks. + // Returns the rejection message, or null once the slot is reserved. + private admitLongPoll(longPoll: LongPollClass | null): string | null { + if (!longPoll) { + return null + } + if (this.activeLongPolls >= this.longPollCap) { + return 'long-poll capacity reached; retry with backoff' + } + if (longPoll === 'ask' && this.activeAskLongPolls >= this.askLongPollCap) { + return 'orchestration.ask capacity reached; retry with backoff' + } + this.activeLongPolls += 1 + if (longPoll === 'ask') { + this.activeAskLongPolls += 1 + } + return null + } + + private releaseLongPoll(longPoll: LongPollClass | null): void { + if (!longPoll) { + return + } + this.activeLongPolls = Math.max(0, this.activeLongPolls - 1) + if (longPoll === 'ask') { + this.activeAskLongPolls = Math.max(0, this.activeAskLongPolls - 1) + } + } + + private parseAndAuth(rawMessage: string): { request: RpcRequest } | { error: RpcResponse } { + let request: RpcRequest + try { + request = JSON.parse(rawMessage) as RpcRequest + } catch { + return { error: this.buildError('unknown', 'bad_request', 'Invalid JSON request') } + } + + if (typeof request.id !== 'string' || request.id.length === 0) { + return { error: this.buildError('unknown', 'bad_request', 'Missing request id') } + } + if (typeof request.method !== 'string' || request.method.length === 0) { + return { error: this.buildError(request.id, 'bad_request', 'Missing RPC method') } + } + if (typeof request.authToken !== 'string' || request.authToken.length === 0) { + return { error: this.buildError(request.id, 'unauthorized', 'Missing auth token') } + } + if (request.authToken !== this.authToken) { + return { error: this.buildError(request.id, 'unauthorized', 'Invalid auth token') } + } + + return { request } + } + + // Why: WebSocket dispatch is streaming (multiple responses) and auths via per-device tokens, not the shared token. + private async handleWebSocketMessage( + rawMessage: string, + reply: (response: string) => void, + sendBinary: (response: Uint8Array) => boolean | void, + wsTransport?: WebSocketTransport, + ws?: WebSocket, + authenticatedDeviceToken?: string | null, + authenticatedSocket?: AuthenticatedMobileSocket + ): Promise { + let request: RpcRequest + try { + request = JSON.parse(rawMessage) as RpcRequest + } catch { + reply(JSON.stringify(this.buildError('unknown', 'bad_request', 'Invalid JSON request'))) + return + } + + if (typeof request.id !== 'string' || request.id.length === 0) { + reply(JSON.stringify(this.buildError('unknown', 'bad_request', 'Missing request id'))) + return + } + if (typeof request.method !== 'string' || request.method.length === 0) { + reply(JSON.stringify(this.buildError(request.id, 'bad_request', 'Missing RPC method'))) + return + } + + const requestToken = + typeof (request as Record).deviceToken === 'string' + ? ((request as Record).deviceToken as string) + : null + if (authenticatedDeviceToken && requestToken && requestToken !== authenticatedDeviceToken) { + reply(JSON.stringify(this.buildError(request.id, 'unauthorized', 'Device token mismatch'))) + return + } + // Why: E2EE already authenticated the channel; authorize by that bound identity, not a repeated request field. + const token = authenticatedDeviceToken ?? requestToken + if (!token) { + reply(JSON.stringify(this.buildError(request.id, 'unauthorized', 'Missing device token'))) + return + } + const device = this.deviceRegistry?.validateToken(token) + if (!device) { + reply(JSON.stringify(this.buildError(request.id, 'unauthorized', 'Invalid device token'))) + return + } + if (device.scope === 'mobile' && !MOBILE_RPC_METHOD_ALLOWLIST.has(request.method)) { + reply( + JSON.stringify( + this.buildError( + request.id, + 'forbidden', + `Method '${request.method}' is not available to mobile clients` + ) + ) + ) + return + } + + // Why: bind deviceToken to this socket so ws.on('close') knows which mobile client disconnected. + if (wsTransport && ws) { + wsTransport.setClientId(ws, token) + } + + const longPoll = longPollClassOf(request) + const rejection = this.admitLongPoll(longPoll) + if (rejection) { + reply(JSON.stringify(this.buildError(request.id, 'runtime_busy', rejection))) + return + } + + const abortRegistration = ws ? this.registerWebSocketDispatchAbort(ws) : null + + // Why: older pairings may lack scope metadata, so stamp the authenticated scope onto status.get. + const replyForRequest = + request.method === 'status.get' + ? (response: string): void => reply(injectDeviceScope(response, device.scope)) + : reply + + const connectionId = ws ? this.mobileSocketWiring?.getConnectionId(ws) : undefined + const pairingProvider = this.mobileRelayPairingProvider + const pairingContext = + pairingProvider && authenticatedSocket + ? { + getEndpoints: (params: PairingGetEndpointsParams) => + pairingProvider.getEndpoints( + { + deviceId: authenticatedSocket.device.deviceId, + connectionId: authenticatedSocket.connectionId, + transport: authenticatedSocket.transport + }, + params + ), + provisionRelay: (params: PairingProvisionRelayParams) => + pairingProvider.provisionRelay( + { + deviceId: authenticatedSocket.device.deviceId, + connectionId: authenticatedSocket.connectionId, + transport: authenticatedSocket.transport + }, + params + ) + } + : undefined + try { + await this.dispatcher.dispatchStreaming(request, replyForRequest, { + // Why: the validated credential preserves existing federation ownership without trusting request fields. + authenticatedCallerFingerprint: fingerprintAuthenticatedPairingCredential(token), + connectionId, + clientId: token, + pairedDeviceId: device.deviceId, + // Why: gates the mobile-only payload diet so full-screen web/desktop clients aren't truncated. + clientKind: device.scope, + clientCapabilities: authenticatedSocket?.clientCapabilities, + pairing: pairingContext, + signal: abortRegistration?.signal, + sendBinary, + registerBinaryStreamHandler: (streamId, handler) => + this.registerBinaryStreamHandler(connectionId, streamId, handler) + }) + } finally { + abortRegistration?.dispose() + this.releaseLongPoll(longPoll) + } + } + + private buildError(id: string, code: string, message: string): RpcResponse { + return errorResponse(id, { runtimeId: this.runtime.getRuntimeId() }, code, message) + } + + private writeMetadata(): void { + const metadata: RuntimeMetadata = { + runtimeId: this.runtime.getRuntimeId(), + pid: this.pid, + transports: this.transports, + authToken: this.authToken, + startedAt: this.runtime.getStartedAt() + } + writeRuntimeMetadata(this.userDataPath, metadata) + } +} + +/** Why: MUST stay in lockstep with createRuntimeTransportMetadata()'s `o-${pid}-${suffix}.sock` shape (unit-test enforced). */ +export const RUNTIME_SOCKET_NAME_REGEX = /^o-(\d+)-[A-Za-z0-9_-]+\.sock$/ + +export function sweepOrphanedRuntimeSockets(userDataPath: string, ownPid: number): void { + let entries: string[] + try { + entries = readdirSync(userDataPath) + } catch { + // Why: first-launch userData may not exist yet; nothing to sweep. + return + } + for (const entry of entries) { + const match = RUNTIME_SOCKET_NAME_REGEX.exec(entry) + if (!match) { + continue + } + const pid = Number(match[1]) + if (!Number.isFinite(pid)) { + continue + } + // Why: never delete our own socket — a bug here would rmSync one we're about to bind. + if (pid === ownPid) { + continue + } + try { + // Why: signal 0 is the POSIX liveness probe (sends nothing); ESRCH = dead pid, EPERM = foreign owner (left alone). + process.kill(pid, 0) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ESRCH') { + try { + rmSync(join(userDataPath, entry), { force: true }) + } catch { + // Why: best-effort sweep; a later start() or OS reboot cleans any socket we can't unlink. + } + } + } + } +} + +export function createRuntimeTransportMetadata( + userDataPath: string, + pid: number, + platform: NodeJS.Platform, + runtimeId = 'runtime' +): RuntimeTransportMetadata { + const endpointSuffix = runtimeId.replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 4) || 'rt' + if (platform === 'win32') { + return { + kind: 'named-pipe', + // Why: named pipes lack the chmod hardening of Unix sockets; a per-runtime suffix avoids a stable, guessable endpoint name. + endpoint: `\\\\.\\pipe\\orca-${pid}-${endpointSuffix}` + } + } + return { + kind: 'unix', + endpoint: join(userDataPath, `o-${pid}-${endpointSuffix}.sock`) + } +} diff --git a/src/renderer/src/components/NewWorkspaceComposerModal.test.tsx b/src/renderer/src/components/NewWorkspaceComposerModal.test.tsx new file mode 100644 index 00000000000..d6f1a5f7c03 --- /dev/null +++ b/src/renderer/src/components/NewWorkspaceComposerModal.test.tsx @@ -0,0 +1,179 @@ +// @vitest-environment happy-dom + +import React, { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { LocalAgentCatalogSnapshot } from '../../../shared/agent-catalog-snapshot' +import type { CustomTuiAgentId, TuiAgent } from '../../../shared/types' + +globalThis.IS_REACT_ACT_ENVIRONMENT = true + +const CUSTOM_CODEX = 'custom-agent:codex:22222222-2222-4222-8222-222222222222' as CustomTuiAgentId + +const mocks = vi.hoisted(() => ({ + submitQuick: vi.fn(), + createDisabled: false, + catalog: { + snapshot: null as unknown, + loading: true, + unavailable: false + }, + cardProps: [] as { createDisabled?: boolean; onCreate?: () => void }[] +})) + +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: unknown) => unknown) => + selector({ + activeModal: 'new-workspace-composer', + modalData: {}, + closeModal: vi.fn(), + settings: { defaultTuiAgent: CUSTOM_CODEX, disabledTuiAgents: [] } + }) +})) + +vi.mock('@/hooks/useComposerState', () => ({ + useComposerState: () => ({ + cardProps: { + detectedAgentIds: new Set(['codex']), + projectOptions: [], + selectedProjectId: null, + selectedRepoIsGit: true + }, + composerRef: { current: null }, + onComposerNodeChange: vi.fn(), + nameInputRef: { current: null }, + submitQuick: mocks.submitQuick, + createDisabled: mocks.createDisabled, + selectAddedProjectRepo: vi.fn() + }) +})) + +vi.mock('@/hooks/useLocalAgentCatalog', () => ({ + useLocalAgentCatalog: () => ({ + snapshot: mocks.catalog.snapshot, + loading: mocks.catalog.loading, + unavailable: mocks.catalog.unavailable, + refetch: vi.fn(), + applySnapshot: vi.fn() + }) +})) + +vi.mock('@/components/NewWorkspaceComposerCard', () => ({ + default: (props: { createDisabled?: boolean; onCreate?: () => void }) => { + mocks.cardProps.push(props) + return ( + + ) + } +})) + +vi.mock('@/components/agent/AgentSettingsDialog', () => ({ default: () => null })) + +vi.mock('@/lib/lazy-with-retry', () => ({ lazyWithRetry: () => () => null })) + +vi.mock('@/components/ui/dialog', () => ({ + Dialog: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogDescription: ({ children }: { children: React.ReactNode }) =>

{children}

, + DialogHeader: ({ children }: { children: React.ReactNode }) =>
{children}
, + DialogTitle: ({ children }: { children: React.ReactNode }) =>

{children}

+})) + +const READY_CUSTOM_CATALOG = { + customAgents: [ + { + status: 'ready', + definition: { + id: CUSTOM_CODEX, + baseAgent: 'codex', + label: 'Team Codex', + args: '--model team', + syncEnv: false, + commandOverride: '/opt/bin/codex' + }, + envSummary: { entryCount: 0, bytes: 0 }, + availabilityReason: 'configured-executable' + } + ] +} as unknown as LocalAgentCatalogSnapshot + +let container: HTMLDivElement +let root: Root + +async function render(): Promise { + const { default: NewWorkspaceComposerModal } = await import('./NewWorkspaceComposerModal') + await act(async () => { + root.render() + }) +} + +function clickCreate(): void { + const button = container.querySelector('[data-testid="create"]') + expect(button).not.toBeNull() + act(() => { + button?.click() + }) +} + +beforeEach(() => { + mocks.submitQuick.mockReset() + mocks.createDisabled = false + mocks.catalog.snapshot = null + mocks.catalog.loading = true + mocks.catalog.unavailable = false + mocks.cardProps = [] + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() +}) + +describe('NewWorkspaceComposerModal quick create', () => { + it('blocks create while the local agent catalog is still loading', async () => { + await render() + + expect(mocks.cardProps.at(-1)?.createDisabled).toBe(true) + clickCreate() + expect(mocks.submitQuick).not.toHaveBeenCalled() + }) + + it('submits the custom default once the catalog has loaded', async () => { + mocks.catalog.snapshot = READY_CUSTOM_CATALOG + mocks.catalog.loading = false + await render() + + expect(mocks.cardProps.at(-1)?.createDisabled).toBe(false) + clickCreate() + expect(mocks.submitQuick).toHaveBeenCalledWith(CUSTOM_CODEX) + }) + + it('still allows create where the local catalog surface does not exist', async () => { + mocks.catalog.loading = false + mocks.catalog.unavailable = true + await render() + + expect(mocks.cardProps.at(-1)?.createDisabled).toBe(false) + clickCreate() + expect(mocks.submitQuick).toHaveBeenCalledWith('codex') + }) + + it('keeps the composer create gate disabled independently of the catalog', async () => { + mocks.createDisabled = true + mocks.catalog.snapshot = READY_CUSTOM_CATALOG + mocks.catalog.loading = false + await render() + + expect(mocks.cardProps.at(-1)?.createDisabled).toBe(true) + }) +}) diff --git a/src/renderer/src/components/NewWorkspaceComposerModal.tsx b/src/renderer/src/components/NewWorkspaceComposerModal.tsx index e6b499a99b7..eda958b3dab 100644 --- a/src/renderer/src/components/NewWorkspaceComposerModal.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerModal.tsx @@ -151,7 +151,10 @@ function QuickTabBody({ enableIssueAutomation: modalData.enableIssueAutomation === true, createGateMode: 'quick' }) - const { snapshot: localAgentCatalog } = useLocalAgentCatalog() + // Why: quick-create resolves the agent from this catalog, so submitting before + // it lands would silently swap a custom default for a built-in. `unavailable` + // (paired web) never resolves a snapshot, so it must not block creation. + const { snapshot: localAgentCatalog, loading: localAgentCatalogLoading } = useLocalAgentCatalog() const quickAgentOptions = useMemo( () => buildWorkspaceAgentOptions({ @@ -202,9 +205,14 @@ function QuickTabBody({ setQuickAgentOverride(agent) }, []) + const createDisabledForQuickAgent = createDisabled || localAgentCatalogLoading + const handleCreate = useCallback(async (): Promise => { + if (localAgentCatalogLoading) { + return + } await submitQuick(quickAgent) - }, [quickAgent, submitQuick]) + }, [localAgentCatalogLoading, quickAgent, submitQuick]) // Why: Add Project layers over the composer as a nested dialog instead of // replacing it in the activeModal slot — closing the composer mid-flow (and // losing the typed name/prompt) was the old, abrupt behavior. Once opened it @@ -280,7 +288,7 @@ function QuickTabBody({ if (!shouldAllowComposerEnterSubmitTarget(target, composerRef.current)) { return } - if (createDisabled) { + if (createDisabledForQuickAgent) { return } event.preventDefault() @@ -288,7 +296,7 @@ function QuickTabBody({ } window.addEventListener('keydown', onKeyDown, { capture: true }) return () => window.removeEventListener('keydown', onKeyDown, { capture: true }) - }, [active, composerRef, createDisabled, handleCreate, nestedDialogOpen]) + }, [active, composerRef, createDisabledForQuickAgent, handleCreate, nestedDialogOpen]) return ( <> @@ -322,6 +330,7 @@ function QuickTabBody({ onQuickAgentChange={handleQuickAgentChange} quickAgentOptions={quickAgentOptions} {...cardProps} + createDisabled={createDisabledForQuickAgent} primaryActionLabel={primaryActionLabel} onOpenAgentSettings={() => setAgentSettingsOpen(true)} onCreate={() => void handleCreate()} diff --git a/src/renderer/src/components/data-recovery/DataRecoveryDialog.test.tsx b/src/renderer/src/components/data-recovery/DataRecoveryDialog.test.tsx new file mode 100644 index 00000000000..804fdc165b1 --- /dev/null +++ b/src/renderer/src/components/data-recovery/DataRecoveryDialog.test.tsx @@ -0,0 +1,54 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { cleanup, render, screen } from '@testing-library/react' +import { DataRecoveryDialog } from './DataRecoveryDialog' + +const PIN = { + id: 'agent-catalog-pre-v1' as const, + compatibility: 'previous-binary' as const, + createdAtMs: 1_752_000_000_000, + sizeBytes: 1024 +} + +const UNREADABLE_COPY = /cannot be read, so it cannot be restored/ + +function installApi(points: unknown[]): void { + ;(window as unknown as { api: unknown }).api = { + dataRecovery: { + listPoints: vi.fn().mockResolvedValue(points), + restore: vi.fn().mockResolvedValue({ ok: true }) + } + } +} + +describe('DataRecoveryDialog', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + afterEach(() => { + cleanup() + }) + + it('offers the restore affordance for a readable point', async () => { + installApi([{ ...PIN, restorable: true }]) + render( {}} />) + expect(await screen.findByText('Restore these settings…')).toBeTruthy() + expect(screen.queryByText(UNREADABLE_COPY)).toBeNull() + }) + + it('withdraws the restore affordance when the point cannot be read', async () => { + installApi([{ ...PIN, restorable: false }]) + render( {}} />) + expect(await screen.findByText(UNREADABLE_COPY)).toBeTruthy() + expect(screen.queryByText('Restore these settings…')).toBeNull() + // The loss warning describes a restore that can no longer happen. + expect(screen.queryByText(/discards settings and custom agents/)).toBeNull() + }) + + it('stays restorable for a host that reports no readability at all', async () => { + installApi([PIN]) + render( {}} />) + expect(await screen.findByText('Restore these settings…')).toBeTruthy() + }) +}) diff --git a/src/renderer/src/components/data-recovery/DataRecoveryDialog.tsx b/src/renderer/src/components/data-recovery/DataRecoveryDialog.tsx index 80d1b34e029..f113567b84c 100644 --- a/src/renderer/src/components/data-recovery/DataRecoveryDialog.tsx +++ b/src/renderer/src/components/data-recovery/DataRecoveryDialog.tsx @@ -18,9 +18,7 @@ export type DataRecoveryDialogProps = { /** `restorable` is optional so a host that predates the readability probe still * lists its points; only an explicit `false` withdraws the restore affordance. */ -type ListedRecoveryPoint = RecoveryPointDto & { restorable?: boolean } - -function isRestorable(point: ListedRecoveryPoint): boolean { +function isRestorable(point: RecoveryPointDto): boolean { return point.restorable !== false } @@ -29,7 +27,7 @@ function pointTitle(point: RecoveryPointDto): string { case 'agent-catalog-pre-v1': return translate( 'auto.components.dataRecovery.pointAgentCatalogPreV1Title', - 'Before the custom-agents update (agent catalog v1)' + 'Before custom agents' ) } } @@ -39,7 +37,7 @@ function pointLossSummary(point: RecoveryPointDto): string { case 'agent-catalog-pre-v1': return translate( 'auto.components.dataRecovery.pointAgentCatalogPreV1Loss', - 'Restoring discards all settings and workspace metadata saved after this point, including any custom agents.' + 'This discards settings and custom agents saved after this update.' ) } } @@ -48,8 +46,8 @@ function pointLossSummary(point: RecoveryPointDto): string { * points by metadata only; the pinned pre-v1 point restores via Prepare * downgrade — Orca restores atomically and quits without relaunching. */ export function DataRecoveryDialog({ open, onOpenChange }: DataRecoveryDialogProps) { - const [points, setPoints] = useState(null) - const [confirming, setConfirming] = useState(null) + const [points, setPoints] = useState(null) + const [confirming, setConfirming] = useState(null) const [restoring, setRestoring] = useState(false) const [restored, setRestored] = useState(false) const [error, setError] = useState(null) @@ -133,7 +131,7 @@ export function DataRecoveryDialog({ open, onOpenChange }: DataRecoveryDialogPro {translate( 'auto.components.dataRecovery.description', - 'Restore this profile from a recovery point created before a data migration. Restoring never deletes the recovery point.' + 'Restore the settings Orca saved before this update. The saved copy is kept.' )} @@ -218,7 +216,7 @@ export function DataRecoveryDialog({ open, onOpenChange }: DataRecoveryDialogPro > {translate( 'auto.components.dataRecovery.prepareDowngrade', - 'Prepare downgrade…' + 'Restore these settings…' )} )} diff --git a/src/renderer/src/components/data-recovery/DataRecoveryMigrationNotice.test.tsx b/src/renderer/src/components/data-recovery/DataRecoveryMigrationNotice.test.tsx index 6b513718b5e..83ef9edecf8 100644 --- a/src/renderer/src/components/data-recovery/DataRecoveryMigrationNotice.test.tsx +++ b/src/renderer/src/components/data-recovery/DataRecoveryMigrationNotice.test.tsx @@ -81,14 +81,54 @@ describe('DataRecoveryMigrationNotice', () => { await vi.waitFor(() => expect(screen.queryByRole('alert')).toBeNull()) }) - it('opens Data recovery listing the pinned point with Prepare downgrade', async () => { + it('shows a non-retryable read-only notice when the profile schema is too new', async () => { + installApi({ + migrationStatus: vi.fn().mockResolvedValue({ + agentCatalogMigrationError: null, + agentCatalogSchemaTooNew: { persistedVersion: 2, supportedVersion: 1 } + }) + }) + render() + expect( + await screen.findByText('Custom agents are read-only in this version of Orca') + ).toBeTruthy() + expect(screen.getByRole('alert').textContent).toContain('saved by a newer version of Orca') + expect( + screen.getByText('Profile agent catalog v2; this version of Orca supports v1.') + ).toBeTruthy() + // Retry re-runs the pinned backup, which cannot clear a newer schema. + expect(screen.queryByText('Retry migration')).toBeNull() + }) + + it('prefers the read-only notice over a retry offer when both states are reported', async () => { + installApi({ + migrationStatus: vi.fn().mockResolvedValue({ + agentCatalogMigrationError: 'disk full', + agentCatalogSchemaTooNew: { persistedVersion: 3, supportedVersion: 1 } + }) + }) + render() + expect( + await screen.findByText('Custom agents are read-only in this version of Orca') + ).toBeTruthy() + expect(screen.queryByText('Retry migration')).toBeNull() + }) + + it('ignores an older host that never sends the schema field', async () => { + installApi({ + migrationStatus: vi.fn().mockResolvedValue({ agentCatalogMigrationError: 'disk full' }) + }) + render() + expect(await screen.findByText('Retry migration')).toBeTruthy() + expect(screen.queryByText('Custom agents are read-only in this version of Orca')).toBeNull() + }) + + it('opens Data recovery listing the pinned point with restore', async () => { const api = installApi() render() fireEvent.click(await screen.findByText('Open Data recovery')) - expect( - await screen.findByText('Before the custom-agents update (agent catalog v1)') - ).toBeTruthy() - fireEvent.click(screen.getByText('Prepare downgrade…')) + expect(await screen.findByText('Before custom agents')).toBeTruthy() + fireEvent.click(screen.getByText('Restore these settings…')) fireEvent.click(screen.getByText('Restore and quit')) await vi.waitFor(() => expect(api.restore).toHaveBeenCalledWith({ diff --git a/src/renderer/src/components/data-recovery/DataRecoveryMigrationNotice.tsx b/src/renderer/src/components/data-recovery/DataRecoveryMigrationNotice.tsx index d5abb891490..e9f2ed57362 100644 --- a/src/renderer/src/components/data-recovery/DataRecoveryMigrationNotice.tsx +++ b/src/renderer/src/components/data-recovery/DataRecoveryMigrationNotice.tsx @@ -2,6 +2,12 @@ import { useCallback, useEffect, useState } from 'react' import { AlertTriangle } from 'lucide-react' import { Button } from '@/components/ui/button' import { translate } from '@/i18n/i18n' +import { + agentCatalogSchemaTooNewMessage, + agentCatalogSchemaTooNewTitle, + agentCatalogSchemaTooNewVersions +} from '../settings/agent-catalog-schema-too-new' +import type { AgentCatalogSchemaTooNew } from '../../../../shared/data-recovery' import { DataRecoveryDialog } from './DataRecoveryDialog' /** App-level persistent notice for a blocked agent-catalog migration (runbook: @@ -10,6 +16,8 @@ import { DataRecoveryDialog } from './DataRecoveryDialog' * nothing on paired web (no dataRecovery preload surface) or when healthy. */ export function DataRecoveryMigrationNotice() { const [migrationError, setMigrationError] = useState(null) + // Absent on an older host, so a missing field is simply "not read-only". + const [schemaTooNew, setSchemaTooNew] = useState(null) const [retrying, setRetrying] = useState(false) const [recoveryOpen, setRecoveryOpen] = useState(false) @@ -17,8 +25,10 @@ export function DataRecoveryMigrationNotice() { try { const status = await window.api.dataRecovery?.migrationStatus() setMigrationError(status?.agentCatalogMigrationError ?? null) + setSchemaTooNew(status?.agentCatalogSchemaTooNew ?? null) } catch { setMigrationError(null) + setSchemaTooNew(null) } }, []) @@ -26,6 +36,31 @@ export function DataRecoveryMigrationNotice() { void refresh() }, [refresh]) + // Read-only wins over the retryable block: no retry can clear a newer schema, + // so offering one would only fail repeatedly. + if (schemaTooNew) { + const versions = agentCatalogSchemaTooNewVersions(schemaTooNew) + return ( +
+ +
+

{agentCatalogSchemaTooNewTitle()}

+

{agentCatalogSchemaTooNewMessage()}

+ {versions ?

{versions}

: null} +
+
+ +
+ +
+ ) + } + if (migrationError === null) { return null } diff --git a/src/renderer/src/components/data-recovery/DataRecoveryPinExitCustomAgentExample.tsx b/src/renderer/src/components/data-recovery/DataRecoveryPinExitCustomAgentExample.tsx new file mode 100644 index 00000000000..11bd8608f71 --- /dev/null +++ b/src/renderer/src/components/data-recovery/DataRecoveryPinExitCustomAgentExample.tsx @@ -0,0 +1,68 @@ +import { AgentIcon } from '@/lib/agent-catalog' +import { Badge } from '@/components/ui/badge' +import { cn } from '@/lib/utils' +import { translate } from '@/i18n/i18n' +import type { BuiltInTuiAgent } from '../../../../shared/types' + +export const PIN_EXIT_CUSTOM_AGENT_EXAMPLE_NAME = 'gpt-5.6-luna-low' +export const PIN_EXIT_CUSTOM_AGENT_EXAMPLE_COMMAND = + 'codex --model gpt-5.6-luna -c model_reasoning_effort="low"' + +/** Static picker mock: shows a custom agent as a named row among built-ins. */ +export function DataRecoveryPinExitCustomAgentExample() { + return ( +
+ + + +
+ ) +} + +function ExamplePickerRow({ + agent, + label, + command, + selected = false +}: { + agent: BuiltInTuiAgent + label: string + command?: string + selected?: boolean +}) { + return ( +
+ + + +
+
+ {label} + {selected ? ( + + {translate('auto.components.dataRecovery.pinExitExampleBadge', 'Custom')} + + ) : null} +
+ {command ? ( +

+ {command} +

+ ) : null} +
+
+ ) +} diff --git a/src/renderer/src/components/data-recovery/DataRecoveryPinExitNotice.test.tsx b/src/renderer/src/components/data-recovery/DataRecoveryPinExitNotice.test.tsx index 1b39f494deb..dfba901abdb 100644 --- a/src/renderer/src/components/data-recovery/DataRecoveryPinExitNotice.test.tsx +++ b/src/renderer/src/components/data-recovery/DataRecoveryPinExitNotice.test.tsx @@ -2,6 +2,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { DataRecoveryPinExitNotice } from './DataRecoveryPinExitNotice' +import { + PIN_EXIT_CUSTOM_AGENT_EXAMPLE_COMMAND, + PIN_EXIT_CUSTOM_AGENT_EXAMPLE_NAME +} from './DataRecoveryPinExitCustomAgentExample' import { dismissPinExitNotice } from './data-recovery-pin-exit-notice-dismissal' const PIN = { @@ -44,7 +48,7 @@ describe('DataRecoveryPinExitNotice', () => { it('renders nothing when the dataRecovery surface is absent', async () => { ;(window as unknown as { api: unknown }).api = {} render() - await vi.waitFor(() => expect(screen.queryByRole('status')).toBeNull()) + await vi.waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()) }) it('renders nothing when migration is blocked', async () => { @@ -52,31 +56,55 @@ describe('DataRecoveryPinExitNotice', () => { migrationStatus: vi.fn().mockResolvedValue({ agentCatalogMigrationError: 'disk full' }) }) render() - await vi.waitFor(() => expect(screen.queryByRole('status')).toBeNull()) + await vi.waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()) }) it('renders nothing when no recovery point exists', async () => { installApi({ listPoints: vi.fn().mockResolvedValue([]) }) render() - await vi.waitFor(() => expect(screen.queryByRole('status')).toBeNull()) + await vi.waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()) + }) + + it('renders nothing when the only pin cannot be restored', async () => { + installApi({ + listPoints: vi.fn().mockResolvedValue([{ ...PIN, restorable: false }]) + }) + render() + await vi.waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()) + }) + + it('shows exit guidance for a host that omits restorable', async () => { + installApi({ + listPoints: vi.fn().mockResolvedValue([{ ...PIN, restorable: undefined }]) + }) + render() + expect(await screen.findByRole('dialog')).toBeTruthy() }) it('shows exit guidance when a pin exists and migration is healthy', async () => { installApi() render() - expect(await screen.findByRole('status')).toBeTruthy() - expect(screen.getByText('This profile was upgraded for custom agents')).toBeTruthy() - expect(screen.getByText('Open Data recovery')).toBeTruthy() + expect(await screen.findByRole('dialog')).toBeTruthy() + expect(screen.getByText('Custom agents are now available')).toBeTruthy() + expect(screen.getByText(PIN_EXIT_CUSTOM_AGENT_EXAMPLE_NAME)).toBeTruthy() + expect(screen.getByText(PIN_EXIT_CUSTOM_AGENT_EXAMPLE_COMMAND)).toBeTruthy() + expect(screen.getByText('Before you install an older Orca')).toBeTruthy() + expect(screen.getByText('Restore the data backup. Orca will quit.')).toBeTruthy() + expect(screen.getByText('Then install the older Orca.')).toBeTruthy() + expect(screen.getByText('Restore data backup…')).toBeTruthy() + expect(screen.getByText('Continue')).toBeTruthy() + expect(screen.queryByText('Open Data recovery')).toBeNull() + expect(screen.queryByText(/profile/i)).toBeNull() }) it('dismisses for this pin and stays hidden after remount', async () => { installApi() const { unmount } = render() - fireEvent.click(await screen.findByText('Got it')) - await vi.waitFor(() => expect(screen.queryByRole('status')).toBeNull()) + fireEvent.click(await screen.findByText('Continue')) + await vi.waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()) unmount() render() - await vi.waitFor(() => expect(screen.queryByRole('status')).toBeNull()) + await vi.waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()) }) it('reappears for a newer pin after a prior dismiss', async () => { @@ -85,15 +113,13 @@ describe('DataRecoveryPinExitNotice', () => { listPoints: vi.fn().mockResolvedValue([{ ...PIN, createdAtMs: PIN.createdAtMs + 1 }]) }) render() - expect(await screen.findByRole('status')).toBeTruthy() + expect(await screen.findByRole('dialog')).toBeTruthy() }) - it('opens Data recovery from the banner', async () => { + it('opens the restore dialog from the data-backup action', async () => { installApi() render() - fireEvent.click(await screen.findByText('Open Data recovery')) - expect( - await screen.findByText('Before the custom-agents update (agent catalog v1)') - ).toBeTruthy() + fireEvent.click(await screen.findByText('Restore data backup…')) + expect(await screen.findByText('Before custom agents')).toBeTruthy() }) }) diff --git a/src/renderer/src/components/data-recovery/DataRecoveryPinExitNotice.tsx b/src/renderer/src/components/data-recovery/DataRecoveryPinExitNotice.tsx index c3c9f2ad7a3..e7ff172a34e 100644 --- a/src/renderer/src/components/data-recovery/DataRecoveryPinExitNotice.tsx +++ b/src/renderer/src/components/data-recovery/DataRecoveryPinExitNotice.tsx @@ -1,25 +1,43 @@ -import { useCallback, useEffect, useState } from 'react' -import { Info } from 'lucide-react' +import { useCallback, useEffect, useRef, useState } from 'react' import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { Separator } from '@/components/ui/separator' import { translate } from '@/i18n/i18n' import type { RecoveryPointDto } from '../../../../shared/data-recovery' import { DataRecoveryDialog } from './DataRecoveryDialog' +import { DataRecoveryPinExitCustomAgentExample } from './DataRecoveryPinExitCustomAgentExample' import { dismissPinExitNotice, isPinExitNoticeDismissed } from './data-recovery-pin-exit-notice-dismissal' -function preV1Point(points: RecoveryPointDto[]): RecoveryPointDto | null { - return points.find((point) => point.id === 'agent-catalog-pre-v1') ?? null +/** Only a pin that can actually be restored: an unreadable one would send people + * to a downgrade they cannot perform. `restorable` is optional (older hosts omit + * it), so only an explicit false withdraws the dialog. */ +function restorablePreV1Point(points: RecoveryPointDto[]): RecoveryPointDto | null { + return ( + points.find((point) => point.id === 'agent-catalog-pre-v1' && point.restorable !== false) ?? + null + ) } -/** One-shot info banner after a successful agent-catalog pin: tells people how - * to leave for stable. Hidden when migration is blocked (red notice owns that - * state), when no pin exists, on paired web, or after dismiss for this pin. */ +/** One-shot dialog after a successful agent-catalog pin. Most people should + * continue; the optional path is how to return to the previous Orca without + * reinstalling over live data. Hidden when migration is blocked (red notice + * owns that state), when no restorable pin exists, on paired web, or after + * dismiss for this pin. */ export function DataRecoveryPinExitNotice() { const [pin, setPin] = useState(null) const [dismissed, setDismissed] = useState(false) const [recoveryOpen, setRecoveryOpen] = useState(false) + const continueRef = useRef(null) const refresh = useCallback(async () => { try { @@ -29,7 +47,7 @@ export function DataRecoveryPinExitNotice() { return } const points = (await window.api.dataRecovery?.listPoints()) ?? [] - const next = preV1Point(points) + const next = restorablePreV1Point(points) setPin(next) setDismissed(next != null && isPinExitNoticeDismissed(next.createdAtMs)) } catch { @@ -41,50 +59,114 @@ export function DataRecoveryPinExitNotice() { void refresh() }, [refresh]) - if (pin === null || dismissed) { - return null - } - const handleDismiss = (): void => { - dismissPinExitNotice(pin.createdAtMs) + if (pin !== null) { + dismissPinExitNotice(pin.createdAtMs) + } setDismissed(true) } + const pinDialogOpen = pin !== null && !dismissed && !recoveryOpen + if (!pinDialogOpen && !recoveryOpen) { + return null + } + return ( -
- {/* Why: this strip sits under native macOS traffic lights; center the - content block and reserve the same pad on both sides so the title is - not clipped and reads as middle-of-window, not left-edge. */} -
-
-
- -
-

+ <> +

{ + // Closing because the restore dialog is on top is temporary. + if (!open && !recoveryOpen) { + handleDismiss() + } + }} + > + { + event.preventDefault() + continueRef.current?.focus() + }} + > + + {translate( 'auto.components.dataRecovery.pinExitTitle', - 'This profile was upgraded for custom agents' + 'Custom agents are now available' )} -

-

+ + {translate( - 'auto.components.dataRecovery.pinExitBody', - 'Orca kept a recovery point so you can return to the previous Orca. Use Data recovery → Prepare downgrade, then install that older version. Do not only reinstall the older app over this profile. Going back discards settings and custom agents saved after the recovery point.' + 'auto.components.dataRecovery.pinExitLead', + 'A custom agent is saved arguments for a harness like Codex or Claude, picked by name.' + )} + + + + +

+ {translate( + 'auto.components.dataRecovery.pinExitExampleHint', + 'Create them in Settings → Agents. Keep working as usual — nothing else is required.' + )} +

+ + + +
+

+ {translate( + 'auto.components.dataRecovery.pinExitRollbackTitle', + 'Before you install an older Orca' )}

+

+ {translate( + 'auto.components.dataRecovery.pinExitRollbackReinstall', + 'This version changed the local data format. Restore the previous backup first, or the older app can break.' + )} +

+
    +
  1. + {translate( + 'auto.components.dataRecovery.pinExitRollbackStepRestore', + 'Restore the data backup. Orca will quit.' + )} +
  2. +
  3. + {translate( + 'auto.components.dataRecovery.pinExitRollbackStepInstall', + 'Then install the older Orca.' + )} +
  4. +
+

+ {translate( + 'auto.components.dataRecovery.pinExitRollbackLoss', + 'The restore discards changes since this update, including custom agents.' + )} +

+
+ +
-
- - -
-
-
-
+ + + -
+ ) } diff --git a/src/renderer/src/components/data-recovery/DataRecoverySettingsRow.tsx b/src/renderer/src/components/data-recovery/DataRecoverySettingsRow.tsx index 72b7783bf6c..3f8a6c795c1 100644 --- a/src/renderer/src/components/data-recovery/DataRecoverySettingsRow.tsx +++ b/src/renderer/src/components/data-recovery/DataRecoverySettingsRow.tsx @@ -34,7 +34,7 @@ export function DataRecoverySettingsRow() { {translate( 'auto.components.dataRecovery.settingsRowHint', - 'A recovery point can return this profile to the previous Orca (Prepare downgrade, then install that version).' + 'You can restore the settings from before this update, then install the previous Orca.' )} + ) +})) + +const { QuickCommandsPane } = await import('./QuickCommandsPane') + +const settings = { terminalQuickCommands: [] } as unknown as GlobalSettings +const alwaysConfirm = async (): Promise => true + +function renderPane(): void { + render( + + + + ) +} + +describe('QuickCommandsPane durable-write failures', () => { + beforeEach(() => { + saveTerminalQuickCommand.mockReset() + deleteTerminalQuickCommand.mockReset() + }) + afterEach(() => { + cleanup() + }) + + it('tells the user nothing was saved when the reference write fails', async () => { + saveTerminalQuickCommand.mockResolvedValue({ + ok: false, + code: 'agent_reference_write_failed' + }) + renderPane() + + fireEvent.click(screen.getByText('stub-save')) + + const notice = await screen.findByRole('alert') + expect(notice.textContent).toContain("Your change wasn't saved") + }) + + it('stays quiet when the quick command reached disk', async () => { + saveTerminalQuickCommand.mockResolvedValue({ ok: true }) + renderPane() + + fireEvent.click(screen.getByText('stub-save')) + await Promise.resolve() + + expect(screen.queryByRole('alert')).toBeNull() + }) +}) diff --git a/src/renderer/src/components/settings/Settings.tsx b/src/renderer/src/components/settings/Settings.tsx index cf38fc91a66..95d9b34edd8 100644 --- a/src/renderer/src/components/settings/Settings.tsx +++ b/src/renderer/src/components/settings/Settings.tsx @@ -369,6 +369,7 @@ import { IntegrationsPane } from './IntegrationsPane' import { TasksPane } from './TasksPane' import { QuickCommandsPane } from './QuickCommandsPane' import { saveSourceControlAiSettings } from '@/lib/agent-catalog-authoring' +import { notifyAgentAuthoringWriteFailure } from '@/lib/agent-authoring-write-failure-toast' import { DeveloperPermissionsPane } from './DeveloperPermissionsPane' import { ComputerUsePane } from './ComputerUsePane' import { MobileSettingsPane } from './MobileSettingsPane' @@ -1127,7 +1128,12 @@ function Settings(): React.JSX.Element { } const latestConfig = readSourceControlAiSettings(latestSettings) const resolvedPatch = typeof patch === 'function' ? patch(latestConfig) : patch - await saveSourceControlAiSettings({ ...latestConfig, ...resolvedPatch }) + // Why: every Source Control AI pane writes through this queue and then + // reports success, so a durable-write rejection has to be surfaced here + // or the edit is silently lost on the next launch. + notifyAgentAuthoringWriteFailure( + await saveSourceControlAiSettings({ ...latestConfig, ...resolvedPatch }) + ) }) sourceControlAiWriteQueueRef.current = next return next diff --git a/src/renderer/src/components/settings/agent-authoring-write-failure.test.ts b/src/renderer/src/components/settings/agent-authoring-write-failure.test.ts new file mode 100644 index 00000000000..8b902129c92 --- /dev/null +++ b/src/renderer/src/components/settings/agent-authoring-write-failure.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' +import { + agentAuthoringWriteFailureMessage, + asAgentAuthoringWriteFailure +} from './agent-authoring-write-failure' + +describe('asAgentAuthoringWriteFailure', () => { + it('narrows a failed catalog write', () => { + expect(asAgentAuthoringWriteFailure({ ok: false, code: 'agent_catalog_write_failed' })).toBe( + 'agent_catalog_write_failed' + ) + }) + + it('narrows a failed reference write', () => { + expect(asAgentAuthoringWriteFailure({ ok: false, code: 'agent_reference_write_failed' })).toBe( + 'agent_reference_write_failed' + ) + }) + + it('ignores successes and other rejections', () => { + expect(asAgentAuthoringWriteFailure({ ok: true })).toBeNull() + expect( + asAgentAuthoringWriteFailure({ ok: false, code: 'catalog_revision_conflict' }) + ).toBeNull() + expect(asAgentAuthoringWriteFailure({ ok: false })).toBeNull() + }) +}) + +describe('agentAuthoringWriteFailureMessage', () => { + it('states that nothing was saved and the user should retry', () => { + const message = agentAuthoringWriteFailureMessage().toLowerCase() + expect(message).toContain('nothing was changed') + expect(message).toContain('try again') + }) +}) diff --git a/src/renderer/src/components/settings/agent-authoring-write-failure.tsx b/src/renderer/src/components/settings/agent-authoring-write-failure.tsx new file mode 100644 index 00000000000..e7e3af574c5 --- /dev/null +++ b/src/renderer/src/components/settings/agent-authoring-write-failure.tsx @@ -0,0 +1,58 @@ +import { AlertTriangle } from 'lucide-react' +import { translate } from '@/i18n/i18n' + +// Why: main now acks catalog/reference authoring only after a durable write, so +// these two codes mean the change never reached disk. Without dedicated copy +// they fall through to the generic "reload and try again" branch, which leaves +// the user unsure whether the edit was applied — the one thing they must know. + +export type AgentAuthoringWriteFailureCode = + | 'agent_catalog_write_failed' + | 'agent_reference_write_failed' + +/** Narrows any catalog/reference mutation result to a durable-write failure, or + * null when it is a success or a different rejection. */ +export function asAgentAuthoringWriteFailure(result: { + ok: boolean + code?: unknown +}): AgentAuthoringWriteFailureCode | null { + if (result.ok) { + return null + } + const { code } = result + if (code === 'agent_catalog_write_failed' || code === 'agent_reference_write_failed') { + return code + } + return null +} + +/** Single source of the "nothing was saved, retry" wording, shared by the + * editor's form banner and the section notice. */ +export function agentAuthoringWriteFailureMessage(): string { + return translate( + 'auto.components.settings.AgentCatalogSection.writeFailedDescription', + "Orca couldn't save this change to disk, so nothing was changed. Check that the disk isn't full or read-only, then try again." + ) +} + +/** Shown after a catalog/reference write is rejected because it could not be + * persisted; stays until the next mutation so the failure is not missed. */ +export function AgentAuthoringWriteFailureNotice(): React.JSX.Element { + return ( +
+

+ + + {translate( + 'auto.components.settings.AgentCatalogSection.writeFailedTitle', + "Your change wasn't saved" + )} + +

+

{agentAuthoringWriteFailureMessage()}

+
+ ) +} diff --git a/src/renderer/src/components/settings/agent-catalog-schema-too-new.test.ts b/src/renderer/src/components/settings/agent-catalog-schema-too-new.test.ts new file mode 100644 index 00000000000..5d8a72614f8 --- /dev/null +++ b/src/renderer/src/components/settings/agent-catalog-schema-too-new.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import { + agentCatalogSchemaTooNewMessage, + agentCatalogSchemaTooNewVersions, + asAgentCatalogSchemaTooNew +} from './agent-catalog-schema-too-new' + +describe('asAgentCatalogSchemaTooNew', () => { + it('narrows the read-only rejection with its versions', () => { + expect( + asAgentCatalogSchemaTooNew({ + ok: false, + code: 'agent_catalog_schema_too_new', + persistedVersion: 2, + supportedVersion: 1 + }) + ).toEqual({ + ok: false, + code: 'agent_catalog_schema_too_new', + persistedVersion: 2, + supportedVersion: 1 + }) + }) + + it('ignores successes and other rejections', () => { + expect(asAgentCatalogSchemaTooNew({ ok: true })).toBeNull() + expect( + asAgentCatalogSchemaTooNew({ ok: false, code: 'agent_catalog_migration_blocked' }) + ).toBeNull() + expect(asAgentCatalogSchemaTooNew({ ok: false })).toBeNull() + }) + + it('tolerates a host that omitted the versions', () => { + expect( + asAgentCatalogSchemaTooNew({ ok: false, code: 'agent_catalog_schema_too_new' }) + ).toMatchObject({ persistedVersion: 0, supportedVersion: 0 }) + }) +}) + +describe('schema-too-new copy', () => { + it('says the profile is from a newer Orca and that retrying will not help', () => { + const message = agentCatalogSchemaTooNewMessage().toLowerCase() + expect(message).toContain('newer version of orca') + expect(message).toContain('retrying will not help') + }) + + it('omits the version line when the host reported no usable versions', () => { + expect(agentCatalogSchemaTooNewVersions({ persistedVersion: 0, supportedVersion: 0 })).toBe('') + expect( + agentCatalogSchemaTooNewVersions({ persistedVersion: 2, supportedVersion: 1 }) + ).toContain('v2') + }) +}) diff --git a/src/renderer/src/components/settings/agent-catalog-schema-too-new.tsx b/src/renderer/src/components/settings/agent-catalog-schema-too-new.tsx new file mode 100644 index 00000000000..9d1ab808770 --- /dev/null +++ b/src/renderer/src/components/settings/agent-catalog-schema-too-new.tsx @@ -0,0 +1,91 @@ +import { AlertTriangle } from 'lucide-react' +import { translate } from '@/i18n/i18n' +import type { AgentCatalogSchemaTooNew } from '../../../../shared/data-recovery' + +// Why: a profile stamped by a newer build is read-only for authoring, and main +// mints this code before any write is attempted. Unlike the migration block it +// is not retryable, so it needs its own narrowing and copy — not the "try again" +// wording. Like the blocked code it is minted in main's write policy, outside +// the shared mutation-result unions, so consumers narrow it structurally. + +export type AgentCatalogSchemaTooNewResult = { + ok: false + code: 'agent_catalog_schema_too_new' + persistedVersion: number + supportedVersion: number +} + +/** Narrows any catalog/reference mutation result to the schema-too-new + * rejection, or null when the result is a success or a different failure. */ +export function asAgentCatalogSchemaTooNew(result: { + ok: boolean + code?: unknown + persistedVersion?: unknown + supportedVersion?: unknown +}): AgentCatalogSchemaTooNewResult | null { + if (result.ok) { + return null + } + const candidate = result + if (candidate.code !== 'agent_catalog_schema_too_new') { + return null + } + return { + ok: false, + code: 'agent_catalog_schema_too_new', + persistedVersion: + typeof candidate.persistedVersion === 'number' ? candidate.persistedVersion : 0, + supportedVersion: + typeof candidate.supportedVersion === 'number' ? candidate.supportedVersion : 0 + } +} + +/** Single source of the read-only wording, shared by the Settings notice and the + * app-level data-recovery banner. */ +export function agentCatalogSchemaTooNewTitle(): string { + return translate( + 'auto.components.settings.AgentCatalogSection.schemaTooNewTitle', + 'Custom agents are read-only in this version of Orca' + ) +} + +export function agentCatalogSchemaTooNewMessage(): string { + return translate( + 'auto.components.settings.AgentCatalogSection.schemaTooNewDescription', + 'This profile was saved by a newer version of Orca. Your data is unchanged and agents keep launching, but custom agents cannot be changed here — update Orca to edit them again. Retrying will not help.' + ) +} + +/** Version detail; empty when the host did not report usable versions. */ +export function agentCatalogSchemaTooNewVersions(state: AgentCatalogSchemaTooNew): string { + if (state.persistedVersion <= 0 || state.supportedVersion <= 0) { + return '' + } + return translate( + 'auto.components.settings.AgentCatalogSection.schemaTooNewVersions', + 'Profile agent catalog v{{persisted}}; this version of Orca supports v{{supported}}.', + { persisted: String(state.persistedVersion), supported: String(state.supportedVersion) } + ) +} + +/** Persistent (no dismiss, no retry) read-only explanation for Settings. */ +export function AgentCatalogSchemaTooNewNotice({ + state +}: { + state: AgentCatalogSchemaTooNew +}): React.JSX.Element { + const versions = agentCatalogSchemaTooNewVersions(state) + return ( +
+

+ + {agentCatalogSchemaTooNewTitle()} +

+

{agentCatalogSchemaTooNewMessage()}

+ {versions ?

{versions}

: null} +
+ ) +} diff --git a/src/renderer/src/components/settings/custom-agent-editor-copy.test.ts b/src/renderer/src/components/settings/custom-agent-editor-copy.test.ts index 3a2c40623aa..87205a6cb60 100644 --- a/src/renderer/src/components/settings/custom-agent-editor-copy.test.ts +++ b/src/renderer/src/components/settings/custom-agent-editor-copy.test.ts @@ -124,6 +124,19 @@ describe('describeMutationFailure', () => { } }) + it('tells the user a failed durable write saved nothing, not just to reload', () => { + const result = { ok: false, code: 'agent_catalog_write_failed', revision: 7 } as Extract< + AgentCatalogMutationResult, + { ok: false } + > + const copy = describeMutationFailure(result, { field: 'label' }) + expect(copy.scope).toBe('form') + if (copy.scope === 'form') { + expect(copy.error.message.toLowerCase()).toContain('nothing was changed') + expect(copy.error.message.toLowerCase()).not.toContain('reload settings') + } + }) + it('treats an oversize payload as a form-level banner', () => { const result = { ok: false, code: 'agent_catalog_payload_too_large', revision: 6 } as Extract< AgentCatalogMutationResult, diff --git a/src/renderer/src/components/settings/custom-agent-editor-copy.ts b/src/renderer/src/components/settings/custom-agent-editor-copy.ts index 94694c70dc1..31f4fa75685 100644 --- a/src/renderer/src/components/settings/custom-agent-editor-copy.ts +++ b/src/renderer/src/components/settings/custom-agent-editor-copy.ts @@ -8,10 +8,14 @@ import type { AgentCatalogMutationResult } from '../../../../shared/agent-catalo import { isBuiltInAgentLabelKey, normalizeAgentLabelKey, - type AgentFieldIssue, - type AgentFieldIssueReason + type AgentFieldIssue } from '../../../../shared/custom-tui-agent-fields' import { TUI_AGENT_DISPLAY_NAMES } from '../../../../shared/tui-agent-display-names' +import { + agentAuthoringWriteFailureMessage, + asAgentAuthoringWriteFailure +} from './agent-authoring-write-failure' +import { agentCatalogSchemaTooNewMessage } from './agent-catalog-schema-too-new' import type { CustomAgentMutationFocus as EditorFocus } from './custom-agent-editor-state' type EditorField = 'label' | 'commandOverride' | 'args' | 'env' @@ -71,15 +75,13 @@ export function agentEditorFieldLabel(field: EditorField): string { // Reason messages are grouped per field so each reads naturally in place; a // shared fallback covers host-only reasons the editor cannot itself produce. -function describeFieldReason(field: EditorField, reason: AgentFieldIssueReason | string): string { +function describeFieldReason(field: EditorField, reason: string): string { const specific = fieldSpecificReason(field, reason) return specific ?? invalidValueFallback() } -function fieldSpecificReason( - field: EditorField, - reason: AgentFieldIssueReason | string -): string | null { +// `string`, not AgentFieldIssueReason: host-only reasons reach here too. +function fieldSpecificReason(field: EditorField, reason: string): string | null { switch (field) { case 'label': return labelReason(reason) @@ -286,6 +288,15 @@ export function describeMutationFailure( } } } + // Distinct from the generic fallback: the draft is intact but unsaved, so the + // banner must say retry rather than let the user assume it persisted. + if (asAgentAuthoringWriteFailure(result)) { + return { scope: 'form', error: { message: agentAuthoringWriteFailureMessage() } } + } + // No retry affordance: only updating Orca clears a too-new profile schema. + if (result.code === 'agent_catalog_schema_too_new') { + return { scope: 'form', error: { message: agentCatalogSchemaTooNewMessage() } } + } if (result.code === 'catalog_revision_conflict') { return { scope: 'form', diff --git a/src/renderer/src/components/settings/custom-agent-editor-state.test.ts b/src/renderer/src/components/settings/custom-agent-editor-state.test.ts index 8425431ed80..f96ff745585 100644 --- a/src/renderer/src/components/settings/custom-agent-editor-state.test.ts +++ b/src/renderer/src/components/settings/custom-agent-editor-state.test.ts @@ -386,4 +386,8 @@ describe('resolveMutationErrorFocus', () => { it('does not blame a field on a revision conflict (the dialog reloads and keeps the draft)', () => { expect(resolveMutationErrorFocus(failure({ code: 'catalog_revision_conflict' }))).toBeNull() }) + + it('does not blame a field when the profile is read-only for a newer schema', () => { + expect(resolveMutationErrorFocus(failure({ code: 'agent_catalog_schema_too_new' }))).toBeNull() + }) }) diff --git a/src/renderer/src/components/settings/custom-agent-editor-state.ts b/src/renderer/src/components/settings/custom-agent-editor-state.ts index 0271d7e35dd..a34eed1a2a2 100644 --- a/src/renderer/src/components/settings/custom-agent-editor-state.ts +++ b/src/renderer/src/components/settings/custom-agent-editor-state.ts @@ -316,10 +316,14 @@ export function resolveMutationErrorFocus( return result.envEntryIndex === undefined ? { field: result.field } : { field: result.field, envEntryIndex: result.envEntryIndex } + // None of these blame a field — including a failed durable write and a + // read-only profile, where the draft stands and simply isn't saved. case 'catalog_revision_conflict': case 'agent_catalog_local_payload_too_large': case 'agent_catalog_payload_too_large': case 'stale_agent_repair_token': + case 'agent_catalog_write_failed': + case 'agent_catalog_schema_too_new': return null } } diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts index 7e194663e28..d83c73b2495 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.test.ts @@ -4,7 +4,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { FolderWorkspace, ProjectGroup } from '../../../../shared/types' const mocks = vi.hoisted(() => ({ - activateAndRevealFolderWorkspace: vi.fn() + activateAndRevealFolderWorkspace: vi.fn(), + assertRuntimeSupportsAgentLaunchIdentity: vi.fn() })) vi.mock('@/lib/worktree-activation', async (importOriginal) => { @@ -12,9 +13,18 @@ vi.mock('@/lib/worktree-activation', async (importOriginal) => { return { ...actual, activateAndRevealFolderWorkspace: mocks.activateAndRevealFolderWorkspace } }) +vi.mock('@/runtime/agent-launch-identity-negotiation', async (importOriginal) => { + const actual = await importOriginal>() + return { + ...actual, + assertRuntimeSupportsAgentLaunchIdentity: mocks.assertRuntimeSupportsAgentLaunchIdentity + } +}) + import { decideInitialAgentTabViewMode } from '@/lib/native-chat-initial-view-mode' import { resolveStartupLaunchDraftText } from '@/lib/worktree-startup-payload' import { useAppStore } from '@/store' +import { AGENT_LAUNCH_IDENTITY_UNSUPPORTED_MESSAGE } from '@/runtime/agent-launch-identity-negotiation' import { submitFolderWorkspaceCreate } from './folder-workspace-composer-submit' function makeProjectGroup(): ProjectGroup { @@ -147,6 +157,55 @@ describe('submitFolderWorkspaceCreate', () => { }) }) + // A pre-identity host strips agentLaunch and opens a blank shell, losing the + // note. Nothing may be created, and the composer must stay open to keep it. + it('refuses before creating anything when the runtime cannot launch agents', async () => { + const createFolderWorkspace = vi.fn(async () => makeFolderWorkspace()) + const onOpenChange = vi.fn() + mocks.assertRuntimeSupportsAgentLaunchIdentity.mockRejectedValueOnce( + new Error(AGENT_LAUNCH_IDENTITY_UNSUPPORTED_MESSAGE) + ) + + await expect( + submitFolderWorkspaceCreate({ + projectGroup: makeProjectGroup(), + name: '', + lastAutoName: '', + linkedWorkItem: null, + note: 'Fix the flaky checkout flow', + quickAgent: 'codex', + autoRenameBranchFromWork: true, + runtimeEnvironmentId: 'env-1', + createFolderWorkspace, + onOpenChange + }) + ).rejects.toThrow(AGENT_LAUNCH_IDENTITY_UNSUPPORTED_MESSAGE) + + expect(createFolderWorkspace).not.toHaveBeenCalled() + expect(mocks.activateAndRevealFolderWorkspace).not.toHaveBeenCalled() + expect(onOpenChange).not.toHaveBeenCalled() + }) + + it('does not probe the runtime for an agent-less folder workspace', async () => { + const createFolderWorkspace = vi.fn(async () => makeFolderWorkspace()) + + await submitFolderWorkspaceCreate({ + projectGroup: makeProjectGroup(), + name: 'Docs', + lastAutoName: '', + linkedWorkItem: null, + note: '', + quickAgent: null, + autoRenameBranchFromWork: false, + runtimeEnvironmentId: 'env-1', + createFolderWorkspace, + onOpenChange: vi.fn() + }) + + expect(mocks.assertRuntimeSupportsAgentLaunchIdentity).not.toHaveBeenCalled() + expect(createFolderWorkspace).toHaveBeenCalled() + }) + it.each([ { label: 'an explicit workspace name', diff --git a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts index e8a1f48c396..0e12ee48a27 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-composer-submit.ts @@ -6,6 +6,7 @@ import { type WorktreeStartupPayload } from '@/lib/worktree-activation' import { isWorkItemLookupText } from '@/lib/work-item-lookup-text' +import { assertRuntimeSupportsAgentLaunchIdentity } from '@/runtime/agent-launch-identity-negotiation' import { useAppStore } from '@/store' import { resolveTuiAgentConfig } from '../../../../shared/custom-tui-agents' import type { AgentLaunchSpawnRequest } from '../../../../shared/agent-launch-spawn-request' @@ -130,6 +131,12 @@ export async function submitFolderWorkspaceCreate({ createFolderWorkspace, onOpenChange }: SubmitFolderWorkspaceCreateParams): Promise { + // Why: this launch carries no client-assembled command, so a pre-identity host + // would strip agentLaunch, open a blank shell and drop the note/linked draft. + // Refuse before creating anything — the composer stays open with the note. + if (quickAgent && runtimeEnvironmentId) { + await assertRuntimeSupportsAgentLaunchIdentity(runtimeEnvironmentId) + } const linkedName = linkedWorkItem ? getLinkedItemDisplayName(linkedWorkItem) : null const nameIsAutoManaged = !name.trim() || name === lastAutoName || isWorkItemLookupText(name) const workspaceName = diff --git a/src/renderer/src/components/tab-bar/TabBarQuickCommandsButton.tsx b/src/renderer/src/components/tab-bar/TabBarQuickCommandsButton.tsx index 93cb3a4ab6d..3f29ec2e782 100644 --- a/src/renderer/src/components/tab-bar/TabBarQuickCommandsButton.tsx +++ b/src/renderer/src/components/tab-bar/TabBarQuickCommandsButton.tsx @@ -14,6 +14,7 @@ import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import { runQuickCommandInNewTab } from '@/lib/run-quick-command-in-new-tab' import { deleteTerminalQuickCommand, saveTerminalQuickCommand } from '@/lib/agent-catalog-authoring' +import { notifyAgentAuthoringWriteFailure } from '@/lib/agent-authoring-write-failure-toast' import type { TerminalQuickCommand } from '../../../../shared/types' import { useConfirmationDialog } from '@/components/confirmation-dialog-context' import { translate } from '@/i18n/i18n' @@ -127,7 +128,7 @@ export function TabBarQuickCommandsButton({ void useAppStore.getState().upsertTerminalQuickCommand(editor.hostId, next) return } - void saveTerminalQuickCommand(next) + void saveTerminalQuickCommand(next).then(notifyAgentAuthoringWriteFailure) } const handleDeleteCommand = async (entry: HostedTerminalQuickCommand): Promise => { @@ -155,7 +156,7 @@ export function TabBarQuickCommandsButton({ void useAppStore.getState().deleteTerminalQuickCommand(entry.hostId, command.id) return } - void deleteTerminalQuickCommand(command.id) + void deleteTerminalQuickCommand(command.id).then(notifyAgentAuthoringWriteFailure) } const handleRun = (entry: HostedTerminalQuickCommand): void => { diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index 6702c1d128f..aa61c7e6f63 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -396,6 +396,7 @@ import { TerminalQuickCommandDialog } from '@/components/terminal-quick-commands/TerminalQuickCommandDialog' import { saveTerminalQuickCommand } from '@/lib/agent-catalog-authoring' +import { notifyAgentAuthoringWriteFailure } from '@/lib/agent-authoring-write-failure-toast' import { keybindingMatchesAction } from '../../../../shared/keybindings' import { pasteTerminalClipboard } from './terminal-clipboard-paste' import { @@ -3977,7 +3978,7 @@ function TerminalPane( void useAppStore.getState().upsertTerminalQuickCommand(quickCommandEditorHostId, command) return } - void saveTerminalQuickCommand(command) + void saveTerminalQuickCommand(command).then(notifyAgentAuthoringWriteFailure) }, [quickCommandEditorHostId] ) diff --git a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.test.tsx b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.test.tsx index 820d14d4341..a99e2d6baee 100644 --- a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.test.tsx +++ b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.test.tsx @@ -4,17 +4,21 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { TerminalQuickCommand } from '../../../../shared/terminal-quick-command-types' +import { beforeEach } from 'vitest' import { TerminalQuickCommandDialog } from './TerminalQuickCommandDialog' +const useLocalAgentCatalogMock = vi.fn((_options?: { enabled?: boolean }) => ({ + snapshot: null, + loading: false, + unavailable: false, + refetch: vi.fn(), + applySnapshot: vi.fn() +})) + // The dialog reads the local agent-catalog snapshot (custom agents) and disabled // agents; a null snapshot keeps these tests on the built-in picker behavior. vi.mock('@/hooks/useLocalAgentCatalog', () => ({ - useLocalAgentCatalog: () => ({ - snapshot: null, - loading: false, - refetch: vi.fn(), - applySnapshot: vi.fn() - }) + useLocalAgentCatalog: (options?: { enabled?: boolean }) => useLocalAgentCatalogMock(options) })) vi.mock('@/store', () => ({ @@ -26,7 +30,7 @@ const mountedRoots: Root[] = [] async function renderDialog( command: TerminalQuickCommand, - props: { defaultAdvancedOpen?: boolean } = {} + props: { defaultAdvancedOpen?: boolean; open?: boolean } = {} ): Promise { const container = document.createElement('div') document.body.appendChild(container) @@ -36,7 +40,7 @@ async function renderDialog( await act(async () => { root.render( { expect(document.body.textContent).not.toMatch(/Advanced\s*·\s*Global/) }) }) + +describe('TerminalQuickCommandDialog local catalog subscription', () => { + beforeEach(() => { + useLocalAgentCatalogMock.mockClear() + }) + + afterEach(async () => { + await act(async () => { + for (const root of mountedRoots.splice(0)) { + root.unmount() + } + }) + document.body.innerHTML = '' + }) + + const agentCommand: TerminalQuickCommand = { + id: 'qc-agent', + label: 'Ask Claude', + action: 'agent-prompt', + agent: 'claude', + prompt: 'hello', + scope: { type: 'global' } + } + + // This dialog is mounted once per tab bar and stays mounted while closed. + it('opts out of the catalog while closed', async () => { + await renderDialog(agentCommand, false) + + expect(useLocalAgentCatalogMock).toHaveBeenCalled() + for (const call of useLocalAgentCatalogMock.mock.calls) { + expect(call[0]).toEqual({ enabled: false }) + } + }) + + it('opts in once open', async () => { + await renderDialog(agentCommand, true) + + expect(useLocalAgentCatalogMock).toHaveBeenCalledWith({ enabled: true }) + }) +}) diff --git a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.tsx b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.tsx index 31a464b7221..05b6e6ae861 100644 --- a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.tsx +++ b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.tsx @@ -114,7 +114,8 @@ export function TerminalQuickCommandDialog({ // picker needs its own read to offer them alongside built-ins. // Selected raw (no `?? []`): a fresh fallback array would re-render on every store write. const disabledTuiAgents = useAppStore((s) => s.settings?.disabledTuiAgents) - const { snapshot: localAgentCatalog } = useLocalAgentCatalog() + // Why: this dialog stays mounted per tab bar while closed; gate the catalog read on `open`. + const { snapshot: localAgentCatalog } = useLocalAgentCatalog({ enabled: open }) const agentOptions = useMemo( () => buildTerminalQuickCommandAgentOptions(selectedAgent, disabledTuiAgents, localAgentCatalog), diff --git a/src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge.ts index 3db123b5caf..cd8dc0d4612 100644 --- a/src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/runtime-client-ipc-bridge.ts @@ -2,6 +2,7 @@ import { applyHostWorktreeTerminalSleepState } from '@/components/terminal-pane/ import { dispatchTerminalSideEffectBatch } from '@/components/terminal-pane/terminal-side-effect-facts-handler' import { emitAutomationsChangedWindowEvent } from '@/lib/automations-changed-window-event' import { applyNativeChatLaunchDraftResolved } from '@/runtime/native-chat-launch-draft-runtime-resolution' +import { emitAgentCatalogRevision } from '@/runtime/agent-catalog-revision-event' import { getRuntimeEnvironmentRevision } from '@/runtime/runtime-environment-revision' import { applyRuntimeEnvironmentSshStateChanged, @@ -141,7 +142,11 @@ export function registerRuntimeClientIpcBridge( }) return } - if (event.type === 'agentCatalogChanged' || event.type === 'agentReferencesChanged') { + if (event.type === 'agentCatalogChanged') { + emitAgentCatalogRevision(event.revision) + return + } + if (event.type === 'agentReferencesChanged') { return } void ensureRuntimeEventRepoKnown(environmentId, event.repoId) diff --git a/src/renderer/src/hooks/local-agent-catalog-store.ts b/src/renderer/src/hooks/local-agent-catalog-store.ts new file mode 100644 index 00000000000..e8bf0a25f6e --- /dev/null +++ b/src/renderer/src/hooks/local-agent-catalog-store.ts @@ -0,0 +1,107 @@ +// One process-wide local agent-catalog cache behind `useLocalAgentCatalog`. +// Every mounted consumer (each split group's tab bar, the composer, quick-command +// and automation dialogs, the settings pane) used to own a full `getLocal` IPC +// round-trip, a private snapshot copy, and its own settings subscription — at a +// 2,500-agent catalog that is megabytes of structured clone per surface. They now +// share this store: one fetch, one cached snapshot, one settings listener. + +import type { GlobalSettings } from '../../../shared/types' +import type { LocalAgentCatalogSnapshot } from '../../../shared/agent-catalog-snapshot' + +export type LocalAgentCatalogState = { + /** Null while the first `getLocal` is in flight. */ + snapshot: LocalAgentCatalogSnapshot | null + /** True when the local catalog surface does not exist here (paired web). */ + unavailable: boolean +} + +/** Also the disabled-consumer state, so an opted-out caller never allocates. */ +export const IDLE_LOCAL_AGENT_CATALOG_STATE: LocalAgentCatalogState = { + snapshot: null, + unavailable: false +} + +let state: LocalAgentCatalogState = IDLE_LOCAL_AGENT_CATALOG_STATE +let requestToken = 0 +let unsubscribeSettings: (() => void) | null = null +const listeners = new Set<() => void>() + +function publish(next: LocalAgentCatalogState): void { + state = next + for (const listener of listeners) { + listener() + } +} + +/** Re-read the local snapshot from the host (desktop preload IPC only). */ +export function loadLocalAgentCatalog(): void { + const getLocal = window.api?.settings?.agentCatalog?.getLocal + if (typeof getLocal !== 'function') { + // Host without the catalog preload surface: report unavailable rather than + // throwing out of the subscribing effect and taking the surface down with it. + publish({ snapshot: state.snapshot, unavailable: true }) + return + } + const token = (requestToken += 1) + void window.api.settings.agentCatalog + .getLocal() + .then((next) => { + if (token === requestToken) { + publish({ snapshot: next, unavailable: false }) + } + }) + .catch(() => { + // Paired web rejects (not_available_on_paired_web): surface an honest + // read-only empty state rather than a perpetual loading spinner. + if (token === requestToken) { + publish({ snapshot: state.snapshot, unavailable: true }) + } + }) +} + +/** Adopt the authoritative snapshot a local mutation just returned, without a + * refetch round-trip. Supersedes any in-flight `getLocal`. */ +export function applyLocalAgentCatalogSnapshot(next: LocalAgentCatalogSnapshot): void { + requestToken += 1 + publish({ snapshot: next, unavailable: state.unavailable }) +} + +function handleSettingsChanged(updates: Partial): void { + // Custom-agent mutations patch customTuiAgents/deletedCustomTuiAgents (and + // bump agentCatalogRevision); without these keys, always-mounted launch + // surfaces keep a stale snapshot after authoring in another component. + if ( + 'defaultTuiAgent' in updates || + 'disabledTuiAgents' in updates || + 'customTuiAgents' in updates || + 'deletedCustomTuiAgents' in updates || + 'agentCatalogRevision' in updates + ) { + loadLocalAgentCatalog() + } +} + +export function subscribeToLocalAgentCatalog(listener: () => void): () => void { + listeners.add(listener) + if (listeners.size === 1) { + unsubscribeSettings = window.api.settings.onChanged(handleSettingsChanged) + loadLocalAgentCatalog() + } + return () => { + listeners.delete(listener) + if (listeners.size > 0) { + return + } + unsubscribeSettings?.() + unsubscribeSettings = null + // With no listener there is no change feed, so a retained snapshot could go + // silently stale; drop it (and orphan any in-flight load) so the next mount + // reads fresh rather than painting a catalog edited while nothing watched. + requestToken += 1 + state = IDLE_LOCAL_AGENT_CATALOG_STATE + } +} + +export function getLocalAgentCatalogState(): LocalAgentCatalogState { + return state +} diff --git a/src/renderer/src/hooks/useAutomationDispatchEvents.session-lifecycle.test.ts b/src/renderer/src/hooks/useAutomationDispatchEvents.session-lifecycle.test.ts new file mode 100644 index 00000000000..2ab8e3be6a2 --- /dev/null +++ b/src/renderer/src/hooks/useAutomationDispatchEvents.session-lifecycle.test.ts @@ -0,0 +1,489 @@ +// Session-lifecycle half of the dispatch suite: completion detection, terminal +// ownership, and retired-terminal cleanup. Workspace resolution and setup launch +// live in useAutomationDispatchEvents.test.ts. +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + automationRunOutputSnapshotMock, + getLatestStoreSubscriber, + makeAutomation, + mockFinalizeTerminalOwnership, + mockFindReusableAutomationSession, + mockLaunchAgentBackgroundSession, + mockLaunchWorktreeBackgroundTerminals, + mockMarkDispatchResult, + mockObserveExistingAutomationSession, + mockReleaseTerminalOwnership, + mockStoreSubscribe, + mockSubmitPromptToAgentPty, + registerAndDispatch, + resetAutomationDispatchHarness, + state +} from './automation-dispatch-events-test-harness' + +vi.mock('@/lib/launch-agent-background-session', () => ({ + launchAgentBackgroundSession: mockLaunchAgentBackgroundSession +})) + +vi.mock('@/lib/launch-worktree-background-terminals', () => ({ + launchWorktreeBackgroundTerminals: mockLaunchWorktreeBackgroundTerminals +})) + +vi.mock('@/lib/agent-paste-draft', () => ({ + submitPromptToAgentPty: mockSubmitPromptToAgentPty +})) + +vi.mock('@/lib/automation-session-reuse', () => ({ + findReusableAutomationSession: mockFindReusableAutomationSession +})) + +vi.mock('@/lib/automation-session-observer', () => ({ + observeExistingAutomationSession: mockObserveExistingAutomationSession +})) + +vi.mock( + '@/components/automations/automation-run-output-snapshot', + () => automationRunOutputSnapshotMock +) + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +vi.mock('@/lib/browser-uuid', () => ({ + createBrowserUuid: () => 'create-request-id' +})) + +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => state, + subscribe: mockStoreSubscribe + } +})) + +const AGENT_PANE_KEY = 'agent-tab:7c6fb4e5-3bf1-4ff4-8259-03f7ae81c40d' + +function requireStoreSubscriber(): () => void { + const subscriber = getLatestStoreSubscriber() + if (!subscriber) { + throw new Error('agent status observer was not registered') + } + return subscriber +} + +describe('useAutomationDispatchEvents session lifecycle', () => { + beforeEach(() => { + resetAutomationDispatchHarness() + }) + + it('finalizes a fresh non-reuse terminal only after completed result persistence', async () => { + const order: string[] = [] + let launchArgs: { onAgentStatus?: (payload: { state: string }) => void } = {} + mockMarkDispatchResult.mockImplementation( + async (result: { status: string; terminalPaneKey?: string | null }) => { + // The retirement clear reuses status 'completed' but nulls the terminal + // identity; label it distinctly so ordering stays legible. + order.push( + result.status === 'completed' && result.terminalPaneKey === null + ? 'clear-terminal-identity' + : `persist:${result.status}` + ) + } + ) + mockFinalizeTerminalOwnership.mockImplementation(() => { + order.push('finalize') + return true + }) + mockLaunchAgentBackgroundSession.mockImplementation(async (args) => { + launchArgs = args + return { + tabId: 'agent-tab', + paneKey: AGENT_PANE_KEY, + ptyId: 'agent-pty', + startupPlan: {}, + terminalOwnership: { + finalize: mockFinalizeTerminalOwnership, + release: mockReleaseTerminalOwnership + } + } + }) + + await registerAndDispatch() + launchArgs.onAgentStatus?.({ state: 'done' }) + await vi.waitFor(() => expect(mockFinalizeTerminalOwnership).toHaveBeenCalledOnce()) + + expect(order).toEqual([ + 'persist:dispatched', + 'persist:completed', + 'finalize', + 'clear-terminal-identity' + ]) + expect(mockReleaseTerminalOwnership).not.toHaveBeenCalled() + // Why: the retired terminal is gone; the run must drop its pane/pty pointers + // so "View run" resolves to the workspace/snapshot, not an unavailable terminal. + expect(mockMarkDispatchResult).toHaveBeenLastCalledWith({ + runId: expect.any(String), + status: 'completed', + terminalSessionId: null, + terminalPaneKey: null, + terminalPtyId: null + }) + }) + + // Why: the host applies this second update to an ALREADY-final run, so the + // cleanup must carry every pointer as an explicit null — an omitted key is + // preserved by the store and would leave "View run" on a dead terminal. + it('clears every terminal pointer explicitly after the terminal is retired', async () => { + mockFinalizeTerminalOwnership.mockReturnValue(true) + let launchArgs: { onAgentStatus?: (payload: { state: string }) => void } = {} + mockLaunchAgentBackgroundSession.mockImplementation(async (args) => { + launchArgs = args + return { + tabId: 'agent-tab', + paneKey: AGENT_PANE_KEY, + ptyId: 'agent-pty', + startupPlan: {}, + terminalOwnership: { + finalize: mockFinalizeTerminalOwnership, + release: mockReleaseTerminalOwnership + } + } + }) + + await registerAndDispatch() + launchArgs.onAgentStatus?.({ state: 'done' }) + await vi.waitFor(() => expect(mockFinalizeTerminalOwnership).toHaveBeenCalledOnce()) + + const cleanup = mockMarkDispatchResult.mock.calls.at(-1)?.[0] + for (const key of ['terminalSessionId', 'terminalPaneKey', 'terminalPtyId'] as const) { + expect(Object.hasOwn(cleanup, key)).toBe(true) + expect(cleanup[key]).toBeNull() + } + }) + + it('logs once when the retired-terminal cleanup cannot be persisted', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + const cleanupError = new Error('cleanup persistence unavailable') + mockFinalizeTerminalOwnership.mockReturnValue(true) + mockMarkDispatchResult.mockImplementation( + async (result: { terminalPaneKey?: string | null }) => { + if (result.terminalPaneKey === null) { + throw cleanupError + } + } + ) + let launchArgs: { onAgentStatus?: (payload: { state: string }) => void } = {} + mockLaunchAgentBackgroundSession.mockImplementation(async (args) => { + launchArgs = args + return { + tabId: 'agent-tab', + paneKey: AGENT_PANE_KEY, + ptyId: 'agent-pty', + startupPlan: {}, + terminalOwnership: { + finalize: mockFinalizeTerminalOwnership, + release: mockReleaseTerminalOwnership + } + } + }) + + await registerAndDispatch() + launchArgs.onAgentStatus?.({ state: 'done' }) + await vi.waitFor(() => expect(errorSpy).toHaveBeenCalledOnce()) + + expect(errorSpy).toHaveBeenCalledWith( + '[automations] Failed to clear retired terminal identity:', + cleanupError + ) + errorSpy.mockRestore() + }) + + it('ignores a session-boundary done so a connecting agent cannot complete the run (STA-3386)', async () => { + let launchArgs: { + onAgentStatus?: (payload: { state: string; sessionBoundary?: boolean }) => void + } = {} + mockLaunchAgentBackgroundSession.mockImplementation(async (args) => { + launchArgs = args + return { + tabId: 'agent-tab', + paneKey: AGENT_PANE_KEY, + ptyId: 'agent-pty', + startupPlan: {}, + terminalOwnership: { + finalize: mockFinalizeTerminalOwnership, + release: mockReleaseTerminalOwnership + } + } + }) + + await registerAndDispatch() + // Why: Claude fires SessionStart (a sessionBoundary done) at launch, before the argv + // prompt submits — treating it as run completion would close the tab on an empty run. + launchArgs.onAgentStatus?.({ state: 'done', sessionBoundary: true }) + await Promise.resolve() + expect(mockFinalizeTerminalOwnership).not.toHaveBeenCalled() + + launchArgs.onAgentStatus?.({ state: 'done' }) + await vi.waitFor(() => expect(mockFinalizeTerminalOwnership).toHaveBeenCalledOnce()) + }) + + it('persists assistant output from a batched working→done→working transition', async () => { + await registerAndDispatch() + const transitionStartedAt = Date.now() + 1 + state.agentStatusByPaneKey = { + [AGENT_PANE_KEY]: { + paneKey: AGENT_PANE_KEY, + state: 'working', + prompt: 'second turn', + agentType: 'claude', + updatedAt: transitionStartedAt + 2, + stateStartedAt: transitionStartedAt + 2, + lastCompletedAssistantMessage: 'Summary.\n\nDetails.', + stateHistory: [ + { state: 'working', prompt: 'first turn', startedAt: transitionStartedAt }, + { state: 'done', prompt: 'first turn', startedAt: transitionStartedAt + 1 } + ] + } + } + requireStoreSubscriber()() + + await vi.waitFor(() => expect(mockFinalizeTerminalOwnership).toHaveBeenCalledOnce()) + expect(mockMarkDispatchResult).toHaveBeenCalledWith( + expect.objectContaining({ + runId: 'run-1', + status: 'completed', + outputSnapshot: { + format: 'plain_text', + content: 'Summary.\n\nDetails.', + capturedAt: 1, + truncated: false + } + }) + ) + }) + + it('does not let later working authorize an earlier historical done on rescan', async () => { + mockFindReusableAutomationSession.mockReturnValue({ + tabId: 'agent-tab', + paneKey: AGENT_PANE_KEY, + ptyId: 'agent-pty' + }) + mockObserveExistingAutomationSession.mockResolvedValue(() => {}) + + await registerAndDispatch(makeAutomation({ reuseSession: true })) + const transitionStartedAt = Date.now() + 1 + state.agentStatusByPaneKey = { + [AGENT_PANE_KEY]: { + paneKey: AGENT_PANE_KEY, + state: 'working', + prompt: 'new turn', + agentType: 'claude', + updatedAt: transitionStartedAt + 1, + stateStartedAt: transitionStartedAt + 1, + stateHistory: [{ state: 'done', prompt: 'old turn', startedAt: transitionStartedAt }] + } + } + const notifyStoreSubscriber = requireStoreSubscriber() + + notifyStoreSubscriber() + notifyStoreSubscriber() + await Promise.resolve() + + expect(mockMarkDispatchResult).not.toHaveBeenCalledWith( + expect.objectContaining({ status: 'completed' }) + ) + }) + + // Why: transport loss, PTY exit and cap eviction all drop and recreate the live + // entry with an empty stateHistory. The working edge is only in the observer's + // own bookkeeping by then, so it must survive a zero-overlap rescan. + it('completes a reuse-session run when the entry is recreated with no history', async () => { + mockFindReusableAutomationSession.mockReturnValue({ + tabId: 'agent-tab', + paneKey: AGENT_PANE_KEY, + ptyId: 'agent-pty' + }) + mockObserveExistingAutomationSession.mockResolvedValue(() => {}) + + await registerAndDispatch(makeAutomation({ reuseSession: true })) + const workingStartedAt = Date.now() + 1 + state.agentStatusByPaneKey = { + [AGENT_PANE_KEY]: { + paneKey: AGENT_PANE_KEY, + state: 'working', + prompt: 'turn', + agentType: 'claude', + updatedAt: workingStartedAt, + stateStartedAt: workingStartedAt, + stateHistory: [{ state: 'working', prompt: 'turn', startedAt: workingStartedAt }] + } + } + const notifyStoreSubscriber = requireStoreSubscriber() + notifyStoreSubscriber() + await Promise.resolve() + + // The entry is dropped and recreated: same pane, now done, history gone. + state.agentStatusByPaneKey = { + [AGENT_PANE_KEY]: { + paneKey: AGENT_PANE_KEY, + state: 'done', + prompt: 'turn', + agentType: 'claude', + updatedAt: workingStartedAt + 2, + stateStartedAt: workingStartedAt + 2, + stateHistory: [] + } + } + notifyStoreSubscriber() + await Promise.resolve() + + expect(mockMarkDispatchResult).toHaveBeenCalledWith( + expect.objectContaining({ status: 'completed' }) + ) + }) + + it('consumes duplicate done and zero-exit completion through one finalizer', async () => { + let launchArgs: { + onAgentStatus?: (payload: { state: string }) => void + onExit?: (ptyId: string, code: number) => void + } = {} + mockLaunchAgentBackgroundSession.mockImplementation(async (args) => { + launchArgs = args + return { + tabId: 'agent-tab', + paneKey: AGENT_PANE_KEY, + ptyId: 'agent-pty', + startupPlan: {}, + terminalOwnership: { + finalize: mockFinalizeTerminalOwnership, + release: mockReleaseTerminalOwnership + } + } + }) + + await registerAndDispatch() + launchArgs.onAgentStatus?.({ state: 'done' }) + launchArgs.onExit?.('agent-pty', 0) + launchArgs.onAgentStatus?.({ state: 'done' }) + await vi.waitFor(() => expect(mockFinalizeTerminalOwnership).toHaveBeenCalledOnce()) + + expect( + mockMarkDispatchResult.mock.calls.filter( + ([result]) => result.status === 'completed' && result.terminalPaneKey !== null + ) + ).toHaveLength(1) + expect(mockReleaseTerminalOwnership).not.toHaveBeenCalled() + }) + + it('releases ownership on nonzero exit without finalizing the tab', async () => { + let onExit: ((ptyId: string, code: number) => void) | undefined + mockLaunchAgentBackgroundSession.mockImplementation(async (args) => { + onExit = args.onExit + return { + tabId: 'agent-tab', + paneKey: AGENT_PANE_KEY, + ptyId: 'agent-pty', + startupPlan: {}, + terminalOwnership: { + finalize: mockFinalizeTerminalOwnership, + release: mockReleaseTerminalOwnership + } + } + }) + + await registerAndDispatch() + onExit?.('agent-pty', 9) + await vi.waitFor(() => expect(mockReleaseTerminalOwnership).toHaveBeenCalledOnce()) + + expect(mockFinalizeTerminalOwnership).not.toHaveBeenCalled() + expect(mockMarkDispatchResult).toHaveBeenCalledWith( + expect.objectContaining({ status: 'dispatch_failed' }) + ) + }) + + it('releases ownership when dispatched result persistence rejects', async () => { + mockMarkDispatchResult.mockRejectedValueOnce(new Error('persistence unavailable')) + + await registerAndDispatch() + + expect(mockReleaseTerminalOwnership).toHaveBeenCalledOnce() + expect(mockFinalizeTerminalOwnership).not.toHaveBeenCalled() + expect(mockMarkDispatchResult).toHaveBeenLastCalledWith( + expect.objectContaining({ status: 'dispatch_failed' }) + ) + }) + + it('releases ownership when completed result persistence rejects', async () => { + mockMarkDispatchResult + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error('completion persistence unavailable')) + .mockResolvedValueOnce(undefined) + mockLaunchAgentBackgroundSession.mockImplementation(async (args) => { + args.onAgentStatus?.({ state: 'done' }) + return { + tabId: 'agent-tab', + paneKey: AGENT_PANE_KEY, + ptyId: 'agent-pty', + startupPlan: {}, + terminalOwnership: { + finalize: mockFinalizeTerminalOwnership, + release: mockReleaseTerminalOwnership + } + } + }) + + await registerAndDispatch() + + expect(mockReleaseTerminalOwnership).toHaveBeenCalledOnce() + expect(mockFinalizeTerminalOwnership).not.toHaveBeenCalled() + expect(mockMarkDispatchResult).toHaveBeenLastCalledWith( + expect.objectContaining({ status: 'dispatch_failed' }) + ) + }) + + it('diagnoses a late completed-persistence rejection once without terminal cleanup', async () => { + let onAgentStatus: ((payload: { state: string }) => void) | undefined + const persistenceError = new Error('late completion persistence unavailable') + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + mockMarkDispatchResult.mockResolvedValueOnce(undefined).mockRejectedValueOnce(persistenceError) + mockLaunchAgentBackgroundSession.mockImplementation(async (args) => { + onAgentStatus = args.onAgentStatus + return { + tabId: 'agent-tab', + paneKey: AGENT_PANE_KEY, + ptyId: 'agent-pty', + startupPlan: {}, + terminalOwnership: { + finalize: mockFinalizeTerminalOwnership, + release: mockReleaseTerminalOwnership + } + } + }) + + await registerAndDispatch() + onAgentStatus?.({ state: 'done' }) + onAgentStatus?.({ state: 'done' }) + await vi.waitFor(() => expect(errorSpy).toHaveBeenCalledOnce()) + + expect(errorSpy).toHaveBeenCalledWith( + '[automations] Failed to persist late automation result:', + persistenceError + ) + expect(mockReleaseTerminalOwnership).toHaveBeenCalledOnce() + expect(mockFinalizeTerminalOwnership).not.toHaveBeenCalled() + expect( + mockMarkDispatchResult.mock.calls.filter( + ([result]) => result.status === 'completed' && result.terminalPaneKey !== null + ) + ).toHaveLength(1) + errorSpy.mockRestore() + }) + + it('preserves a fresh reuse-enabled session as the future reuse seed', async () => { + mockFindReusableAutomationSession.mockReturnValue(null) + + await registerAndDispatch(makeAutomation({ reuseSession: true })) + + expect(mockReleaseTerminalOwnership).toHaveBeenCalledOnce() + expect(mockFinalizeTerminalOwnership).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/hooks/useLocalAgentCatalog.test.ts b/src/renderer/src/hooks/useLocalAgentCatalog.test.ts index 89264167503..b7b20468815 100644 --- a/src/renderer/src/hooks/useLocalAgentCatalog.test.ts +++ b/src/renderer/src/hooks/useLocalAgentCatalog.test.ts @@ -1,7 +1,7 @@ // @vitest-environment happy-dom import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { act, renderHook, waitFor } from '@testing-library/react' +import { act, cleanup, renderHook, waitFor } from '@testing-library/react' import type { LocalAgentCatalogSnapshot } from '../../../shared/agent-catalog-snapshot' import { useLocalAgentCatalog } from './useLocalAgentCatalog' @@ -21,17 +21,21 @@ function snapshot(revision: number): LocalAgentCatalogSnapshot { const getLocal = vi.fn<() => Promise>() let settingsChangedCallback: ((updates: Record) => void) | null = null +let settingsSubscriptions = 0 beforeEach(() => { getLocal.mockReset() getLocal.mockResolvedValue(snapshot(1)) settingsChangedCallback = null + settingsSubscriptions = 0 ;(window as unknown as { api: unknown }).api = { settings: { agentCatalog: { getLocal }, onChanged: (cb: (updates: Record) => void) => { + settingsSubscriptions += 1 settingsChangedCallback = cb return () => { + settingsSubscriptions -= 1 settingsChangedCallback = null } } @@ -40,6 +44,9 @@ beforeEach(() => { }) afterEach(() => { + // The catalog cache is process-wide; unmount every consumer so the next test + // starts from an empty store rather than the previous test's snapshot. + cleanup() vi.restoreAllMocks() }) @@ -125,3 +132,74 @@ describe('useLocalAgentCatalog', () => { await waitFor(() => expect(result.current.snapshot?.revision).toBe(9)) }) }) + +// P1-22: a workspace with many split groups mounts this hook a dozen-plus times. +// Each consumer used to own a full `getLocal` round-trip (megabytes of structured +// clone at a large catalog) plus its own settings listener. +describe('useLocalAgentCatalog shared store (P1-22)', () => { + it('serves many concurrent consumers from one fetch and one settings listener', async () => { + const { result } = renderHook(() => ({ + a: useLocalAgentCatalog(), + b: useLocalAgentCatalog(), + c: useLocalAgentCatalog() + })) + await waitFor(() => expect(result.current.a.snapshot?.revision).toBe(1)) + + expect(getLocal).toHaveBeenCalledTimes(1) + expect(settingsSubscriptions).toBe(1) + // One cached snapshot, not one copy per consumer. + expect(result.current.b.snapshot).toBe(result.current.a.snapshot) + expect(result.current.c.snapshot).toBe(result.current.a.snapshot) + }) + + it('shares one refetch across separately mounted consumers', async () => { + getLocal.mockResolvedValueOnce(snapshot(1)).mockResolvedValueOnce(snapshot(2)) + const first = renderHook(() => useLocalAgentCatalog()) + const second = renderHook(() => useLocalAgentCatalog()) + await waitFor(() => expect(second.result.current.snapshot?.revision).toBe(1)) + expect(getLocal).toHaveBeenCalledTimes(1) + + act(() => settingsChangedCallback?.({ agentCatalogRevision: 2 })) + await waitFor(() => expect(first.result.current.snapshot?.revision).toBe(2)) + + // One reload feeds both surfaces. + expect(getLocal).toHaveBeenCalledTimes(2) + expect(second.result.current.snapshot).toBe(first.result.current.snapshot) + }) + + it('keeps a disabled consumer (a closed dialog) off the store entirely', async () => { + const { result } = renderHook(() => useLocalAgentCatalog({ enabled: false })) + await waitFor(() => expect(result.current.loading).toBe(false)) + + expect(getLocal).not.toHaveBeenCalled() + expect(settingsSubscriptions).toBe(0) + expect(result.current.snapshot).toBeNull() + expect(result.current.unavailable).toBe(false) + }) + + it('reports unavailable instead of throwing when the host has no catalog surface', async () => { + ;(window as unknown as { api: unknown }).api = { + settings: { onChanged: () => () => {} } + } + const { result } = renderHook(() => useLocalAgentCatalog()) + await waitFor(() => expect(result.current.unavailable).toBe(true)) + expect(result.current.loading).toBe(false) + }) + + it('loads once when a disabled consumer is enabled alongside an active one', async () => { + const { result, rerender } = renderHook( + ({ open }: { open: boolean }) => ({ + always: useLocalAgentCatalog(), + dialog: useLocalAgentCatalog({ enabled: open }) + }), + { initialProps: { open: false } } + ) + await waitFor(() => expect(result.current.always.snapshot?.revision).toBe(1)) + expect(result.current.dialog.snapshot).toBeNull() + + rerender({ open: true }) + // The dialog reads the already-cached snapshot; opening costs no extra fetch. + expect(getLocal).toHaveBeenCalledTimes(1) + expect(result.current.dialog.snapshot).toBe(result.current.always.snapshot) + }) +}) diff --git a/src/renderer/src/hooks/useLocalAgentCatalog.ts b/src/renderer/src/hooks/useLocalAgentCatalog.ts index 82798fcaba5..b2fc0765d1e 100644 --- a/src/renderer/src/hooks/useLocalAgentCatalog.ts +++ b/src/renderer/src/hooks/useLocalAgentCatalog.ts @@ -1,5 +1,13 @@ -import { useCallback, useEffect, useRef, useState } from 'react' +import { useEffect, useState } from 'react' import type { LocalAgentCatalogSnapshot } from '../../../shared/agent-catalog-snapshot' +import { + applyLocalAgentCatalogSnapshot, + getLocalAgentCatalogState, + IDLE_LOCAL_AGENT_CATALOG_STATE, + loadLocalAgentCatalog, + subscribeToLocalAgentCatalog, + type LocalAgentCatalogState +} from './local-agent-catalog-store' export type UseLocalAgentCatalog = { /** Null while the first `getLocal` is in flight. */ @@ -15,6 +23,12 @@ export type UseLocalAgentCatalog = { applySnapshot: (snapshot: LocalAgentCatalogSnapshot) => void } +export type UseLocalAgentCatalogOptions = { + /** False keeps a mounted-but-inert surface (a closed dialog) off the store: + * no fetch, no subscription, no snapshot. Defaults to true. */ + enabled?: boolean +} + /** * Live local agent-catalog snapshot for the Settings catalog UI (custom agents, * repair rows, projection/storage status). The catalog is desktop-local preload @@ -24,66 +38,33 @@ export type UseLocalAgentCatalog = { * pane cannot derive them from the settings store. Default/disabled selection can * still change out-of-band (another window, menu), so we refetch on those narrow * settings slices to keep rows and the default picker consistent. + * + * Every consumer reads one shared fetch/cache/subscription (see + * `local-agent-catalog-store`): a workspace with many split tab bars pays for a + * single `getLocal` round-trip and one snapshot copy, not one per surface. */ -export function useLocalAgentCatalog(): UseLocalAgentCatalog { - const [snapshot, setSnapshot] = useState(null) - const [unavailable, setUnavailable] = useState(false) - const requestTokenRef = useRef(0) - const mountedRef = useRef(true) - - const load = useCallback(() => { - const token = (requestTokenRef.current += 1) - void window.api.settings.agentCatalog - .getLocal() - .then((next) => { - if (mountedRef.current && token === requestTokenRef.current) { - setSnapshot(next) - setUnavailable(false) - } - }) - .catch(() => { - // Paired web rejects (not_available_on_paired_web): surface an honest - // read-only empty state rather than a perpetual loading spinner. - if (mountedRef.current && token === requestTokenRef.current) { - setUnavailable(true) - } - }) - }, []) - - const applySnapshot = useCallback((next: LocalAgentCatalogSnapshot) => { - // Bump the token so an in-flight refetch cannot overwrite this fresher result. - requestTokenRef.current += 1 - setSnapshot(next) - }, []) +export function useLocalAgentCatalog(options?: UseLocalAgentCatalogOptions): UseLocalAgentCatalog { + const enabled = options?.enabled ?? true + const [state, setState] = useState(IDLE_LOCAL_AGENT_CATALOG_STATE) useEffect(() => { - mountedRef.current = true - load() - const unsubscribe = window.api.settings.onChanged((updates) => { - // Custom-agent mutations patch customTuiAgents/deletedCustomTuiAgents (and - // bump agentCatalogRevision); without these keys, always-mounted launch - // surfaces keep a stale snapshot after authoring in another component. - if ( - 'defaultTuiAgent' in updates || - 'disabledTuiAgents' in updates || - 'customTuiAgents' in updates || - 'deletedCustomTuiAgents' in updates || - 'agentCatalogRevision' in updates - ) { - load() - } - }) - return () => { - mountedRef.current = false - unsubscribe() + if (!enabled) { + setState(IDLE_LOCAL_AGENT_CATALOG_STATE) + return undefined } - }, [load]) + const sync = (): void => setState(getLocalAgentCatalogState()) + const unsubscribe = subscribeToLocalAgentCatalog(sync) + // Adopt whatever the store already holds; a later consumer must not wait for + // the next publish to see the snapshot an earlier one already loaded. + sync() + return unsubscribe + }, [enabled]) return { - snapshot, - loading: snapshot === null && !unavailable, - unavailable, - refetch: load, - applySnapshot + snapshot: state.snapshot, + loading: enabled && state.snapshot === null && !state.unavailable, + unavailable: state.unavailable, + refetch: loadLocalAgentCatalog, + applySnapshot: applyLocalAgentCatalogSnapshot } } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index d357c9e3acc..3bd98089966 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -10688,6 +10688,11 @@ "nullDefaultAttention": "Choose a default agent. Until you do, attended launches can't start and will ask you to pick one.", "migrationBlockedTitle": "Agent settings are temporarily read-only", "migrationBlockedDescription": "Orca could not back up your existing agent settings before updating them, so changes are blocked to protect your data. Resolve the problem below, then try again.", + "writeFailedTitle": "Your change wasn't saved", + "writeFailedDescription": "Orca couldn't save this change to disk, so nothing was changed. Check that the disk isn't full or read-only, then try again.", + "schemaTooNewTitle": "Custom agents are read-only in this version of Orca", + "schemaTooNewDescription": "This profile was saved by a newer version of Orca. Your data is unchanged and agents keep launching, but custom agents cannot be changed here — update Orca to edit them again. Retrying will not help.", + "schemaTooNewVersions": "Profile agent catalog v{{persisted}}; this version of Orca supports v{{supported}}.", "unavailable": "Custom agents are managed on the desktop host." }, "AgentCatalogStatusBadge": { @@ -16151,19 +16156,20 @@ "manageAgents": "Manage agents" }, "dataRecovery": { - "pointAgentCatalogPreV1Title": "Before the custom-agents update (agent catalog v1)", - "pointAgentCatalogPreV1Loss": "Restoring discards all settings and workspace metadata saved after this point, including any custom agents.", + "pointAgentCatalogPreV1Title": "Before custom agents", + "pointAgentCatalogPreV1Loss": "This discards settings and custom agents saved after this update.", "title": "Data recovery", - "description": "Restore this profile from a recovery point created before a data migration. Restoring never deletes the recovery point.", + "description": "Restore the settings Orca saved before this update. The saved copy is kept.", "loading": "Looking for recovery points…", "empty": "No recovery points exist for this profile.", "createdAt": "Created {{date}}", "createdUnknown": "Creation time unknown", "previousBinary": "After restoring, Orca quits. Install the previous Orca version before opening it again.", + "unreadable": "This recovery point cannot be read, so it cannot be restored. Check its file permissions, or move whatever now occupies its path, then reopen Data recovery.", "restoring": "Restoring…", "confirmRestore": "Restore and quit", "cancel": "Cancel", - "prepareDowngrade": "Prepare downgrade…", + "prepareDowngrade": "Restore these settings…", "restoreFailed": "Restore failed and no changes were made: {{error}}", "restoredTitle": "Restored — quitting Orca", "restoredBody": "The recovery point was restored and Orca is quitting. This window no longer saves changes, so anything you do here from now on is discarded. If Orca does not quit on its own, quit it yourself, then install the previous Orca version before opening it again.", @@ -16174,11 +16180,19 @@ "retryMigration": "Retry migration", "openDataRecovery": "Open Data recovery", "copyDetails": "Copy details", - "settingsRowHint": "A pre-update recovery point exists for this profile.", + "settingsRowHint": "You can restore the settings from before this update, then install the previous Orca.", "settingsRowOpen": "Data recovery…", - "pinExitTitle": "This profile was upgraded for custom agents", - "pinExitBody": "Orca kept a recovery point so you can return to the previous Orca. Use Data recovery → Prepare downgrade, then install that older version. Do not only reinstall the older app over this profile. Going back discards settings and custom agents saved after the recovery point.", - "dismissPinExit": "Got it" + "pinExitTitle": "Custom agents are now available", + "pinExitLead": "A custom agent is saved arguments for a harness like Codex or Claude, picked by name.", + "pinExitExampleBadge": "Custom", + "pinExitExampleHint": "Create them in Settings → Agents. Keep working as usual — nothing else is required.", + "pinExitRollbackTitle": "Before you install an older Orca", + "pinExitRollbackReinstall": "This version changed the local data format. Restore the previous backup first, or the older app can break.", + "pinExitRollbackStepRestore": "Restore the data backup. Orca will quit.", + "pinExitRollbackStepInstall": "Then install the older Orca.", + "pinExitRollbackLoss": "The restore discards changes since this update, including custom agents.", + "pinExitGoBack": "Restore data backup…", + "dismissPinExit": "Continue" } }, "i18n": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index bc97738f929..700959b781f 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -9789,6 +9789,11 @@ "nullDefaultAttention": "Elige un agente predeterminado. Hasta que lo hagas, los inicios atendidos no podrán comenzar y te pedirán que elijas uno.", "migrationBlockedTitle": "Los ajustes de agentes son temporalmente de solo lectura", "migrationBlockedDescription": "Orca no pudo hacer una copia de seguridad de tus ajustes de agentes existentes antes de actualizarlos, por lo que los cambios están bloqueados para proteger tus datos. Resuelve el problema que aparece abajo y vuelve a intentarlo.", + "writeFailedTitle": "Tu cambio no se guardó", + "writeFailedDescription": "Orca no pudo guardar este cambio en el disco, así que no se modificó nada. Comprueba que el disco no esté lleno ni sea de solo lectura y vuelve a intentarlo.", + "schemaTooNewTitle": "Los agentes personalizados son de solo lectura en esta versión de Orca", + "schemaTooNewDescription": "Este perfil se guardó con una versión más reciente de Orca. Tus datos no han cambiado y los agentes siguen iniciándose, pero los agentes personalizados no se pueden modificar aquí: actualiza Orca para volver a editarlos. Reintentar no servirá de nada.", + "schemaTooNewVersions": "Catálogo de agentes del perfil v{{persisted}}; esta versión de Orca admite v{{supported}}.", "unavailable": "Custom agents are managed on the desktop host." }, "AgentCatalogStatusBadge": { @@ -14608,19 +14613,20 @@ "manageAgents": "Administrar agentes" }, "dataRecovery": { - "pointAgentCatalogPreV1Title": "Before the custom-agents update (agent catalog v1)", - "pointAgentCatalogPreV1Loss": "Restoring discards all settings and workspace metadata saved after this point, including any custom agents.", + "pointAgentCatalogPreV1Title": "Before custom agents", + "pointAgentCatalogPreV1Loss": "This discards settings and custom agents saved after this update.", "title": "Data recovery", - "description": "Restore this profile from a recovery point created before a data migration. Restoring never deletes the recovery point.", + "description": "Restore the settings Orca saved before this update. The saved copy is kept.", "loading": "Looking for recovery points…", "empty": "No recovery points exist for this profile.", "createdAt": "Created {{date}}", "createdUnknown": "Creation time unknown", "previousBinary": "After restoring, Orca quits. Install the previous Orca version before opening it again.", + "unreadable": "Este punto de recuperación no se puede leer, por lo que no se puede restaurar. Comprueba sus permisos de archivo o mueve lo que ahora ocupe su ruta y vuelve a abrir Recuperación de datos.", "restoring": "Restoring…", "confirmRestore": "Restore and quit", "cancel": "Cancel", - "prepareDowngrade": "Prepare downgrade…", + "prepareDowngrade": "Restore these settings…", "restoreFailed": "Restore failed and no changes were made: {{error}}", "close": "Close", "noticeTitle": "Orca could not back up your data before a required update", @@ -14629,7 +14635,7 @@ "retryMigration": "Retry migration", "openDataRecovery": "Open Data recovery", "copyDetails": "Copy details", - "settingsRowHint": "A pre-update recovery point exists for this profile.", + "settingsRowHint": "You can restore the settings from before this update, then install the previous Orca.", "settingsRowOpen": "Data recovery…" } }, diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index d636c111ea4..83c9efe24c2 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -9789,6 +9789,11 @@ "nullDefaultAttention": "デフォルトの Agent を選択してください。選択するまで、対話的な起動は開始できず、選択を求められます。", "migrationBlockedTitle": "Agent 設定は一時的に読み取り専用です", "migrationBlockedDescription": "Orca は既存の Agent 設定を更新する前にバックアップを作成できなかったため、データを保護するために変更がブロックされています。以下の問題を解決してから、もう一度お試しください。", + "writeFailedTitle": "変更は保存されませんでした", + "writeFailedDescription": "Orca はこの変更をディスクに保存できなかったため、何も変更されていません。ディスクの空き容量があり、読み取り専用でないことを確認してから、もう一度お試しください。", + "schemaTooNewTitle": "このバージョンの Orca ではカスタム Agent は読み取り専用です", + "schemaTooNewDescription": "このプロファイルは新しいバージョンの Orca で保存されました。データは変更されておらず、Agent の起動も引き続き可能ですが、カスタム Agent はここでは変更できません。編集するには Orca を更新してください。再試行しても解決しません。", + "schemaTooNewVersions": "プロファイルの Agent カタログは v{{persisted}}、このバージョンの Orca が対応するのは v{{supported}} です。", "unavailable": "Custom agents are managed on the desktop host." }, "AgentCatalogStatusBadge": { @@ -14608,19 +14613,20 @@ "manageAgents": "Agent を管理" }, "dataRecovery": { - "pointAgentCatalogPreV1Title": "Before the custom-agents update (agent catalog v1)", - "pointAgentCatalogPreV1Loss": "Restoring discards all settings and workspace metadata saved after this point, including any custom agents.", + "pointAgentCatalogPreV1Title": "Before custom agents", + "pointAgentCatalogPreV1Loss": "This discards settings and custom agents saved after this update.", "title": "Data recovery", - "description": "Restore this profile from a recovery point created before a data migration. Restoring never deletes the recovery point.", + "description": "Restore the settings Orca saved before this update. The saved copy is kept.", "loading": "Looking for recovery points…", "empty": "No recovery points exist for this profile.", "createdAt": "Created {{date}}", "createdUnknown": "Creation time unknown", "previousBinary": "After restoring, Orca quits. Install the previous Orca version before opening it again.", + "unreadable": "このリカバリポイントは読み取れないため復元できません。ファイルのアクセス権を確認するか、そのパスを現在占有しているものを移動してから、データリカバリを開き直してください。", "restoring": "Restoring…", "confirmRestore": "Restore and quit", "cancel": "Cancel", - "prepareDowngrade": "Prepare downgrade…", + "prepareDowngrade": "Restore these settings…", "restoreFailed": "Restore failed and no changes were made: {{error}}", "close": "Close", "noticeTitle": "Orca could not back up your data before a required update", @@ -14629,7 +14635,7 @@ "retryMigration": "Retry migration", "openDataRecovery": "Open Data recovery", "copyDetails": "Copy details", - "settingsRowHint": "A pre-update recovery point exists for this profile.", + "settingsRowHint": "You can restore the settings from before this update, then install the previous Orca.", "settingsRowOpen": "Data recovery…" } }, diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 58b7d943cda..2722b802647 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -9794,6 +9794,11 @@ "nullDefaultAttention": "기본 에이전트를 선택하세요. 선택하기 전에는 대화형 실행을 시작할 수 없으며 에이전트 선택을 요청받습니다.", "migrationBlockedTitle": "에이전트 설정이 일시적으로 읽기 전용입니다", "migrationBlockedDescription": "Orca가 기존 에이전트 설정을 업데이트하기 전에 백업할 수 없었기 때문에 데이터를 보호하기 위해 변경이 차단되었습니다. 아래 문제를 해결한 후 다시 시도하세요.", + "writeFailedTitle": "변경 사항이 저장되지 않았습니다", + "writeFailedDescription": "Orca가 이 변경 사항을 디스크에 저장하지 못해 아무것도 변경되지 않았습니다. 디스크가 가득 찼거나 읽기 전용은 아닌지 확인한 후 다시 시도하세요.", + "schemaTooNewTitle": "이 버전의 Orca에서는 사용자 지정 에이전트가 읽기 전용입니다", + "schemaTooNewDescription": "이 프로필은 더 새로운 버전의 Orca에서 저장되었습니다. 데이터는 변경되지 않았고 에이전트도 계속 실행되지만, 사용자 지정 에이전트는 여기서 변경할 수 없습니다. 다시 편집하려면 Orca를 업데이트하세요. 다시 시도해도 해결되지 않습니다.", + "schemaTooNewVersions": "프로필 에이전트 카탈로그 v{{persisted}}, 이 버전의 Orca가 지원하는 버전은 v{{supported}}입니다.", "unavailable": "Custom agents are managed on the desktop host." }, "AgentCatalogStatusBadge": { @@ -14640,19 +14645,20 @@ "manageAgents": "에이전트 관리" }, "dataRecovery": { - "pointAgentCatalogPreV1Title": "Before the custom-agents update (agent catalog v1)", - "pointAgentCatalogPreV1Loss": "Restoring discards all settings and workspace metadata saved after this point, including any custom agents.", + "pointAgentCatalogPreV1Title": "Before custom agents", + "pointAgentCatalogPreV1Loss": "This discards settings and custom agents saved after this update.", "title": "Data recovery", - "description": "Restore this profile from a recovery point created before a data migration. Restoring never deletes the recovery point.", + "description": "Restore the settings Orca saved before this update. The saved copy is kept.", "loading": "Looking for recovery points…", "empty": "No recovery points exist for this profile.", "createdAt": "Created {{date}}", "createdUnknown": "Creation time unknown", "previousBinary": "After restoring, Orca quits. Install the previous Orca version before opening it again.", + "unreadable": "이 복구 지점은 읽을 수 없어 복원할 수 없습니다. 파일 권한을 확인하거나 해당 경로를 현재 차지하고 있는 항목을 옮긴 다음 데이터 복구를 다시 여세요.", "restoring": "Restoring…", "confirmRestore": "Restore and quit", "cancel": "Cancel", - "prepareDowngrade": "Prepare downgrade…", + "prepareDowngrade": "Restore these settings…", "restoreFailed": "Restore failed and no changes were made: {{error}}", "close": "Close", "noticeTitle": "Orca could not back up your data before a required update", @@ -14661,7 +14667,7 @@ "retryMigration": "Retry migration", "openDataRecovery": "Open Data recovery", "copyDetails": "Copy details", - "settingsRowHint": "A pre-update recovery point exists for this profile.", + "settingsRowHint": "You can restore the settings from before this update, then install the previous Orca.", "settingsRowOpen": "Data recovery…" } }, diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 2cdd5d7a4b1..823922a696d 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -9804,6 +9804,11 @@ "nullDefaultAttention": "请选择一个默认智能体。在此之前,需人工干预的启动将无法开始,并会要求你选择一个。", "migrationBlockedTitle": "智能体设置暂时为只读状态", "migrationBlockedDescription": "Orca 在更新前无法备份你现有的智能体设置,因此变更已被阻止以保护你的数据。请先解决下面的问题,然后重试。", + "writeFailedTitle": "你的更改未保存", + "writeFailedDescription": "Orca 无法将此更改保存到磁盘,因此未做任何更改。请检查磁盘是否已满或为只读,然后重试。", + "schemaTooNewTitle": "在此版本的 Orca 中,自定义智能体为只读状态", + "schemaTooNewDescription": "此配置文件由更新版本的 Orca 保存。你的数据未发生变化,智能体也仍可正常启动,但无法在此修改自定义智能体——请更新 Orca 后再进行编辑。重试无法解决此问题。", + "schemaTooNewVersions": "配置文件的智能体目录为 v{{persisted}};此版本的 Orca 支持 v{{supported}}。", "unavailable": "Custom agents are managed on the desktop host." }, "AgentCatalogStatusBadge": { @@ -14631,19 +14636,20 @@ "manageAgents": "管理代理" }, "dataRecovery": { - "pointAgentCatalogPreV1Title": "Before the custom-agents update (agent catalog v1)", - "pointAgentCatalogPreV1Loss": "Restoring discards all settings and workspace metadata saved after this point, including any custom agents.", + "pointAgentCatalogPreV1Title": "Before custom agents", + "pointAgentCatalogPreV1Loss": "This discards settings and custom agents saved after this update.", "title": "Data recovery", - "description": "Restore this profile from a recovery point created before a data migration. Restoring never deletes the recovery point.", + "description": "Restore the settings Orca saved before this update. The saved copy is kept.", "loading": "Looking for recovery points…", "empty": "No recovery points exist for this profile.", "createdAt": "Created {{date}}", "createdUnknown": "Creation time unknown", "previousBinary": "After restoring, Orca quits. Install the previous Orca version before opening it again.", + "unreadable": "此恢复点无法读取,因此无法还原。请检查其文件权限,或移走当前占用该路径的内容,然后重新打开数据恢复。", "restoring": "Restoring…", "confirmRestore": "Restore and quit", "cancel": "Cancel", - "prepareDowngrade": "Prepare downgrade…", + "prepareDowngrade": "Restore these settings…", "restoreFailed": "Restore failed and no changes were made: {{error}}", "close": "Close", "noticeTitle": "Orca could not back up your data before a required update", @@ -14652,7 +14658,7 @@ "retryMigration": "Retry migration", "openDataRecovery": "Open Data recovery", "copyDetails": "Copy details", - "settingsRowHint": "A pre-update recovery point exists for this profile.", + "settingsRowHint": "You can restore the settings from before this update, then install the previous Orca.", "settingsRowOpen": "Data recovery…" } }, diff --git a/src/renderer/src/lib/agent-authoring-write-failure-toast.test.ts b/src/renderer/src/lib/agent-authoring-write-failure-toast.test.ts new file mode 100644 index 00000000000..b0d37ef5c5e --- /dev/null +++ b/src/renderer/src/lib/agent-authoring-write-failure-toast.test.ts @@ -0,0 +1,53 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const toastError = vi.fn() +vi.mock('sonner', () => ({ toast: { error: (...args: unknown[]) => toastError(...args) } })) + +const { notifyAgentAuthoringWriteFailure } = await import('./agent-authoring-write-failure-toast') + +type ToastCall = [string, { id: string; description: string }] + +/** Widens a mutation result so the literal is checked against the mutation shape, + * not the narrower `{ ok }` the notifier accepts. */ +function mutationResult(result: { ok: boolean; code?: string }): { ok: boolean } { + return result +} + +describe('notifyAgentAuthoringWriteFailure', () => { + beforeEach(() => { + toastError.mockClear() + }) + + it('stays silent when the mutation was persisted', () => { + expect(notifyAgentAuthoringWriteFailure(mutationResult({ ok: true }))).toBeNull() + expect(toastError).not.toHaveBeenCalled() + }) + + it('stays silent for rejections that did not lose the change', () => { + expect( + notifyAgentAuthoringWriteFailure( + mutationResult({ ok: false, code: 'reference_revision_conflict' }) + ) + ).toBeNull() + expect(toastError).not.toHaveBeenCalled() + }) + + it('reports catalog and reference durable-write failures under one toast id', () => { + expect( + notifyAgentAuthoringWriteFailure( + mutationResult({ ok: false, code: 'agent_reference_write_failed' }) + ) + ).toBe('agent_reference_write_failed') + expect( + notifyAgentAuthoringWriteFailure( + mutationResult({ ok: false, code: 'agent_catalog_write_failed' }) + ) + ).toBe('agent_catalog_write_failed') + + expect(toastError).toHaveBeenCalledTimes(2) + const [title, options] = toastError.mock.calls[0] as ToastCall + expect(title).toContain("wasn't saved") + expect(options.description).toContain('try again') + expect((toastError.mock.calls[1] as ToastCall)[1].id).toBe(options.id) + }) +}) diff --git a/src/renderer/src/lib/agent-authoring-write-failure-toast.ts b/src/renderer/src/lib/agent-authoring-write-failure-toast.ts new file mode 100644 index 00000000000..76b9ef9bef7 --- /dev/null +++ b/src/renderer/src/lib/agent-authoring-write-failure-toast.ts @@ -0,0 +1,35 @@ +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' +import { + agentAuthoringWriteFailureMessage, + asAgentAuthoringWriteFailure, + type AgentAuthoringWriteFailureCode +} from '@/components/settings/agent-authoring-write-failure' + +// Why: quick commands and AI recipes are also authored from surfaces with no +// notice slot (tab bar, terminal pane, generation dialogs). Those durable-write +// rejections get the same copy through one toast id, so a burst of failed writes +// replaces rather than stacks. +const AGENT_AUTHORING_WRITE_FAILURE_TOAST_ID = 'agent-authoring-write-failed' + +/** Toasts the shared "nothing was saved" copy for a durable-write rejection; + * returns the code so callers can also skip their own success affordance. */ +export function notifyAgentAuthoringWriteFailure(result: { + ok: boolean +}): AgentAuthoringWriteFailureCode | null { + const code = asAgentAuthoringWriteFailure(result) + if (!code) { + return null + } + toast.error( + translate( + 'auto.components.settings.AgentCatalogSection.writeFailedTitle', + "Your change wasn't saved" + ), + { + id: AGENT_AUTHORING_WRITE_FAILURE_TOAST_ID, + description: agentAuthoringWriteFailureMessage() + } + ) + return code +} diff --git a/src/renderer/src/lib/launch-agent-in-new-tab-paired-web.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab-paired-web.test.ts new file mode 100644 index 00000000000..954446b90bc --- /dev/null +++ b/src/renderer/src/lib/launch-agent-in-new-tab-paired-web.test.ts @@ -0,0 +1,268 @@ +// Paired-web-client half of the launchAgentInNewTab suite: every launch is +// delegated to the host runtime through agentLaunch, never assembled client-side. +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + mockCreateTab, + mockCreateWebRuntimeAgentSessionTerminal, + mockCreateWebRuntimeAgentSessionTerminalWithLaunchDraft, + mockCreateWebRuntimeSessionTerminal, + mockIsWebRuntimeSessionActive, + mockPasteDraftWhenAgentReady, + mockQueueTabStartupCommand, + mockSetActiveTabType, + mockToastError, + mockToastMessage, + mockTrack, + resetLaunchAgentInNewTabHarness, + store +} from './launch-agent-in-new-tab-test-harness' + +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => store + } +})) + +vi.mock('sonner', () => ({ + toast: { message: mockToastMessage, error: mockToastError } +})) + +vi.mock('@/components/tab-bar/reconcile-order', () => ({ + reconcileTabOrder: vi.fn( + (_stored, termIds: string[], editorIds: string[], browserIds: string[]) => [ + ...termIds, + ...editorIds, + ...browserIds + ] + ) +})) + +vi.mock('@/lib/agent-paste-draft', () => ({ + pasteDraftWhenAgentReady: mockPasteDraftWhenAgentReady +})) + +vi.mock('@/lib/telemetry', () => ({ + track: mockTrack, + tuiAgentToAgentKind: (agent: string) => agent +})) + +vi.mock('@/runtime/web-runtime-session', () => ({ + createWebRuntimeSessionTerminal: mockCreateWebRuntimeSessionTerminal, + createWebRuntimeAgentSessionTerminal: mockCreateWebRuntimeAgentSessionTerminal, + createWebRuntimeAgentSessionTerminalWithLaunchDraft: + mockCreateWebRuntimeAgentSessionTerminalWithLaunchDraft, + isWebRuntimeSessionActive: mockIsWebRuntimeSessionActive, + isWebTerminalSurfaceTabId: vi.fn(() => false) +})) + +describe('launchAgentInNewTab in paired web clients', () => { + beforeEach(() => { + resetLaunchAgentInNewTabHarness() + }) + + it('delegates agent quick launch to the host runtime in paired web clients', async () => { + mockIsWebRuntimeSessionActive.mockReturnValue(true) + store.settings = { + agentCmdOverrides: {}, + agentDefaultArgs: {}, + agentDefaultEnv: {}, + activeRuntimeEnvironmentId: 'web-runtime' + } + store.tabsByWorktree = { + 'wt-1': [ + { id: 'tab-1' }, + { id: 'stale-agent-tab', launchAgent: 'claude' } as { id: string; launchAgent: string } + ] + } + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + const result = launchAgentInNewTab({ + agent: 'claude', + worktreeId: 'wt-1', + groupId: 'group-1' + }) + + expect(result).toEqual( + expect.objectContaining({ + tabId: null, + pasteDraftAfterLaunch: false + }) + ) + // Paired web launches route through the same host `agentLaunch` boundary as + // the local path — identity + launch policy only, never a client command. + expect(mockCreateWebRuntimeSessionTerminal).toHaveBeenCalledWith({ + worktreeId: 'wt-1', + environmentId: 'web-runtime', + targetGroupId: 'group-1', + activate: true, + agentLaunch: { + selection: { kind: 'agent', agent: 'claude' }, + allowEmptyPromptLaunch: true + }, + viewMode: 'terminal' + }) + expect(mockCreateTab).not.toHaveBeenCalled() + expect(mockQueueTabStartupCommand).not.toHaveBeenCalled() + await Promise.resolve() + expect(mockSetActiveTabType).toHaveBeenCalledWith('terminal') + expect(store.closeTab).toHaveBeenCalledWith('stale-agent-tab', { + reason: 'cleanup' + }) + }) + + it('mirrors a paired-host draft while keeping launch assembly host-owned', async () => { + mockIsWebRuntimeSessionActive.mockReturnValue(true) + store.settings = { + agentCmdOverrides: {}, + agentDefaultArgs: {}, + agentDefaultEnv: {}, + activeRuntimeEnvironmentId: 'web-runtime' + } + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + const result = launchAgentInNewTab({ + agent: 'claude', + worktreeId: 'wt-1', + prompt: 'review before sending', + promptDelivery: 'draft' + }) + + expect(result).toEqual(expect.objectContaining({ tabId: null, pasteDraftAfterLaunch: false })) + expect(mockCreateWebRuntimeAgentSessionTerminalWithLaunchDraft).toHaveBeenCalledWith({ + worktreeId: 'wt-1', + environmentId: 'web-runtime', + targetGroupId: undefined, + activate: true, + agentLaunch: { + selection: { kind: 'agent', agent: 'claude' }, + prompt: 'review before sending', + promptDelivery: 'draft' + }, + viewMode: 'terminal', + agent: 'claude', + launchDraft: 'review before sending' + }) + expect(mockCreateWebRuntimeSessionTerminal).not.toHaveBeenCalled() + expect(mockCreateTab).not.toHaveBeenCalled() + }) + + it('forwards a prompt launch to paired web runtime hosts through agentLaunch only', async () => { + mockIsWebRuntimeSessionActive.mockReturnValue(true) + store.settings = { + agentCmdOverrides: {}, + agentDefaultArgs: { codex: '--model gpt-5 --reasoning-effort high' }, + agentDefaultEnv: { codex: { CODEX_PROFILE: 'captured' } }, + activeRuntimeEnvironmentId: 'web-runtime' + } + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + const result = launchAgentInNewTab({ + agent: 'codex', + worktreeId: 'wt-1', + prompt: 'fix the spinner', + groupId: 'group-1' + }) + + expect(result).toEqual( + expect.objectContaining({ + tabId: null, + pasteDraftAfterLaunch: false + }) + ) + // The host owns argv/env/config assembly (including captured agentDefaultArgs + // /agentDefaultEnv); the request carries identity + folded prompt only. + expect(mockCreateWebRuntimeSessionTerminal).toHaveBeenCalledWith({ + worktreeId: 'wt-1', + environmentId: 'web-runtime', + targetGroupId: 'group-1', + activate: true, + agentLaunch: { + selection: { kind: 'agent', agent: 'codex' }, + prompt: 'fix the spinner' + }, + viewMode: 'terminal' + }) + expect(mockCreateTab).not.toHaveBeenCalled() + expect(mockQueueTabStartupCommand).not.toHaveBeenCalled() + }) + + it('propagates the default chat mode to paired web runtime launches', async () => { + mockIsWebRuntimeSessionActive.mockReturnValue(true) + store.settings = { + agentCmdOverrides: {}, + agentDefaultArgs: {}, + agentDefaultEnv: {}, + activeRuntimeEnvironmentId: 'web-runtime', + experimentalNativeChat: true, + openAgentTabsInChatByDefault: true + } + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' }) + + expect(mockCreateWebRuntimeSessionTerminal).toHaveBeenCalledWith( + expect.objectContaining({ + worktreeId: 'wt-1', + environmentId: 'web-runtime', + agentLaunch: { + selection: { kind: 'agent', agent: 'codex' }, + allowEmptyPromptLaunch: true + }, + viewMode: 'chat' + }) + ) + }) + + it('propagates the resolved terminal mode to paired web runtime launches', async () => { + mockIsWebRuntimeSessionActive.mockReturnValue(true) + store.settings = { + agentCmdOverrides: {}, + agentDefaultArgs: {}, + agentDefaultEnv: {}, + activeRuntimeEnvironmentId: 'web-runtime', + experimentalNativeChat: true, + openAgentTabsInChatByDefault: false + } + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' }) + + expect(mockCreateWebRuntimeSessionTerminal).toHaveBeenCalledWith( + expect.objectContaining({ + worktreeId: 'wt-1', + environmentId: 'web-runtime', + agentLaunch: { + selection: { kind: 'agent', agent: 'codex' }, + allowEmptyPromptLaunch: true + }, + viewMode: 'terminal' + }) + ) + }) + + it('surfaces a toast when host agent launch fails in paired web clients', async () => { + mockIsWebRuntimeSessionActive.mockReturnValue(true) + mockCreateWebRuntimeSessionTerminal.mockResolvedValue({ + status: 'failed', + message: 'Upgrade the remote Orca host before starting or resuming agent sessions.' + }) + store.settings = { + agentCmdOverrides: {}, + agentDefaultArgs: {}, + agentDefaultEnv: {}, + activeRuntimeEnvironmentId: 'web-runtime' + } + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + launchAgentInNewTab({ + agent: 'claude', + worktreeId: 'wt-1' + }) + + await Promise.resolve() + expect(mockToastError).toHaveBeenCalledWith( + 'Upgrade the remote Orca host before starting or resuming agent sessions.' + ) + expect(mockSetActiveTabType).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/lib/launch-agent-in-new-tab-test-harness.ts b/src/renderer/src/lib/launch-agent-in-new-tab-test-harness.ts new file mode 100644 index 00000000000..2980b905a82 --- /dev/null +++ b/src/renderer/src/lib/launch-agent-in-new-tab-test-harness.ts @@ -0,0 +1,141 @@ +// Shared fixtures for the launchAgentInNewTab suites. The vi.mock calls stay in +// each test file (they are hoisted per module), but their factories read the +// mock functions from here so both files drive one set of doubles. +import { vi, type Mock } from 'vitest' + +export const mockCreateTab: Mock = vi.fn() +export const mockQueueTabStartupCommand: Mock = vi.fn() +export const mockSetActiveTabType: Mock = vi.fn() +export const mockSetTabBarOrder: Mock = vi.fn() +export const mockSetAgentStatus: Mock = vi.fn() +export const mockPasteDraftWhenAgentReady: Mock = vi.fn() +export const mockSeedNativeChatLaunchPrompt: Mock = vi.fn() +export const mockSeedNativeChatLaunchDraft: Mock = vi.fn() +export const mockMarkNativeChatLaunchPromptFailed: Mock = vi.fn() +export const mockTrack: Mock = vi.fn() +export const mockToastMessage: Mock = vi.fn() +export const mockToastError: Mock = vi.fn() +export const mockCreateWebRuntimeSessionTerminal: Mock = vi.fn() +export const mockCreateWebRuntimeAgentSessionTerminal: Mock = vi.fn() +export const mockCreateWebRuntimeAgentSessionTerminalWithLaunchDraft: Mock = vi.fn() +export const mockIsWebRuntimeSessionActive: Mock = vi.fn(() => false) + +export const LEAF_ID = '11111111-1111-4111-8111-111111111111' + +export const store = { + activeRepoId: 'repo-1', + activeWorktreeId: 'wt-1', + settings: { + agentCmdOverrides: {}, + agentDefaultArgs: {} as Record, + agentDefaultEnv: {} as Record>, + activeRuntimeEnvironmentId: null as string | null + } as { + agentCmdOverrides: Record + agentDefaultArgs: Record + agentDefaultEnv: Record> + activeRuntimeEnvironmentId: string | null + terminalWindowsShell?: string + experimentalNativeChat?: boolean + openAgentTabsInChatByDefault?: boolean + nativeChatSessionOptions?: Record< + string, + { model?: string; valuesByModel?: Record> } + > + }, + projects: [ + { + id: 'repo-1', + localWindowsRuntimePreference: { kind: 'inherit-global' as const } + } + ] as { + id: string + localWindowsRuntimePreference: + | { kind: 'inherit-global' } + | { kind: 'windows-host' } + | { kind: 'wsl'; distro: string | null } + }[], + repos: [{ id: 'repo-1', connectionId: null as string | null, path: '/repo' }], + sshConnectionStates: new Map([['ssh-a', { status: 'connected' }]]), + transientClearedAgentStatusConnectionIds: {} as Record, + worktreesByRepo: { + 'repo-1': [ + { + id: 'wt-1', + repoId: 'repo-1', + projectId: 'repo-1', + path: '/repo/worktree', + displayName: 'main' + } + ] + }, + allWorktrees: vi.fn(() => store.worktreesByRepo['repo-1']) as Mock, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-1' }] + }, + openFiles: [] as { id: string; worktreeId: string }[], + browserTabsByWorktree: {} as Record, + tabBarOrderByWorktree: {} as Record, + terminalLayoutsByTabId: {} as Record< + string, + { activeLeafId: string | null; ptyIdsByLeafId?: Record } + >, + ptyIdsByTabId: {} as Record, + createTab: mockCreateTab, + closeTab: vi.fn() as Mock, + queueTabStartupCommand: mockQueueTabStartupCommand, + setActiveTabType: mockSetActiveTabType, + setTabBarOrder: mockSetTabBarOrder, + setAgentStatus: mockSetAgentStatus, + seedNativeChatLaunchPrompt: mockSeedNativeChatLaunchPrompt, + seedNativeChatLaunchDraft: mockSeedNativeChatLaunchDraft, + markNativeChatLaunchPromptFailed: mockMarkNativeChatLaunchPromptFailed +} + +/** Restores every fixture the suites mutate; call from each beforeEach. */ +export function resetLaunchAgentInNewTabHarness(): void { + vi.clearAllMocks() + mockIsWebRuntimeSessionActive.mockReturnValue(false) + mockCreateWebRuntimeSessionTerminal.mockResolvedValue({ status: 'created' }) + mockCreateWebRuntimeAgentSessionTerminal.mockResolvedValue({ + outcome: { status: 'created' }, + promptDelivered: true + }) + mockCreateWebRuntimeAgentSessionTerminalWithLaunchDraft.mockResolvedValue({ status: 'created' }) + store.activeRepoId = 'repo-1' + store.activeWorktreeId = 'wt-1' + store.settings = { + agentCmdOverrides: {}, + agentDefaultArgs: {}, + agentDefaultEnv: {}, + activeRuntimeEnvironmentId: null + } + store.projects = [ + { + id: 'repo-1', + localWindowsRuntimePreference: { kind: 'inherit-global' } + } + ] + store.repos = [{ id: 'repo-1', connectionId: null, path: '/repo' }] + store.sshConnectionStates = new Map([['ssh-a', { status: 'connected' }]]) + store.transientClearedAgentStatusConnectionIds = {} + store.worktreesByRepo = { + 'repo-1': [ + { + id: 'wt-1', + repoId: 'repo-1', + projectId: 'repo-1', + path: '/repo/worktree', + displayName: 'main' + } + ] + } + store.tabsByWorktree = { 'wt-1': [{ id: 'tab-1' }] } + store.openFiles = [] + store.browserTabsByWorktree = {} + store.tabBarOrderByWorktree = {} + store.terminalLayoutsByTabId = {} + store.ptyIdsByTabId = {} + mockCreateTab.mockReturnValue({ id: 'tab-1' }) + mockPasteDraftWhenAgentReady.mockResolvedValue(true) +} diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts index 4d30d30b43d..f27caf6783b 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.test.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.test.ts @@ -1,94 +1,23 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { toAppSshPtyId } from '../../../shared/ssh-pty-id' -import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' - -const mockCreateTab = vi.fn() -const mockQueueTabStartupCommand = vi.fn() -const mockSetActiveTabType = vi.fn() -const mockSetTabViewMode = vi.fn() -const mockSetTabBarOrder = vi.fn() -const mockSetAgentStatus = vi.fn() -const mockPasteDraftWhenAgentReady = vi.fn() -const mockSeedNativeChatLaunchPrompt = vi.fn() -const mockSeedNativeChatLaunchDraft = vi.fn() -const mockMarkNativeChatLaunchPromptFailed = vi.fn() -const mockTrack = vi.fn() -const mockToastMessage = vi.fn() -const mockWaitForAgentReady = vi.fn() - -const LEAF_ID = '11111111-1111-4111-8111-111111111111' - -const store = { - activeRepoId: 'repo-1', - activeWorktreeId: 'wt-1', - settings: { - agentCmdOverrides: {}, - agentDefaultArgs: {} as Record, - agentDefaultEnv: {} as Record>, - activeRuntimeEnvironmentId: null as string | null - } as { - agentCmdOverrides: Record - agentDefaultArgs: Record - agentDefaultEnv: Record> - activeRuntimeEnvironmentId: string | null - terminalWindowsShell?: string - experimentalNativeChat?: boolean - experimentalStructuredNativeChat?: boolean - openAgentTabsInChatByDefault?: boolean - nativeChatSessionOptions?: Record< - string, - { model?: string; valuesByModel?: Record> } - > - }, - projects: [ - { - id: 'repo-1', - localWindowsRuntimePreference: { kind: 'inherit-global' as const } - } - ] as { - id: string - localWindowsRuntimePreference: - | { kind: 'inherit-global' } - | { kind: 'windows-host' } - | { kind: 'wsl'; distro: string | null } - }[], - repos: [{ id: 'repo-1', connectionId: null as string | null, path: '/repo' }], - sshConnectionStates: new Map([['ssh-a', { status: 'connected' }]]), - transientClearedAgentStatusConnectionIds: {} as Record, - worktreesByRepo: { - 'repo-1': [ - { - id: 'wt-1', - repoId: 'repo-1', - projectId: 'repo-1', - path: '/repo/worktree', - displayName: 'main' - } - ] - }, - allWorktrees: vi.fn(() => store.worktreesByRepo['repo-1']), - tabsByWorktree: { - 'wt-1': [{ id: 'tab-1' }] - }, - openFiles: [] as { id: string; worktreeId: string }[], - browserTabsByWorktree: {} as Record, - tabBarOrderByWorktree: {} as Record, - terminalLayoutsByTabId: {} as Record< - string, - { activeLeafId: string | null; ptyIdsByLeafId?: Record } - >, - ptyIdsByTabId: {} as Record, - createTab: mockCreateTab, - closeTab: vi.fn(), - queueTabStartupCommand: mockQueueTabStartupCommand, - setActiveTabType: mockSetActiveTabType, - setTabViewMode: mockSetTabViewMode, - setTabBarOrder: mockSetTabBarOrder, - setAgentStatus: mockSetAgentStatus, - seedNativeChatLaunchPrompt: mockSeedNativeChatLaunchPrompt, - seedNativeChatLaunchDraft: mockSeedNativeChatLaunchDraft, - markNativeChatLaunchPromptFailed: mockMarkNativeChatLaunchPromptFailed -} +import { + LEAF_ID, + mockCreateTab, + mockCreateWebRuntimeAgentSessionTerminal, + mockCreateWebRuntimeAgentSessionTerminalWithLaunchDraft, + mockCreateWebRuntimeSessionTerminal, + mockIsWebRuntimeSessionActive, + mockPasteDraftWhenAgentReady, + mockQueueTabStartupCommand, + mockSeedNativeChatLaunchDraft, + mockSeedNativeChatLaunchPrompt, + mockSetAgentStatus, + mockToastError, + mockToastMessage, + mockTrack, + resetLaunchAgentInNewTabHarness, + store +} from './launch-agent-in-new-tab-test-harness' vi.mock('@/store', () => ({ useAppStore: { @@ -96,8 +25,6 @@ vi.mock('@/store', () => ({ } })) -const mockToastError = vi.fn() - vi.mock('sonner', () => ({ toast: { message: mockToastMessage, error: mockToastError } })) @@ -116,20 +43,11 @@ vi.mock('@/lib/agent-paste-draft', () => ({ pasteDraftWhenAgentReady: mockPasteDraftWhenAgentReady })) -vi.mock('@/lib/agent-ready-wait', () => ({ - waitForAgentReady: mockWaitForAgentReady -})) - vi.mock('@/lib/telemetry', () => ({ track: mockTrack, tuiAgentToAgentKind: (agent: string) => agent })) -const mockCreateWebRuntimeSessionTerminal = vi.fn() -const mockCreateWebRuntimeAgentSessionTerminal = vi.fn() -const mockCreateWebRuntimeAgentSessionTerminalWithLaunchDraft = vi.fn() -const mockIsWebRuntimeSessionActive = vi.fn(() => false) - vi.mock('@/runtime/web-runtime-session', () => ({ createWebRuntimeSessionTerminal: mockCreateWebRuntimeSessionTerminal, createWebRuntimeAgentSessionTerminal: mockCreateWebRuntimeAgentSessionTerminal, @@ -141,51 +59,7 @@ vi.mock('@/runtime/web-runtime-session', () => ({ describe('launchAgentInNewTab', () => { beforeEach(() => { - vi.clearAllMocks() - mockIsWebRuntimeSessionActive.mockReturnValue(false) - mockCreateWebRuntimeSessionTerminal.mockResolvedValue({ status: 'created' }) - mockCreateWebRuntimeAgentSessionTerminal.mockResolvedValue({ - outcome: { status: 'created' }, - promptDelivered: true - }) - mockCreateWebRuntimeAgentSessionTerminalWithLaunchDraft.mockResolvedValue({ status: 'created' }) - store.activeRepoId = 'repo-1' - store.activeWorktreeId = 'wt-1' - store.settings = { - agentCmdOverrides: {}, - agentDefaultArgs: {}, - agentDefaultEnv: {}, - activeRuntimeEnvironmentId: null - } - store.projects = [ - { - id: 'repo-1', - localWindowsRuntimePreference: { kind: 'inherit-global' } - } - ] - store.repos = [{ id: 'repo-1', connectionId: null, path: '/repo' }] - store.sshConnectionStates = new Map([['ssh-a', { status: 'connected' }]]) - store.transientClearedAgentStatusConnectionIds = {} - store.worktreesByRepo = { - 'repo-1': [ - { - id: 'wt-1', - repoId: 'repo-1', - projectId: 'repo-1', - path: '/repo/worktree', - displayName: 'main' - } - ] - } - store.tabsByWorktree = { 'wt-1': [{ id: 'tab-1' }] } - store.openFiles = [] - store.browserTabsByWorktree = {} - store.tabBarOrderByWorktree = {} - store.terminalLayoutsByTabId = {} - store.ptyIdsByTabId = {} - mockCreateTab.mockReturnValue({ id: 'tab-1' }) - mockPasteDraftWhenAgentReady.mockResolvedValue(true) - mockWaitForAgentReady.mockResolvedValue({ ready: true, reason: 'foreground-match' }) + resetLaunchAgentInNewTabHarness() }) it('stamps the launched agent on the new tab for immediate provider icon bootstrap', async () => { @@ -200,40 +74,14 @@ describe('launchAgentInNewTab', () => { launchAgent: 'codex' }) }) - it('keeps Floating Workspace authority on native Windows beside an active WSL project', async () => { - store.projects = [ - { - id: 'repo-1', - localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } - } - ] - const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') - const result = launchAgentInNewTab({ - agent: 'codex', - worktreeId: FLOATING_TERMINAL_WORKTREE_ID, - launchPlatform: 'win32' - }) - - expect(result).not.toBeNull() - expect(mockIsWebRuntimeSessionActive).toHaveBeenLastCalledWith(null) - expect(mockCreateWebRuntimeSessionTerminal).not.toHaveBeenCalled() - expect(mockCreateTab).toHaveBeenCalledWith( - FLOATING_TERMINAL_WORKTREE_ID, - undefined, - undefined, - { launchAgent: 'codex' } - ) - }) - - it('keeps prompted Codex launches on the ordinary terminal path', async () => { + it('opens supported submit-after-ready launches in chat and seeds a launch prompt echo', async () => { store.settings = { agentCmdOverrides: {}, agentDefaultArgs: {}, agentDefaultEnv: {}, activeRuntimeEnvironmentId: null, experimentalNativeChat: true, - experimentalStructuredNativeChat: true, openAgentTabsInChatByDefault: true } const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') @@ -261,7 +109,6 @@ describe('launchAgentInNewTab', () => { text: 'large generated prompt', createdAt: expect.any(Number) }) - expect(mockSetTabViewMode).not.toHaveBeenCalled() }) it('opens local Grok submit-after-ready launches in native chat', async () => { @@ -271,7 +118,6 @@ describe('launchAgentInNewTab', () => { agentDefaultEnv: {}, activeRuntimeEnvironmentId: null, experimentalNativeChat: true, - experimentalStructuredNativeChat: true, openAgentTabsInChatByDefault: true } const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') @@ -303,7 +149,6 @@ describe('launchAgentInNewTab', () => { agentDefaultEnv: {}, activeRuntimeEnvironmentId: null, experimentalNativeChat: true, - experimentalStructuredNativeChat: true, openAgentTabsInChatByDefault: true } store.repos = [{ id: 'repo-1', connectionId: 'ssh-target-1', path: '/repo' }] @@ -324,7 +169,6 @@ describe('launchAgentInNewTab', () => { agentDefaultEnv: {}, activeRuntimeEnvironmentId: null, experimentalNativeChat: true, - experimentalStructuredNativeChat: true, openAgentTabsInChatByDefault: true } const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') @@ -336,7 +180,8 @@ describe('launchAgentInNewTab', () => { promptDelivery: 'draft' }) - // Claude's --prefill launch seeds the draft without a paste callback. + // Claude takes the draft on --prefill, so no paste runs and + // deliverLaunchPromptToAgentTab never fires — this is the only seed. expect(result?.pasteDraftAfterLaunch).toBe(false) expect(mockSeedNativeChatLaunchDraft).toHaveBeenCalledWith( expect.objectContaining({ @@ -345,7 +190,12 @@ describe('launchAgentInNewTab', () => { text: 'https://github.com/o/r/issues/12' }) ) - expect(mockCreateTab.mock.calls[0]?.[3]).toHaveProperty('viewMode', 'chat') + expect(mockCreateTab).toHaveBeenCalledWith( + 'wt-1', + undefined, + undefined, + expect.objectContaining({ viewMode: 'chat' }) + ) }) it('mirrors a multi-line draft into chat and opens the tab there', async () => { @@ -355,7 +205,6 @@ describe('launchAgentInNewTab', () => { agentDefaultEnv: {}, activeRuntimeEnvironmentId: null, experimentalNativeChat: true, - experimentalStructuredNativeChat: true, openAgentTabsInChatByDefault: true } const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') @@ -371,7 +220,12 @@ describe('launchAgentInNewTab', () => { expect(mockSeedNativeChatLaunchDraft).toHaveBeenCalledWith( expect.objectContaining({ tabId: 'tab-1', agent: 'claude', text: prompt }) ) - expect(mockCreateTab.mock.calls[0]?.[3]).toHaveProperty('viewMode', 'chat') + expect(mockCreateTab).toHaveBeenCalledWith( + 'wt-1', + undefined, + undefined, + expect.objectContaining({ viewMode: 'chat' }) + ) }) it('passes quick command labels only to locally-created agent tabs', async () => { @@ -389,396 +243,6 @@ describe('launchAgentInNewTab', () => { }) }) -<<<<<<< HEAD - it('does not inject native-chat model preferences into terminal Quick Commands', async () => { - store.settings = { - agentCmdOverrides: {}, - agentDefaultArgs: { codex: '--profile team' }, - agentDefaultEnv: {}, - activeRuntimeEnvironmentId: null, - experimentalNativeChat: true, - experimentalStructuredNativeChat: true, - openAgentTabsInChatByDefault: false, - nativeChatSessionOptions: { - codex: { - model: 'gpt-5.2-codex', - valuesByModel: { 'gpt-5.2-codex': { effort: 'medium' } } - } - } - } - const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') - - launchAgentInNewTab({ - agent: 'codex', - worktreeId: 'wt-1', - prompt: 'Review this diff', - launchSource: 'quick_command', - quickCommandLabel: 'Review' - }) - - const launch = mockQueueTabStartupCommand.mock.calls[0]?.[1] - expect(launch.command).toContain("'--profile' 'team'") - expect(launch.command).not.toContain("'-m'") - expect(launch.command).not.toContain('model_reasoning_effort=') - expect(launch.sessionOptions).toBeUndefined() - }) - - it('applies native-chat model preferences to Quick Commands opened in chat', async () => { - store.settings = { - agentCmdOverrides: {}, - agentDefaultArgs: {}, - agentDefaultEnv: {}, - activeRuntimeEnvironmentId: null, - experimentalNativeChat: true, - experimentalStructuredNativeChat: true, - openAgentTabsInChatByDefault: true, - nativeChatSessionOptions: { - codex: { - model: 'gpt-5.2-codex', - valuesByModel: { 'gpt-5.2-codex': { effort: 'medium' } } - } - } - } - const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') - - launchAgentInNewTab({ - agent: 'codex', - worktreeId: 'wt-1', - prompt: 'Review this diff', - launchSource: 'quick_command', - quickCommandLabel: 'Review' - }) - - const launch = mockQueueTabStartupCommand.mock.calls[0]?.[1] - expect(launch.command).toContain("'-m' 'gpt-5.2-codex'") - expect(launch.command).toContain("'-c' 'model_reasoning_effort=medium'") - expect(launch.sessionOptions).toEqual({ model: 'gpt-5.2-codex', effort: 'medium' }) - expect(mockCreateTab).toHaveBeenCalledWith( - 'wt-1', - undefined, - undefined, - expect.objectContaining({ viewMode: 'chat' }) - ) - expect(mockSetTabViewMode).not.toHaveBeenCalled() - }) - - it('preserves paired-host draft delivery and supported launch preferences', async () => { -||||||| parent of ebaa81ab2f (Rebase custom-agents onto main (2/4): renderer) - it('does not inject native-chat model preferences into terminal Quick Commands', async () => { - store.settings = { - agentCmdOverrides: {}, - agentDefaultArgs: { codex: '--profile team' }, - agentDefaultEnv: {}, - activeRuntimeEnvironmentId: null, - experimentalNativeChat: true, - openAgentTabsInChatByDefault: false, - nativeChatSessionOptions: { - codex: { - model: 'gpt-5.2-codex', - valuesByModel: { 'gpt-5.2-codex': { effort: 'medium' } } - } - } - } - const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') - - launchAgentInNewTab({ - agent: 'codex', - worktreeId: 'wt-1', - prompt: 'Review this diff', - launchSource: 'quick_command', - quickCommandLabel: 'Review' - }) - - const launch = mockQueueTabStartupCommand.mock.calls[0]?.[1] - expect(launch.command).toContain("'--profile' 'team'") - expect(launch.command).not.toContain("'-m'") - expect(launch.command).not.toContain('model_reasoning_effort=') - expect(launch.sessionOptions).toBeUndefined() - }) - - it('applies native-chat model preferences to Quick Commands opened in chat', async () => { - store.settings = { - agentCmdOverrides: {}, - agentDefaultArgs: {}, - agentDefaultEnv: {}, - activeRuntimeEnvironmentId: null, - experimentalNativeChat: true, - openAgentTabsInChatByDefault: true, - nativeChatSessionOptions: { - codex: { - model: 'gpt-5.2-codex', - valuesByModel: { 'gpt-5.2-codex': { effort: 'medium' } } - } - } - } - const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') - - launchAgentInNewTab({ - agent: 'codex', - worktreeId: 'wt-1', - prompt: 'Review this diff', - launchSource: 'quick_command', - quickCommandLabel: 'Review' - }) - - const launch = mockQueueTabStartupCommand.mock.calls[0]?.[1] - expect(launch.command).toContain("'-m' 'gpt-5.2-codex'") - expect(launch.command).toContain("'-c' 'model_reasoning_effort=medium'") - expect(launch.sessionOptions).toEqual({ model: 'gpt-5.2-codex', effort: 'medium' }) - expect(mockCreateTab).toHaveBeenCalledWith( - 'wt-1', - undefined, - undefined, - expect.objectContaining({ viewMode: 'chat' }) - ) - }) - - it('preserves paired-host draft delivery and supported launch preferences', async () => { -======= - it('delegates agent quick launch to the host runtime in paired web clients', async () => { ->>>>>>> ebaa81ab2f (Rebase custom-agents onto main (2/4): renderer) - mockIsWebRuntimeSessionActive.mockReturnValue(true) - store.settings = { - agentCmdOverrides: {}, - agentDefaultArgs: {}, - agentDefaultEnv: {}, -<<<<<<< HEAD - activeRuntimeEnvironmentId: 'web-runtime', - experimentalNativeChat: true, - experimentalStructuredNativeChat: true, - openAgentTabsInChatByDefault: true, - nativeChatSessionOptions: { - claude: { - model: 'opus', - valuesByModel: { opus: { effort: 'high', fastMode: true } } - } - } -||||||| parent of ebaa81ab2f (Rebase custom-agents onto main (2/4): renderer) - activeRuntimeEnvironmentId: 'web-runtime', - experimentalNativeChat: true, - openAgentTabsInChatByDefault: true, - nativeChatSessionOptions: { - claude: { - model: 'opus', - valuesByModel: { opus: { effort: 'high', fastMode: true } } - } - } -======= - activeRuntimeEnvironmentId: 'web-runtime' - } - store.tabsByWorktree = { - 'wt-1': [ - { id: 'tab-1' }, - { id: 'stale-agent-tab', launchAgent: 'claude' } as { id: string; launchAgent: string } - ] - } - const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') - - const result = launchAgentInNewTab({ - agent: 'claude', - worktreeId: 'wt-1', - groupId: 'group-1' - }) - - expect(result).toEqual( - expect.objectContaining({ - tabId: null, - pasteDraftAfterLaunch: false - }) - ) - // Paired web launches route through the same host `agentLaunch` boundary as - // the local path — identity + launch policy only, never a client command. - expect(mockCreateWebRuntimeSessionTerminal).toHaveBeenCalledWith({ - worktreeId: 'wt-1', - environmentId: 'web-runtime', - targetGroupId: 'group-1', - activate: true, - agentLaunch: { - selection: { kind: 'agent', agent: 'claude' }, - allowEmptyPromptLaunch: true - }, - viewMode: 'terminal' - }) - expect(mockCreateTab).not.toHaveBeenCalled() - expect(mockQueueTabStartupCommand).not.toHaveBeenCalled() - await Promise.resolve() - expect(mockSetActiveTabType).toHaveBeenCalledWith('terminal') - expect(store.closeTab).toHaveBeenCalledWith('stale-agent-tab', { - reason: 'cleanup' - }) - }) - - it('mirrors a paired-host draft while keeping launch assembly host-owned', async () => { - mockIsWebRuntimeSessionActive.mockReturnValue(true) - store.settings = { - agentCmdOverrides: {}, - agentDefaultArgs: {}, - agentDefaultEnv: {}, - activeRuntimeEnvironmentId: 'web-runtime' ->>>>>>> ebaa81ab2f (Rebase custom-agents onto main (2/4): renderer) - } - const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') - - const result = launchAgentInNewTab({ - agent: 'claude', - worktreeId: 'wt-1', - prompt: 'review before sending', - promptDelivery: 'draft' - }) - - expect(result).toEqual(expect.objectContaining({ tabId: null, pasteDraftAfterLaunch: false })) -<<<<<<< HEAD - expect(mockCreateWebRuntimeAgentSessionTerminalWithLaunchDraft).toHaveBeenCalledWith( - expect.objectContaining({ - launchAgent: 'claude', -||||||| parent of ebaa81ab2f (Rebase custom-agents onto main (2/4): renderer) - // The draft rides in on the launch command, so this host-class launch also - // carries the text that seeds the mirrored tab's chat composer. - expect(mockCreateWebRuntimeAgentSessionTerminalWithLaunchDraft).toHaveBeenCalledWith( - expect.objectContaining({ - launchAgent: 'claude', -======= - expect(mockCreateWebRuntimeAgentSessionTerminalWithLaunchDraft).toHaveBeenCalledWith({ - worktreeId: 'wt-1', - environmentId: 'web-runtime', - targetGroupId: undefined, - activate: true, - agentLaunch: { - selection: { kind: 'agent', agent: 'claude' }, ->>>>>>> ebaa81ab2f (Rebase custom-agents onto main (2/4): renderer) - prompt: 'review before sending', - promptDelivery: 'draft' - }, - viewMode: 'terminal', - agent: 'claude', - launchDraft: 'review before sending' - }) - expect(mockCreateWebRuntimeSessionTerminal).not.toHaveBeenCalled() - expect(mockCreateTab).not.toHaveBeenCalled() - }) - - it('forwards a prompt launch to paired web runtime hosts through agentLaunch only', async () => { - mockIsWebRuntimeSessionActive.mockReturnValue(true) - store.settings = { - agentCmdOverrides: {}, - agentDefaultArgs: { codex: '--model gpt-5 --reasoning-effort high' }, - agentDefaultEnv: { codex: { CODEX_PROFILE: 'captured' } }, - activeRuntimeEnvironmentId: 'web-runtime' - } - const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') - - const result = launchAgentInNewTab({ - agent: 'codex', - worktreeId: 'wt-1', - prompt: 'fix the spinner', - groupId: 'group-1' - }) - - expect(result).toEqual( - expect.objectContaining({ - tabId: null, - pasteDraftAfterLaunch: false - }) - ) - // The host owns argv/env/config assembly (including captured agentDefaultArgs - // /agentDefaultEnv); the request carries identity + folded prompt only. - expect(mockCreateWebRuntimeSessionTerminal).toHaveBeenCalledWith({ - worktreeId: 'wt-1', - environmentId: 'web-runtime', - targetGroupId: 'group-1', - activate: true, - agentLaunch: { - selection: { kind: 'agent', agent: 'codex' }, - prompt: 'fix the spinner' - }, - viewMode: 'terminal' - }) - expect(mockCreateTab).not.toHaveBeenCalled() - expect(mockQueueTabStartupCommand).not.toHaveBeenCalled() - }) - - it('propagates the default chat mode to paired web runtime launches', async () => { - mockIsWebRuntimeSessionActive.mockReturnValue(true) - store.settings = { - agentCmdOverrides: {}, - agentDefaultArgs: {}, - agentDefaultEnv: {}, - activeRuntimeEnvironmentId: 'web-runtime', - experimentalNativeChat: true, - experimentalStructuredNativeChat: true, - openAgentTabsInChatByDefault: true - } - const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') - - launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' }) - - expect(mockCreateWebRuntimeSessionTerminal).toHaveBeenCalledWith( - expect.objectContaining({ - worktreeId: 'wt-1', - environmentId: 'web-runtime', - agentLaunch: { - selection: { kind: 'agent', agent: 'codex' }, - allowEmptyPromptLaunch: true - }, - viewMode: 'chat' - }) - ) - }) - - it('propagates the resolved terminal mode to paired web runtime launches', async () => { - mockIsWebRuntimeSessionActive.mockReturnValue(true) - store.settings = { - agentCmdOverrides: {}, - agentDefaultArgs: {}, - agentDefaultEnv: {}, - activeRuntimeEnvironmentId: 'web-runtime', - experimentalNativeChat: true, - experimentalStructuredNativeChat: true, - openAgentTabsInChatByDefault: false - } - const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') - - launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' }) - - expect(mockCreateWebRuntimeSessionTerminal).toHaveBeenCalledWith( - expect.objectContaining({ - worktreeId: 'wt-1', - environmentId: 'web-runtime', - agentLaunch: { - selection: { kind: 'agent', agent: 'codex' }, - allowEmptyPromptLaunch: true - }, - viewMode: 'terminal' - }) - ) - }) - - it('surfaces a toast when host agent launch fails in paired web clients', async () => { - mockIsWebRuntimeSessionActive.mockReturnValue(true) - mockCreateWebRuntimeSessionTerminal.mockResolvedValue({ - status: 'failed', - message: 'Upgrade the remote Orca host before starting or resuming agent sessions.' - }) - store.settings = { - agentCmdOverrides: {}, - agentDefaultArgs: {}, - agentDefaultEnv: {}, - activeRuntimeEnvironmentId: 'web-runtime' - } - const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') - - launchAgentInNewTab({ - agent: 'claude', - worktreeId: 'wt-1' - }) - - await Promise.resolve() - expect(mockToastError).toHaveBeenCalledWith( - 'Upgrade the remote Orca host before starting or resuming agent sessions.' - ) - expect(mockSetActiveTabType).not.toHaveBeenCalled() - }) - it('queues initial working status for Command Code argv prompt launches', async () => { const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') @@ -814,6 +278,8 @@ describe('launchAgentInNewTab', () => { prompt: 'fix the spinner', launchSource: 'onboarding' }) + + expect(mockTrack).not.toHaveBeenCalledWith('agent_prompt_sent', expect.anything()) }) it('does not track prompt-sent for draft launches', async () => { @@ -1079,6 +545,36 @@ describe('launchAgentInNewTab', () => { expect(queued.agentLaunch).not.toHaveProperty('agentArgs') }) + // P1-24: unsaved recipe arg edits are the only args text a client sends, and + // only ever scoped by the owner locator that names which recipe they replace. + it('threads unsaved recipe args alongside the locator', async () => { + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + launchAgentInNewTab({ + agent: 'codex', + worktreeId: 'wt-1', + sourceRecord: { owner: 'source-control-recipe', id: 'fixChecks' }, + unsavedAgentArgs: '--model sonnet' + }) + + const queued = mockQueueTabStartupCommand.mock.calls[0][1] + expect(queued.agentLaunch).toEqual({ + selection: { kind: 'agent', agent: 'codex' }, + allowEmptyPromptLaunch: true, + sourceRecord: { owner: 'source-control-recipe', id: 'fixChecks' }, + unsavedAgentArgs: '--model sonnet' + }) + }) + + it('drops unsaved recipe args when no owner locator scopes them', async () => { + const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') + + launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1', unsavedAgentArgs: '--model sonnet' }) + + const queued = mockQueueTabStartupCommand.mock.calls[0][1] + expect(queued.agentLaunch).not.toHaveProperty('unsavedAgentArgs') + }) + it('routes a bare quick launch through agentLaunch with no client command, config, or token', async () => { const { launchAgentInNewTab } = await import('./launch-agent-in-new-tab') diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts index 9a85507f74f..727cdec11e7 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -45,6 +45,10 @@ export type LaunchAgentInNewTabArgs = { * agentArgs/env the host resolves and applies to this launch. Clients send * only the locator; the host owns all command/arg assembly. */ sourceRecord?: AgentLaunchSourceRecord + /** Unsaved edits of the sourceRecord recipe's agentArgs, applied to this launch + * instead of the stored ones. Best-effort: a host predating the field launches + * the stored args, so never report these as applied. */ + unsavedAgentArgs?: string /** Optional working directory for the new terminal session. */ initialCwd?: string | null /** Force generated prompt text out of the shell launch command. `draft` @@ -98,6 +102,7 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI groupId, prompt, sourceRecord, + unsavedAgentArgs, initialCwd, promptDelivery = 'auto-submit', launchSource, @@ -182,8 +187,15 @@ export function launchAgentInNewTab(args: LaunchAgentInNewTabArgs): LaunchAgentI } : { allowEmptyPromptLaunch: true }), // Why: recipe-driven launches name the saved owner so the host resolves and - // applies its stored agentArgs (and env) itself; the client never sends args. - ...(sourceRecord ? { sourceRecord } : {}) + // applies its stored agentArgs (and env) itself. Unsaved arg edits ride along + // as text the host substitutes for that stored snapshot — never assembled argv + // — and only make sense with the owner locator that scopes them. + ...(sourceRecord + ? { + sourceRecord, + ...(unsavedAgentArgs !== undefined ? { unsavedAgentArgs } : {}) + } + : {}) } // Why: the remote host can't infer this client's draft/default view choice, so decide it here for paired tabs too. diff --git a/src/renderer/src/lib/launch-work-item-direct-agent-selection.test.ts b/src/renderer/src/lib/launch-work-item-direct-agent-selection.test.ts new file mode 100644 index 00000000000..463c3fe5216 --- /dev/null +++ b/src/renderer/src/lib/launch-work-item-direct-agent-selection.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it, vi } from 'vitest' +import type { + LocalAgentCatalogSnapshot, + LocalCustomTuiAgent +} from '../../../shared/agent-catalog-snapshot' +import type { CustomTuiAgentId } from '../../../shared/types' +import { + loadDirectLaunchAgentCatalog, + resolveDirectLaunchAgent +} from './launch-work-item-direct-agent-selection' + +const CUSTOM_CODEX = 'custom-agent:codex:11111111-1111-4111-8111-111111111111' as CustomTuiAgentId + +function readyCustom(): LocalCustomTuiAgent { + return { + status: 'ready', + definition: { + id: CUSTOM_CODEX, + baseAgent: 'codex', + label: 'Model-specific Codex', + args: '--model custom-model', + syncEnv: false, + commandOverride: '/opt/bin/codex' + }, + envSummary: { entryCount: 0, bytes: 0 }, + availabilityReason: 'configured-executable' + } +} + +function snapshot(): LocalAgentCatalogSnapshot { + return { customAgents: [readyCustom()] } as LocalAgentCatalogSnapshot +} + +describe('resolveDirectLaunchAgent', () => { + it('accepts a custom agent override that base detection never reports', () => { + expect( + resolveDirectLaunchAgent({ + agentOverride: CUSTOM_CODEX, + detectedAgents: ['claude'], + localAgentCatalog: snapshot(), + settings: { defaultTuiAgent: 'claude', disabledTuiAgents: [] } + }) + ).toEqual({ requestedAgent: CUSTOM_CODEX, agentOverrideUnavailable: false }) + }) + + it('launches a custom default instead of falling back to a built-in', () => { + expect( + resolveDirectLaunchAgent({ + detectedAgents: ['claude', 'codex'], + localAgentCatalog: snapshot(), + settings: { defaultTuiAgent: CUSTOM_CODEX, disabledTuiAgents: [] } + }) + ).toEqual({ requestedAgent: CUSTOM_CODEX, agentOverrideUnavailable: false }) + }) + + it('reports a disabled custom override as unavailable rather than launching another agent', () => { + expect( + resolveDirectLaunchAgent({ + agentOverride: CUSTOM_CODEX, + detectedAgents: ['claude'], + localAgentCatalog: snapshot(), + settings: { defaultTuiAgent: 'claude', disabledTuiAgents: [CUSTOM_CODEX] } + }) + ).toEqual({ requestedAgent: null, agentOverrideUnavailable: true }) + }) + + it('keeps built-in detection gating for built-in overrides and defaults', () => { + expect( + resolveDirectLaunchAgent({ + agentOverride: 'cursor', + detectedAgents: ['claude'], + localAgentCatalog: null, + settings: { defaultTuiAgent: 'claude', disabledTuiAgents: [] } + }) + ).toEqual({ requestedAgent: null, agentOverrideUnavailable: true }) + expect( + resolveDirectLaunchAgent({ + detectedAgents: ['claude'], + localAgentCatalog: null, + settings: { defaultTuiAgent: 'cursor', disabledTuiAgents: [] } + }) + ).toEqual({ requestedAgent: 'claude', agentOverrideUnavailable: false }) + }) +}) + +describe('loadDirectLaunchAgentCatalog', () => { + it('degrades to the built-in catalog when the local surface is unavailable', async () => { + vi.stubGlobal('window', { + api: { settings: { agentCatalog: { getLocal: vi.fn().mockRejectedValue(new Error('web')) } } } + }) + await expect(loadDirectLaunchAgentCatalog()).resolves.toBeNull() + + vi.stubGlobal('window', {}) + await expect(loadDirectLaunchAgentCatalog()).resolves.toBeNull() + vi.unstubAllGlobals() + }) +}) diff --git a/src/renderer/src/lib/launch-work-item-direct-agent-selection.ts b/src/renderer/src/lib/launch-work-item-direct-agent-selection.ts new file mode 100644 index 00000000000..ccf27fba2f1 --- /dev/null +++ b/src/renderer/src/lib/launch-work-item-direct-agent-selection.ts @@ -0,0 +1,61 @@ +import { buildWorkspaceAgentOptions } from '@/lib/workspace-agent-options' +import { DEFAULT_DISABLED_TUI_AGENTS, pickTuiAgent } from '../../../shared/tui-agent-selection' +import type { LocalAgentCatalogSnapshot } from '../../../shared/agent-catalog-snapshot' +import type { GlobalSettings, TuiAgent } from '../../../shared/types' + +type DirectLaunchAgentSettings = + | Partial> + | null + | undefined + +/** Desktop-local custom-agent catalog for a direct launch. Paired web has no + * local catalog surface, so a rejection degrades to the built-in catalog. */ +export async function loadDirectLaunchAgentCatalog(): Promise { + try { + return await window.api.settings.agentCatalog.getLocal() + } catch { + return null + } +} + +/** + * Resolve the agent identity a direct "Use" launch requests, validated against + * the full catalog (built-ins plus ready custom agents) exactly like the + * composer picker. Custom ids never appear in base detection, so gating on + * detection alone rejects a custom override and silently downgrades a custom + * default to a built-in. + */ +export function resolveDirectLaunchAgent({ + agentOverride, + detectedAgents, + localAgentCatalog, + settings +}: { + agentOverride?: TuiAgent + detectedAgents: readonly TuiAgent[] + localAgentCatalog: LocalAgentCatalogSnapshot | null + settings: DirectLaunchAgentSettings +}): { requestedAgent: TuiAgent | null; agentOverrideUnavailable: boolean } { + const selectableAgentIds = new Set( + buildWorkspaceAgentOptions({ + detectedAgentIds: new Set(detectedAgents), + disabledTuiAgents: settings?.disabledTuiAgents ?? DEFAULT_DISABLED_TUI_AGENTS, + localAgentCatalog + }).map((entry) => entry.id) + ) + if (agentOverride) { + const overrideUsable = selectableAgentIds.has(agentOverride) + return { + requestedAgent: overrideUsable ? agentOverride : null, + agentOverrideUnavailable: !overrideUsable + } + } + return { + requestedAgent: pickTuiAgent( + settings?.defaultTuiAgent, + selectableAgentIds, + settings?.disabledTuiAgents + ), + agentOverrideUnavailable: false + } +} diff --git a/src/renderer/src/lib/launch-work-item-direct.test.ts b/src/renderer/src/lib/launch-work-item-direct.test.ts index abd5b05d40f..03a4608096e 100644 --- a/src/renderer/src/lib/launch-work-item-direct.test.ts +++ b/src/renderer/src/lib/launch-work-item-direct.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { AppState } from '@/store' +import type { CustomTuiAgentId } from '../../../shared/types' import type * as TuiAgentSelectionModule from '../../../shared/tui-agent-selection' import type * as TuiAgentStartupModule from '@/lib/tui-agent-startup' @@ -667,6 +668,76 @@ describe('launchWorkItemDirect', () => { expect(mocks.activateAndRevealWorktree.mock.calls.at(-1)?.[1]).not.toHaveProperty('startup') }) + it('launches a custom default and a custom override that base detection never reports', async () => { + const customCodex = + 'custom-agent:codex:11111111-1111-4111-8111-111111111111' as CustomTuiAgentId + globalThis.window = { + api: { + ...mockApi, + settings: { + agentCatalog: { + getLocal: vi.fn().mockResolvedValue({ + customAgents: [ + { + status: 'ready', + definition: { + id: customCodex, + baseAgent: 'codex', + label: 'Model-specific Codex', + args: '--model custom-model', + syncEnv: false, + commandOverride: '/opt/bin/codex' + }, + envSummary: { entryCount: 0, bytes: 0 }, + availabilityReason: 'configured-executable' + } + ] + }) + } + } + } + } as unknown as Window & typeof globalThis + mocks.store.settings = { + defaultTuiAgent: customCodex, + disabledTuiAgents: [], + agentCmdOverrides: {} + } as unknown as AppState['settings'] + const { launchWorkItemDirect } = await import('./launch-work-item-direct') + const item = { + title: 'Fix failing checks', + url: 'https://github.com/acme/repo/pull/1', + type: 'issue' as const, + number: 1, + pasteContent: 'Fix the failing checks.' + } + + await expect( + launchWorkItemDirect({ + item, + repoId: 'repo-1', + openModalFallback: mocks.openModalFallback, + launchSource: 'task_page' + }) + ).resolves.toBe(true) + expect(mocks.createWorktree.mock.calls[0]?.[25]).toMatchObject({ + agentLaunch: { selection: { kind: 'agent', agent: customCodex } } + }) + + await expect( + launchWorkItemDirect({ + item, + repoId: 'repo-1', + openModalFallback: mocks.openModalFallback, + launchSource: 'task_page', + agentOverride: customCodex + }) + ).resolves.toBe(true) + expect(mocks.createWorktree.mock.calls[1]?.[25]).toMatchObject({ + agentLaunch: { selection: { kind: 'agent', agent: customCodex } } + }) + expect(mocks.toastError).not.toHaveBeenCalled() + }) + it('plans direct local Windows-path launches with POSIX startup for WSL project runtime', async () => { mocks.store.repos = [ { diff --git a/src/renderer/src/runtime/agent-catalog-revision-event.ts b/src/renderer/src/runtime/agent-catalog-revision-event.ts new file mode 100644 index 00000000000..684421405d1 --- /dev/null +++ b/src/renderer/src/runtime/agent-catalog-revision-event.ts @@ -0,0 +1,23 @@ +// Why: the runtime client-event stream is owned by renderer hooks, but the paired-web +// catalog cache lives in the preload shim, which renderer code must never import (it +// would pull the browser shim into the desktop bundle). A window event bridges them; +// on desktop nothing subscribes and the announcement is inert. + +export const AGENT_CATALOG_REVISION_EVENT = 'orca:agent-catalog-revision' + +export function emitAgentCatalogRevision(revision: number): void { + window.dispatchEvent(new CustomEvent(AGENT_CATALOG_REVISION_EVENT, { detail: { revision } })) +} + +export function subscribeAgentCatalogRevision(onRevision: (revision: number) => void): () => void { + const listener = (event: Event): void => { + const revision = (event as CustomEvent<{ revision?: unknown }>).detail?.revision + if (typeof revision === 'number') { + onRevision(revision) + } + } + window.addEventListener(AGENT_CATALOG_REVISION_EVENT, listener) + return () => { + window.removeEventListener(AGENT_CATALOG_REVISION_EVENT, listener) + } +} diff --git a/src/renderer/src/runtime/agent-launch-identity-negotiation.test.ts b/src/renderer/src/runtime/agent-launch-identity-negotiation.test.ts new file mode 100644 index 00000000000..87e54f37018 --- /dev/null +++ b/src/renderer/src/runtime/agent-launch-identity-negotiation.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it, vi } from 'vitest' +import { RUNTIME_COMPAT_BLOCK_CODE } from './runtime-protocol-compat' +import { + AGENT_LAUNCH_IDENTITY_UNSUPPORTED_MESSAGE, + negotiateAgentLaunchIdentityArm +} from './agent-launch-identity-negotiation' + +const mocks = vi.hoisted(() => ({ supportsCapability: vi.fn() })) + +vi.mock('./runtime-rpc-client', () => ({ + runtimeEnvironmentSupportsCapability: mocks.supportsCapability +})) + +function compatBlockError(): Error { + const error = new Error('Update Orca on the server.') + ;(error as { code?: string }).code = RUNTIME_COMPAT_BLOCK_CODE + return error +} + +describe('negotiateAgentLaunchIdentityArm', () => { + it('sends the identity arm to a capable runtime', async () => { + mocks.supportsCapability.mockResolvedValueOnce(true) + await expect(negotiateAgentLaunchIdentityArm('env-1', false)).resolves.toBe('identity') + expect(mocks.supportsCapability).toHaveBeenCalledWith('env-1', 'agent-launch.identity.v1') + }) + + // A pre-identity host strips the arm and spawns a bare login shell it still + // answers as a created terminal, so callers with nothing to degrade to must fail. + it('refuses when the runtime is unsupported and no client command exists', async () => { + mocks.supportsCapability.mockResolvedValueOnce(false) + await expect(negotiateAgentLaunchIdentityArm('env-1', false)).rejects.toThrow( + AGENT_LAUNCH_IDENTITY_UNSUPPORTED_MESSAGE + ) + }) + + it('degrades to the legacy command when the runtime is unsupported', async () => { + mocks.supportsCapability.mockResolvedValueOnce(false) + await expect(negotiateAgentLaunchIdentityArm('env-1', true)).resolves.toBe('legacy') + }) + + it('degrades to the legacy command when the probe fails transiently', async () => { + mocks.supportsCapability.mockRejectedValueOnce(new Error('offline')) + await expect(negotiateAgentLaunchIdentityArm('env-1', true)).resolves.toBe('legacy') + }) + + it('rethrows a transient probe failure when there is no command to degrade to', async () => { + mocks.supportsCapability.mockRejectedValueOnce(new Error('offline')) + await expect(negotiateAgentLaunchIdentityArm('env-1', false)).rejects.toThrow('offline') + }) + + it('keeps a version block a version block even with a legacy command', async () => { + mocks.supportsCapability.mockRejectedValueOnce(compatBlockError()) + await expect(negotiateAgentLaunchIdentityArm('env-1', true)).rejects.toMatchObject({ + code: RUNTIME_COMPAT_BLOCK_CODE + }) + }) +}) diff --git a/src/renderer/src/runtime/agent-launch-identity-negotiation.ts b/src/renderer/src/runtime/agent-launch-identity-negotiation.ts index e3881a60caa..64e9d9efb47 100644 --- a/src/renderer/src/runtime/agent-launch-identity-negotiation.ts +++ b/src/renderer/src/runtime/agent-launch-identity-negotiation.ts @@ -6,6 +6,7 @@ // refuse. Plain English, like the other host-version errors in protocol-compat. import { AGENT_LAUNCH_IDENTITY_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' +import { isRuntimeCompatBlockError } from './runtime-protocol-compat' import { runtimeEnvironmentSupportsCapability } from './runtime-rpc-client' export const AGENT_LAUNCH_IDENTITY_UNSUPPORTED_MESSAGE = @@ -23,3 +24,37 @@ export async function assertRuntimeSupportsAgentLaunchIdentity( throw new Error(AGENT_LAUNCH_IDENTITY_UNSUPPORTED_MESSAGE) } } + +export type AgentLaunchIdentityArm = 'identity' | 'legacy' + +/** + * Negotiates before anything is spawned. Answers 'legacy' only when the caller + * kept a client-assembled command to degrade to; otherwise refuses, so an + * unsupported host can never answer with a bare shell reported as a launch. + */ +export async function negotiateAgentLaunchIdentityArm( + environmentId: string, + hasLegacyCommand: boolean +): Promise { + let supported: boolean + try { + supported = await runtimeEnvironmentSupportsCapability( + environmentId, + AGENT_LAUNCH_IDENTITY_RUNTIME_CAPABILITY + ) + } catch (error) { + // A failed read-only probe spawned nothing; only the legacy command is safe + // to fall back to, and a version block must stay a version block. + if (isRuntimeCompatBlockError(error) || !hasLegacyCommand) { + throw error + } + return 'legacy' + } + if (supported) { + return 'identity' + } + if (!hasLegacyCommand) { + throw new Error(AGENT_LAUNCH_IDENTITY_UNSUPPORTED_MESSAGE) + } + return 'legacy' +} diff --git a/src/renderer/src/runtime/web-runtime-session.test.ts b/src/renderer/src/runtime/web-runtime-session.test.ts index 1a8ac904d93..e9fa571aa3d 100644 --- a/src/renderer/src/runtime/web-runtime-session.test.ts +++ b/src/renderer/src/runtime/web-runtime-session.test.ts @@ -1,18 +1,44 @@ +/* eslint-disable max-lines */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' import { activateWebRuntimeSessionWorktree, - refreshWebRuntimeSessionTabsSnapshot + activateWebRuntimeSessionTab, + closeWebRuntimeTerminal, + closeWebRuntimeSessionTab, + consumePendingWebRuntimeSplitMirrorTelemetry, + createWebRuntimeSessionBrowserTab, + createWebRuntimeAgentSessionTerminal, + createWebRuntimeAgentSessionTerminalWithLaunchDraft, + createWebRuntimeSessionTerminal, + isWebRuntimeSessionActive, + moveWebRuntimeSessionTab, + refreshWebRuntimeSessionTabsSnapshot, + setWebRuntimeTabProps, + splitWebRuntimeTerminal } from './web-runtime-session' +import { + peekWebSessionFocusIntent, + resetWebSessionFocusIntentForTests +} from './web-session-focus-intent' +import { + isWebSessionCloseIntentPending, + recordWebSessionCloseIntent, + resetWebSessionCloseIntentForTests +} from './web-session-close-intent' import { clearRuntimeCompatibilityCacheForTests } from './runtime-rpc-client' +import { AGENT_LAUNCH_IDENTITY_UNSUPPORTED_MESSAGE } from './agent-launch-identity-negotiation' import { confirmWebAgentSessionHandoffAfterCreate, isWebAgentSessionHandoffPostCreateSnapshotConfirmed, recordWebAgentSessionHandoff, resetWebAgentSessionHandoffsForTests } from './web-agent-session-handoff' -import { resetWebSessionCloseIntentForTests } from './web-session-close-intent' -import { ENVIRONMENT_ID, WORKTREE_ID, makeSnapshot } from './web-runtime-session-test-harness' -import { replaceRuntimeEnvironmentRevisions } from './runtime-environment-revision' +import { toRuntimeExecutionHostId } from '../../../shared/execution-host' +import { + isWebSessionBrowserPlacementGroupReserved, + resetWebSessionBrowserPlacementsForTests +} from './web-session-browser-placement' const mocks = vi.hoisted(() => ({ getState: vi.fn(), @@ -27,16 +53,11 @@ const mocks = vi.hoisted(() => ({ applyWebSessionTabsSnapshot: vi.fn(), decideWebSessionTabsSnapshot: vi.fn(() => ({ apply: true, settlesHostMirror: true })), acceptReplayedWebSessionTabsSnapshot: vi.fn(), - getWebSessionTabsTrackingGeneration: vi.fn(() => 0), resolveHostSessionTabIdForWebSessionTab: vi.fn(), trackTerminalPaneSplit: vi.fn(), deliverLaunchPromptToAgentTab: vi.fn(), seedNativeChatLaunchDraftForAgentTab: vi.fn(), - getRuntimeEnvironmentIdForWorktree: vi.fn(), - hasMaterializedWebRuntimeBrowserPage: vi.fn(), - recoverWebSessionTerminalOrphansBeforeApply: vi.fn( - async (_state: unknown, snapshot: unknown) => snapshot - ) + getRuntimeEnvironmentIdForWorktree: vi.fn() })) vi.mock('../store', () => ({ @@ -51,7 +72,6 @@ vi.mock('./web-session-tabs-sync', () => ({ acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot, applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot, decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot, - getWebSessionTabsTrackingGeneration: mocks.getWebSessionTabsTrackingGeneration, applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => { mocks.setState(buildPatch) // The production caller invokes the returned settle receipt. @@ -60,10 +80,6 @@ vi.mock('./web-session-tabs-sync', () => ({ resolveHostSessionTabIdForWebSessionTab: mocks.resolveHostSessionTabIdForWebSessionTab })) -vi.mock('./web-session-terminal-orphan-recovery', () => ({ - recoverWebSessionTerminalOrphansBeforeApply: mocks.recoverWebSessionTerminalOrphansBeforeApply -})) - vi.mock('@/lib/feature-education-telemetry', () => ({ trackTerminalPaneSplit: mocks.trackTerminalPaneSplit })) @@ -77,16 +93,51 @@ vi.mock('@/lib/agent-launch-prompt-delivery', () => ({ seedNativeChatLaunchDraftForAgentTab: mocks.seedNativeChatLaunchDraftForAgentTab })) -vi.mock('./web-runtime-browser-materialization', () => ({ - hasMaterializedWebRuntimeBrowserPage: mocks.hasMaterializedWebRuntimeBrowserPage -})) +const ENVIRONMENT_ID = 'web-env-1' +const RUNTIME_EXECUTION_HOST_ID = toRuntimeExecutionHostId(ENVIRONMENT_ID) +const WORKTREE_ID = 'repo::/worktree' +const FOCUS_LEAF_ID = '11111111-1111-4111-8111-111111111111' afterEach(() => resetWebSessionCloseIntentForTests()) +// Identity-only launches probe status.get first; answer it, then delegate. +function withIdentityCapableStatus( + handler: (request: { method: string }) => Promise, + capabilities: string[] = ['agent-launch.identity.v1'] +) { + return vi.fn(async (request: { method: string }) => { + if (request.method === 'status.get') { + return { + id: 'status', + ok: true, + result: { + runtimeId: 'runtime-1', + graphStatus: 'ready', + runtimeProtocolVersion: 3, + minCompatibleRuntimeClientVersion: 2, + capabilities + } + } + } + return await handler(request) + }) +} + +function makeSnapshot(): RuntimeMobileSessionTabsResult { + return { + worktree: WORKTREE_ID, + publicationEpoch: 'epoch-1', + snapshotVersion: 1, + activeGroupId: 'group-1', + activeTabId: null, + activeTabType: null, + tabs: [] + } +} + describe('refreshWebRuntimeSessionTabsSnapshot', () => { afterEach(() => { resetWebAgentSessionHandoffsForTests() - replaceRuntimeEnvironmentRevisions([]) vi.unstubAllGlobals() vi.clearAllMocks() }) @@ -100,9 +151,6 @@ describe('refreshWebRuntimeSessionTabsSnapshot', () => { vi.stubGlobal('window', { api: { runtimeEnvironments: { call: runtimeCall } } }) - mocks.setState.mockImplementation((updater: (state: unknown) => unknown) => - updater({ state: 'before' }) - ) mocks.applyWebSessionTabsSnapshot.mockImplementation((state) => state) recordWebAgentSessionHandoff({ environmentId: ENVIRONMENT_ID, @@ -157,34 +205,6 @@ describe('refreshWebRuntimeSessionTabsSnapshot', () => { }) expect(confirmed('provisional-a')).toBe(false) }) - - it('applies the recovered snapshot instead of a transient pending-handle frame', async () => { - const pending = makeSnapshot() - const recovered = { ...pending, publicationEpoch: 'recovered', snapshotVersion: 2 } - const state = { state: 'before' } - const runtimeCall = vi.fn().mockResolvedValue({ id: 'list', ok: true, result: pending }) - vi.stubGlobal('window', { - api: { runtimeEnvironments: { call: runtimeCall } } - }) - mocks.getState.mockReturnValue(state) - mocks.setState.mockImplementation((updater: (current: unknown) => unknown) => updater(state)) - mocks.recoverWebSessionTerminalOrphansBeforeApply.mockResolvedValueOnce(recovered) - mocks.applyWebSessionTabsSnapshot.mockImplementation((state) => state) - replaceRuntimeEnvironmentRevisions([{ id: ENVIRONMENT_ID, createdAt: 1, pairingRevision: 17 }]) - - await refreshWebRuntimeSessionTabsSnapshot(ENVIRONMENT_ID, WORKTREE_ID) - - expect(mocks.recoverWebSessionTerminalOrphansBeforeApply).toHaveBeenCalledWith( - state, - pending, - ENVIRONMENT_ID, - { - expectedEnvironmentPairingRevision: 17, - getCurrentState: expect.any(Function) - } - ) - expect(mocks.applyWebSessionTabsSnapshot).toHaveBeenCalledWith(state, recovered, ENVIRONMENT_ID) - }) }) describe('activateWebRuntimeSessionWorktree', () => { @@ -258,3 +278,2584 @@ describe('activateWebRuntimeSessionWorktree', () => { ) }) }) + +describe('createWebRuntimeSessionBrowserTab', () => { + beforeEach(() => { + vi.stubGlobal('__ORCA_WEB_CLIENT__', true) + mocks.getState.mockReturnValue({ + settings: { + activeRuntimeEnvironmentId: ENVIRONMENT_ID + }, + runtimeStatusByEnvironmentId: new Map([ + [ENVIRONMENT_ID, { status: { capabilities: ['browser.screencast.v1'] }, checkedAt: 1 }] + ]), + activeWorktreeId: WORKTREE_ID, + activeWorkspaceExecutionHostId: RUNTIME_EXECUTION_HOST_ID, + activeTabType: 'editor', + activeTabTypeByWorktree: { [WORKTREE_ID]: 'editor' }, + activeFileIdByWorktree: { [WORKTREE_ID]: '/worktree/index.html' }, + browserPagesByWorkspace: {}, + remoteBrowserPageHandlesByPageId: {}, + unifiedTabsByWorktree: { + [WORKTREE_ID]: [ + { + id: 'local-editor-tab', + entityId: '/worktree/index.html', + contentType: 'editor' + } + ] + }, + createBrowserTab: mocks.createBrowserTab, + closeEmptyGroup: mocks.closeEmptyGroup, + moveUnifiedTabToGroup: mocks.moveUnifiedTabToGroup, + setRemoteBrowserPageHandle: mocks.setRemoteBrowserPageHandle, + focusBrowserTabInWorktree: mocks.focusBrowserTabInWorktree, + setActiveWorktree: mocks.setActiveWorktree + }) + mocks.setState.mockImplementation((updater: (state: unknown) => unknown) => { + updater({ + state: 'before', + activeWorktreeId: WORKTREE_ID + }) + }) + mocks.subscribe.mockReturnValue(vi.fn()) + mocks.createBrowserTab.mockReturnValue({ + id: 'local-browser-workspace-1', + activePageId: 'local-page-1', + pageIds: ['local-page-1'] + }) + mocks.moveUnifiedTabToGroup.mockReturnValue(true) + mocks.applyFreshWebSessionTabsSnapshot.mockReturnValue({ state: 'after' }) + mocks.resolveHostSessionTabIdForWebSessionTab.mockReturnValue(null) + mocks.deliverLaunchPromptToAgentTab.mockResolvedValue(true) + }) + + afterEach(() => { + vi.unstubAllGlobals() + clearRuntimeCompatibilityCacheForTests() + resetWebSessionBrowserPlacementsForTests() + resetWebSessionFocusIntentForTests() + vi.clearAllMocks() + }) + + it('rejects before RPC when the selected runtime does not advertise screencast', async () => { + mocks.getState.mockReturnValue({ + settings: { activeRuntimeEnvironmentId: ENVIRONMENT_ID }, + runtimeStatusByEnvironmentId: new Map([ + [ENVIRONMENT_ID, { status: { capabilities: [] }, checkedAt: 1 }] + ]) + }) + const runtimeCall = vi.fn() + vi.stubGlobal('window', { api: { runtimeEnvironments: { call: runtimeCall } } }) + + await expect( + createWebRuntimeSessionBrowserTab({ + worktreeId: WORKTREE_ID, + environmentId: ENVIRONMENT_ID + }) + ).rejects.toThrow('does not support browser streaming') + + expect(runtimeCall).not.toHaveBeenCalled() + expect(mocks.createBrowserTab).not.toHaveBeenCalled() + }) + + it('applies an empty host snapshot without retaining delayed browser focus', async () => { + const snapshot = makeSnapshot() + let resolveList!: (response: unknown) => void + const listResponse = new Promise((resolve) => { + resolveList = resolve + }) + const runtimeCall = vi + .fn() + .mockResolvedValueOnce({ + id: 'create', + ok: true, + result: { browserPageId: 'remote-browser-page-1' } + }) + .mockReturnValueOnce(listResponse) + + vi.stubGlobal('window', { + api: { + runtimeEnvironments: { + call: runtimeCall + } + } + }) + + const pendingCreate = createWebRuntimeSessionBrowserTab({ + worktreeId: WORKTREE_ID, + url: 'https://example.com/' + }) + + await vi.waitFor(() => expect(runtimeCall).toHaveBeenCalledTimes(2)) + expect(mocks.subscribe).toHaveBeenCalledOnce() + expect(peekWebSessionFocusIntent({ environmentId: ENVIRONMENT_ID }, WORKTREE_ID)).toEqual({ + hostTabId: 'remote-browser-page-1', + expectedCurrentLocalTabId: 'local-editor-tab' + }) + resolveList({ id: 'list', ok: true, result: snapshot }) + await expect(pendingCreate).resolves.toBe(true) + await vi.waitFor(() => expect(mocks.applyFreshWebSessionTabsSnapshot).toHaveBeenCalledTimes(1)) + + expect(runtimeCall).toHaveBeenNthCalledWith(1, { + selector: ENVIRONMENT_ID, + method: 'browser.tabCreate', + params: { + worktree: `id:${WORKTREE_ID}`, + url: 'https://example.com/', + profileId: undefined, + // Why: a user-initiated "New Browser Tab" focuses the new tab, which on a + // headless host marks it active in the session snapshot. + activate: true, + waitForRegistration: false + }, + timeoutMs: 15_000 + }) + expect(runtimeCall).toHaveBeenNthCalledWith(2, { + selector: ENVIRONMENT_ID, + method: 'session.tabs.list', + params: { + worktree: `id:${WORKTREE_ID}` + }, + timeoutMs: 15_000 + }) + expect(mocks.applyFreshWebSessionTabsSnapshot).toHaveBeenCalledWith( + { state: 'before', activeWorktreeId: WORKTREE_ID }, + snapshot, + ENVIRONMENT_ID + ) + expect(mocks.acceptReplayedWebSessionTabsSnapshot).toHaveBeenCalledWith( + ENVIRONMENT_ID, + WORKTREE_ID + ) + expect(mocks.acceptReplayedWebSessionTabsSnapshot.mock.invocationCallOrder[0]).toBeLessThan( + mocks.applyFreshWebSessionTabsSnapshot.mock.invocationCallOrder[0]! + ) + expect(peekWebSessionFocusIntent({ environmentId: ENVIRONMENT_ID }, WORKTREE_ID)).toBeNull() + expect(mocks.createBrowserTab).not.toHaveBeenCalled() + }) + + it('does not let a slow browser create replace a newer browser selection', async () => { + let resolveCreate!: (response: unknown) => void + const createResponse = new Promise((resolve) => { + resolveCreate = resolve + }) + mocks.resolveHostSessionTabIdForWebSessionTab.mockReturnValue('newer-host-browser') + const runtimeCall = vi.fn(async (request: { method: string }) => { + if (request.method === 'browser.tabCreate') { + return createResponse + } + if (request.method === 'session.tabs.activate') { + return { id: 'activate', ok: true, result: {} } + } + return { id: 'list', ok: true, result: makeSnapshot() } + }) + vi.stubGlobal('window', { api: { runtimeEnvironments: { call: runtimeCall } } }) + + const pendingCreate = createWebRuntimeSessionBrowserTab({ worktreeId: WORKTREE_ID }) + await vi.waitFor(() => + expect( + runtimeCall.mock.calls.some(([request]) => request.method === 'browser.tabCreate') + ).toBe(true) + ) + await activateWebRuntimeSessionTab({ + worktreeId: WORKTREE_ID, + tabId: 'newer-local-browser' + }) + resolveCreate({ + id: 'create', + ok: true, + result: { browserPageId: 'created-host-browser' } + }) + await expect(pendingCreate).resolves.toBe(true) + + expect(peekWebSessionFocusIntent({ environmentId: ENVIRONMENT_ID }, WORKTREE_ID)).toEqual({ + hostTabId: 'newer-host-browser' + }) + }) + + it('does not focus a slow browser create after an editor A-B-A selection', async () => { + let resolveCreate!: (response: unknown) => void + const createResponse = new Promise((resolve) => { + resolveCreate = resolve + }) + let state = mocks.getState() + let listener: ((next: typeof state, previous: typeof state) => void) | null = null + mocks.getState.mockImplementation(() => state) + mocks.subscribe.mockImplementation((nextListener) => { + listener = nextListener + return vi.fn() + }) + const runtimeCall = vi.fn(async (request: { method: string }) => { + if (request.method === 'browser.tabCreate') { + return createResponse + } + return { id: 'list', ok: true, result: makeSnapshot() } + }) + vi.stubGlobal('window', { api: { runtimeEnvironments: { call: runtimeCall } } }) + + const pendingCreate = createWebRuntimeSessionBrowserTab({ worktreeId: WORKTREE_ID }) + await vi.waitFor(() => expect(listener).not.toBeNull()) + const editorBState = { + ...state, + activeFileIdByWorktree: { [WORKTREE_ID]: '/worktree/other.html' }, + unifiedTabsByWorktree: { + [WORKTREE_ID]: [ + ...state.unifiedTabsByWorktree[WORKTREE_ID], + { + id: 'other-editor-tab', + entityId: '/worktree/other.html', + contentType: 'editor' + } + ] + } + } + listener!(editorBState, state) + const editorAState = { + ...editorBState, + activeFileIdByWorktree: { [WORKTREE_ID]: '/worktree/index.html' } + } + listener!(editorAState, editorBState) + state = editorAState + resolveCreate({ + id: 'create', + ok: true, + result: { browserPageId: 'created-host-browser' } + }) + await expect(pendingCreate).resolves.toBe(true) + + expect(peekWebSessionFocusIntent({ environmentId: ENVIRONMENT_ID }, WORKTREE_ID)).toBeNull() + }) + + it('does not focus a browser after an editor A-B-A selection during reconciliation', async () => { + let resolveList!: (response: unknown) => void + const listResponse = new Promise((resolve) => { + resolveList = resolve + }) + let state = mocks.getState() + let listener: ((next: typeof state, previous: typeof state) => void) | null = null + mocks.getState.mockImplementation(() => state) + mocks.subscribe.mockImplementation((nextListener) => { + listener = nextListener + return vi.fn() + }) + const runtimeCall = vi + .fn() + .mockResolvedValueOnce({ + id: 'create', + ok: true, + result: { browserPageId: 'created-host-browser' } + }) + .mockReturnValueOnce(listResponse) + vi.stubGlobal('window', { api: { runtimeEnvironments: { call: runtimeCall } } }) + + const pendingCreate = createWebRuntimeSessionBrowserTab({ worktreeId: WORKTREE_ID }) + await vi.waitFor(() => expect(runtimeCall).toHaveBeenCalledTimes(2)) + const editorBState = { + ...state, + activeFileIdByWorktree: { [WORKTREE_ID]: '/worktree/other.html' }, + unifiedTabsByWorktree: { + [WORKTREE_ID]: [ + ...state.unifiedTabsByWorktree[WORKTREE_ID], + { + id: 'other-editor-tab', + entityId: '/worktree/other.html', + contentType: 'editor' + } + ] + } + } + listener!(editorBState, state) + const editorAState = { + ...editorBState, + activeFileIdByWorktree: { [WORKTREE_ID]: '/worktree/index.html' } + } + listener!(editorAState, editorBState) + state = editorAState + resolveList({ id: 'list', ok: true, result: makeSnapshot() }) + await expect(pendingCreate).resolves.toBe(true) + + expect(peekWebSessionFocusIntent({ environmentId: ENVIRONMENT_ID }, WORKTREE_ID)).toBeNull() + }) + + it.each([ + { + label: 'worktree', + leave: (state: Record) => ({ + ...state, + activeWorktreeId: 'repo::/other-worktree' + }), + returnToStart: (state: Record) => ({ + ...state, + activeWorktreeId: WORKTREE_ID + }) + }, + { + label: 'runtime owner', + leave: (state: Record) => ({ + ...state, + activeWorkspaceExecutionHostId: 'local' + }), + returnToStart: (state: Record) => ({ + ...state, + activeWorkspaceExecutionHostId: RUNTIME_EXECUTION_HOST_ID + }) + } + ])( + 'does not focus after leaving and returning to the $label during reconciliation', + async ({ leave, returnToStart }) => { + let resolveList!: (response: unknown) => void + const listResponse = new Promise((resolve) => { + resolveList = resolve + }) + let state = mocks.getState() + let listener: ((next: typeof state, previous: typeof state) => void) | null = null + mocks.getState.mockImplementation(() => state) + mocks.subscribe.mockImplementation((nextListener) => { + listener = nextListener + return vi.fn() + }) + const runtimeCall = vi + .fn() + .mockResolvedValueOnce({ + id: 'create', + ok: true, + result: { browserPageId: 'created-host-browser' } + }) + .mockReturnValueOnce(listResponse) + vi.stubGlobal('window', { api: { runtimeEnvironments: { call: runtimeCall } } }) + + const pendingCreate = createWebRuntimeSessionBrowserTab({ worktreeId: WORKTREE_ID }) + await vi.waitFor(() => expect(runtimeCall).toHaveBeenCalledTimes(2)) + const awayState = leave(state) + listener!(awayState, state) + const returnedState = returnToStart(awayState) + listener!(returnedState, awayState) + state = returnedState + resolveList({ id: 'list', ok: true, result: makeSnapshot() }) + await expect(pendingCreate).resolves.toBe(true) + + expect(peekWebSessionFocusIntent({ environmentId: ENVIRONMENT_ID }, WORKTREE_ID)).toBeNull() + } + ) + + it('does not retry a failed browser create', async () => { + const runtimeCall = vi.fn().mockResolvedValue({ + id: 'create-lost', + ok: false, + error: { code: 'remote_runtime_unavailable', message: 'response lost' } + }) + vi.stubGlobal('window', { api: { runtimeEnvironments: { call: runtimeCall } } }) + + await expect( + createWebRuntimeSessionBrowserTab({ worktreeId: WORKTREE_ID, url: 'https://example.com/' }) + ).resolves.toBe(false) + + expect(runtimeCall).toHaveBeenCalledOnce() + expect(mocks.createBrowserTab).not.toHaveBeenCalled() + }) + + it('creates an unfocused browser while preserving its requested split', async () => { + mocks.getState.mockReturnValue({ + settings: { activeRuntimeEnvironmentId: ENVIRONMENT_ID }, + runtimeStatusByEnvironmentId: new Map([ + [ENVIRONMENT_ID, { status: { capabilities: ['browser.screencast.v1'] }, checkedAt: 1 }] + ]), + activeWorktreeId: WORKTREE_ID, + browserPagesByWorkspace: { + 'host-browser-workspace': [ + { id: 'host-browser-page', workspaceId: 'host-browser-workspace' } + ] + }, + remoteBrowserPageHandlesByPageId: { + 'host-browser-page': { + environmentId: ENVIRONMENT_ID, + remotePageId: 'remote-browser-page-1' + } + }, + unifiedTabsByWorktree: { [WORKTREE_ID]: [] }, + createBrowserTab: mocks.createBrowserTab, + closeEmptyGroup: mocks.closeEmptyGroup, + moveUnifiedTabToGroup: mocks.moveUnifiedTabToGroup, + setRemoteBrowserPageHandle: mocks.setRemoteBrowserPageHandle, + focusBrowserTabInWorktree: mocks.focusBrowserTabInWorktree, + setActiveWorktree: mocks.setActiveWorktree + }) + const runtimeCall = vi + .fn() + .mockResolvedValueOnce({ + id: 'create', + ok: true, + result: { browserPageId: 'remote-browser-page-1' } + }) + .mockResolvedValueOnce({ id: 'list', ok: true, result: makeSnapshot() }) + vi.stubGlobal('window', { api: { runtimeEnvironments: { call: runtimeCall } } }) + + await expect( + createWebRuntimeSessionBrowserTab({ + worktreeId: WORKTREE_ID, + environmentId: ENVIRONMENT_ID, + clientTargetGroupId: 'client-preview-group', + focusOnCreate: false + }) + ).resolves.toBe(true) + + expect(runtimeCall).toHaveBeenNthCalledWith(1, { + selector: ENVIRONMENT_ID, + method: 'browser.tabCreate', + params: { + worktree: `id:${WORKTREE_ID}`, + url: undefined, + profileId: undefined, + activate: false, + waitForRegistration: false + }, + timeoutMs: 15_000 + }) + expect(mocks.subscribe).not.toHaveBeenCalled() + expect(peekWebSessionFocusIntent({ environmentId: ENVIRONMENT_ID }, WORKTREE_ID)).toBeNull() + expect(mocks.acceptReplayedWebSessionTabsSnapshot).toHaveBeenCalledWith( + ENVIRONMENT_ID, + WORKTREE_ID + ) + expect( + isWebSessionBrowserPlacementGroupReserved({ + environmentId: ENVIRONMENT_ID, + worktreeId: WORKTREE_ID, + groupId: 'client-preview-group' + }) + ).toBe(true) + expect(mocks.createBrowserTab).not.toHaveBeenCalled() + expect(mocks.closeEmptyGroup).not.toHaveBeenCalled() + }) + + it('releases and removes a newly-created split when host creation fails', async () => { + vi.stubGlobal('window', { + api: { + runtimeEnvironments: { + call: vi.fn().mockRejectedValue(new Error('offline')) + } + } + }) + + await expect( + createWebRuntimeSessionBrowserTab({ + worktreeId: WORKTREE_ID, + environmentId: ENVIRONMENT_ID, + clientTargetGroupId: 'client-preview-group', + clientTargetGroupCreated: true + }) + ).resolves.toBe(false) + + expect(mocks.closeEmptyGroup).toHaveBeenCalledWith(WORKTREE_ID, 'client-preview-group') + expect( + isWebSessionBrowserPlacementGroupReserved({ + environmentId: ENVIRONMENT_ID, + worktreeId: WORKTREE_ID, + groupId: 'client-preview-group' + }) + ).toBe(false) + }) + + it('keeps the requested worktree selected while the browser snapshot catches up', async () => { + const snapshot = makeSnapshot() + const setStateResults: unknown[] = [] + let focusState = { + ...mocks.getState(), + activeWorktreeId: 'landing', + activeWorkspaceExecutionHostId: 'local' + } + mocks.getState.mockImplementation(() => focusState) + mocks.setActiveWorktree.mockImplementation((worktreeId, executionHostId) => { + focusState = { + ...focusState, + activeWorktreeId: worktreeId, + activeWorkspaceExecutionHostId: executionHostId + } + }) + let mockState: Record = { state: 'before-stage', activeWorktreeId: 'landing' } + mocks.setState.mockImplementation((updater: (state: unknown) => unknown) => { + const result = updater(mockState) + setStateResults.push(result) + if (result && result !== mockState) { + mockState = { ...mockState, ...(result as Record) } + } + }) + const runtimeCall = vi + .fn() + .mockResolvedValueOnce({ + id: 'create', + ok: true, + result: { browserPageId: 'remote-browser-page-1' } + }) + .mockImplementationOnce(async () => { + mockState = { ...mockState, activeWorktreeId: 'other-worktree' } + return { + id: 'list', + ok: true, + result: snapshot + } + }) + + vi.stubGlobal('window', { + api: { + runtimeEnvironments: { + call: runtimeCall + } + } + }) + + await expect( + createWebRuntimeSessionBrowserTab({ + worktreeId: WORKTREE_ID, + url: 'https://example.com/' + }) + ).resolves.toBe(true) + + await vi.waitFor(() => expect(mocks.applyFreshWebSessionTabsSnapshot).toHaveBeenCalledTimes(1)) + + expect(mocks.setActiveWorktree).toHaveBeenCalledWith(WORKTREE_ID, RUNTIME_EXECUTION_HOST_ID) + expect(mocks.applyFreshWebSessionTabsSnapshot).toHaveBeenCalledWith( + { state: 'before-stage', activeWorktreeId: 'other-worktree' }, + snapshot, + ENVIRONMENT_ID + ) + expect(setStateResults.at(-1)).toEqual({ state: 'after' }) + }) + + it('does not reselect an already active browser worktree on the same runtime', async () => { + mocks.getState.mockReturnValue({ + settings: { activeRuntimeEnvironmentId: ENVIRONMENT_ID }, + runtimeStatusByEnvironmentId: new Map([ + [ENVIRONMENT_ID, { status: { capabilities: ['browser.screencast.v1'] }, checkedAt: 1 }] + ]), + activeWorktreeId: WORKTREE_ID, + activeWorkspaceExecutionHostId: RUNTIME_EXECUTION_HOST_ID, + browserPagesByWorkspace: {}, + remoteBrowserPageHandlesByPageId: {}, + unifiedTabsByWorktree: {}, + createBrowserTab: mocks.createBrowserTab, + closeEmptyGroup: mocks.closeEmptyGroup, + moveUnifiedTabToGroup: mocks.moveUnifiedTabToGroup, + setRemoteBrowserPageHandle: mocks.setRemoteBrowserPageHandle, + focusBrowserTabInWorktree: mocks.focusBrowserTabInWorktree, + setActiveWorktree: mocks.setActiveWorktree + }) + const runtimeCall = vi + .fn() + .mockResolvedValueOnce({ + id: 'create', + ok: true, + result: { browserPageId: 'remote-browser-page-1' } + }) + .mockResolvedValueOnce({ id: 'list', ok: true, result: makeSnapshot() }) + vi.stubGlobal('window', { api: { runtimeEnvironments: { call: runtimeCall } } }) + + await expect( + createWebRuntimeSessionBrowserTab({ + worktreeId: WORKTREE_ID, + environmentId: ENVIRONMENT_ID, + url: 'https://example.com/' + }) + ).resolves.toBe(true) + + expect(mocks.setActiveWorktree).not.toHaveBeenCalled() + }) + + it('can create a browser tab without selecting the target worktree', async () => { + const setStateResults: unknown[] = [] + mocks.setState.mockImplementation((updater: (state: unknown) => unknown) => { + const result = updater({ + state: 'before-stage', + activeWorktreeId: 'main-worktree' + }) + setStateResults.push(result) + }) + const runtimeCall = vi + .fn() + .mockResolvedValueOnce({ + id: 'create', + ok: true, + result: { browserPageId: 'remote-browser-page-1' } + }) + .mockResolvedValueOnce({ + id: 'list', + ok: true, + result: makeSnapshot() + }) + + vi.stubGlobal('window', { + api: { + runtimeEnvironments: { + call: runtimeCall + } + } + }) + + await expect( + createWebRuntimeSessionBrowserTab({ + worktreeId: WORKTREE_ID, + url: 'https://example.com/', + selectWorktree: false + }) + ).resolves.toBe(true) + + expect(mocks.setActiveWorktree).not.toHaveBeenCalled() + expect(mocks.focusBrowserTabInWorktree).not.toHaveBeenCalled() + }) + + it('does not focus a staged browser tab when the user leaves before host create resolves', async () => { + let activeWorktreeId = WORKTREE_ID + mocks.getState.mockImplementation(() => ({ + settings: { + activeRuntimeEnvironmentId: ENVIRONMENT_ID + }, + runtimeStatusByEnvironmentId: new Map([ + [ENVIRONMENT_ID, { status: { capabilities: ['browser.screencast.v1'] }, checkedAt: 1 }] + ]), + activeWorktreeId, + browserPagesByWorkspace: {}, + remoteBrowserPageHandlesByPageId: {}, + createBrowserTab: mocks.createBrowserTab, + setRemoteBrowserPageHandle: mocks.setRemoteBrowserPageHandle, + focusBrowserTabInWorktree: mocks.focusBrowserTabInWorktree, + setActiveWorktree: mocks.setActiveWorktree + })) + const runtimeCall = vi + .fn() + .mockImplementationOnce(async () => { + activeWorktreeId = 'other-worktree' + return { + id: 'create', + ok: true, + result: { browserPageId: 'remote-browser-page-1' } + } + }) + .mockResolvedValueOnce({ + id: 'list', + ok: true, + result: makeSnapshot() + }) + + vi.stubGlobal('window', { + api: { + runtimeEnvironments: { + call: runtimeCall + } + } + }) + + await expect( + createWebRuntimeSessionBrowserTab({ + worktreeId: WORKTREE_ID, + url: 'https://example.com/' + }) + ).resolves.toBe(true) + + expect(mocks.focusBrowserTabInWorktree).not.toHaveBeenCalled() + expect(mocks.setActiveWorktree).toHaveBeenCalledWith(WORKTREE_ID, RUNTIME_EXECUTION_HOST_ID) + await vi.waitFor(() => expect(mocks.setState).toHaveBeenCalledTimes(1)) + }) + + it('does not require a staged browser page before the host snapshot catches up', async () => { + mocks.createBrowserTab.mockReturnValue({ + id: 'local-browser-workspace-1' + }) + const runtimeCall = vi + .fn() + .mockResolvedValueOnce({ + id: 'create', + ok: true, + result: { browserPageId: 'remote-browser-page-1' } + }) + .mockResolvedValueOnce({ + id: 'list', + ok: true, + result: makeSnapshot() + }) + + vi.stubGlobal('window', { + api: { + runtimeEnvironments: { + call: runtimeCall + } + } + }) + + await expect( + createWebRuntimeSessionBrowserTab({ + worktreeId: WORKTREE_ID, + url: 'https://example.com/' + }) + ).resolves.toBe(true) + + await vi.waitFor(() => expect(mocks.applyFreshWebSessionTabsSnapshot).toHaveBeenCalledTimes(1)) + expect(mocks.setRemoteBrowserPageHandle).not.toHaveBeenCalled() + }) + + it('keeps client presentation fields out of the RPC shape', async () => { + const runtimeCall = vi + .fn() + .mockResolvedValueOnce({ + id: 'create', + ok: true, + result: { browserPageId: 'remote-browser-page-1' } + }) + .mockResolvedValueOnce({ id: 'list', ok: true, result: makeSnapshot() }) + vi.stubGlobal('window', { api: { runtimeEnvironments: { call: runtimeCall } } }) + + await expect( + createWebRuntimeSessionBrowserTab({ + worktreeId: WORKTREE_ID, + environmentId: ENVIRONMENT_ID, + url: 'https://example.com/?q=private', + selectWorktree: false, + stagedTitle: 'Search Google', + stagedFocusAddressBar: false + }) + ).resolves.toBe(true) + + expect(mocks.createBrowserTab).not.toHaveBeenCalled() + expect(runtimeCall.mock.calls[0][0].params).not.toHaveProperty('stagedTitle') + }) + + it('can log remote browser failure without retaining downstream details', async () => { + const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + vi.stubGlobal('window', { + api: { + runtimeEnvironments: { + call: vi.fn().mockRejectedValue(new Error('failed https://example.com/?q=private')) + } + } + }) + + await expect( + createWebRuntimeSessionBrowserTab({ + worktreeId: WORKTREE_ID, + environmentId: ENVIRONMENT_ID, + url: 'https://example.com/?q=private', + failureLogMode: 'operation-only' + }) + ).resolves.toBe(false) + + expect(consoleWarn).toHaveBeenCalledWith('[web-runtime-session] failed to create browser tab') + expect(JSON.stringify(consoleWarn.mock.calls)).not.toContain('private') + }) +}) + +describe('createWebRuntimeSessionTerminal', () => { + beforeEach(() => { + vi.stubGlobal('__ORCA_WEB_CLIENT__', true) + mocks.getState.mockReturnValue({ + settings: { + activeRuntimeEnvironmentId: ENVIRONMENT_ID + }, + activeWorktreeId: WORKTREE_ID, + browserPagesByWorkspace: {}, + remoteBrowserPageHandlesByPageId: {}, + createBrowserTab: mocks.createBrowserTab, + setRemoteBrowserPageHandle: mocks.setRemoteBrowserPageHandle, + focusBrowserTabInWorktree: mocks.focusBrowserTabInWorktree, + setActiveWorktree: mocks.setActiveWorktree + }) + mocks.setState.mockImplementation((updater: (state: unknown) => unknown) => { + updater({ + state: 'before', + activeWorktreeId: WORKTREE_ID + }) + }) + mocks.applyFreshWebSessionTabsSnapshot.mockReturnValue({ state: 'after' }) + mocks.resolveHostSessionTabIdForWebSessionTab.mockReturnValue(null) + mocks.deliverLaunchPromptToAgentTab.mockResolvedValue(true) + }) + + afterEach(() => { + vi.unstubAllGlobals() + clearRuntimeCompatibilityCacheForTests() + resetWebSessionFocusIntentForTests() + vi.clearAllMocks() + }) + + it('keeps same-ID local and runtime worktrees on the selected runtime owner', async () => { + const selectedHosts: (string | undefined)[] = [] + mocks.setActiveWorktree.mockImplementation((_worktreeId: string, executionHostId?: string) => { + selectedHosts.push(executionHostId) + }) + const runtimeCall = vi + .fn() + .mockResolvedValueOnce({ + id: 'create', + ok: true, + result: { tab: { id: 'host-tab-1', leafId: 'host-leaf-1' } } + }) + .mockResolvedValueOnce({ id: 'list', ok: true, result: makeSnapshot() }) + vi.stubGlobal('window', { + api: { runtimeEnvironments: { call: runtimeCall } } + }) + + await expect( + createWebRuntimeSessionTerminal({ + worktreeId: WORKTREE_ID, + environmentId: ENVIRONMENT_ID + }) + ).resolves.toEqual({ status: 'created' }) + + expect(selectedHosts).toEqual([RUNTIME_EXECUTION_HOST_ID]) + }) + + it.each([ + { sessionKind: 'fresh' as const, activate: true }, + { sessionKind: 'fresh' as const, activate: false }, + { sessionKind: 'resume' as const, activate: true }, + { sessionKind: 'resume' as const, activate: false } + ])( + 'keeps $sessionKind host creation background with activate=$activate while focus stays client-owned', + async ({ sessionKind, activate }) => { + const hostTabId = `host-${sessionKind}-${activate ? 'active' : 'background'}` + const runtimeCall = vi.fn(async (request: { method: string }) => { + if (request.method === 'status.get') { + return { + id: 'status', + ok: true, + result: { + runtimeId: 'runtime-1', + graphStatus: 'ready', + runtimeProtocolVersion: 3, + minCompatibleRuntimeClientVersion: 2, + capabilities: ['agent-session.host-authority.v1'] + } + } + } + if ( + request.method === 'terminal.createAgentSession' || + request.method === 'terminal.ensureAgentSession' + ) { + return { + id: 'agent-session', + ok: true, + result: { + terminal: { + handle: `term-${sessionKind}`, + worktreeId: WORKTREE_ID, + tabId: hostTabId, + paneKey: `${hostTabId}:${FOCUS_LEAF_ID}` + }, + disposition: 'created' + } + } + } + return { id: 'list', ok: true, result: makeSnapshot() } + }) + vi.stubGlobal('window', { + api: { runtimeEnvironments: { call: runtimeCall } } + }) + + await expect( + createWebRuntimeSessionTerminal({ + worktreeId: WORKTREE_ID, + agentSessionKind: sessionKind, + launchAgent: 'codex', + ...(sessionKind === 'resume' + ? { + command: "codex resume 'session-1'", + providerSession: { key: 'session_id' as const, id: 'session-1' } + } + : {}), + activate + }) + ).resolves.toEqual({ status: 'created' }) + + const authorityMethod = + sessionKind === 'resume' ? 'terminal.ensureAgentSession' : 'terminal.createAgentSession' + const authorityRequest = runtimeCall.mock.calls.find( + ([request]) => request.method === authorityMethod + )?.[0] + expect(authorityRequest).toMatchObject({ + selector: ENVIRONMENT_ID, + method: authorityMethod, + params: { presentation: 'background' } + }) + expect(peekWebSessionFocusIntent({ environmentId: ENVIRONMENT_ID }, WORKTREE_ID)).toEqual( + activate ? { hostTabId, leafId: FOCUS_LEAF_ID } : null + ) + expect(mocks.acceptReplayedWebSessionTabsSnapshot).toHaveBeenCalledTimes(activate ? 1 : 0) + } + ) + + it('creates paired web agents through host authority so activation is mirrored', async () => { + const snapshot = { + ...makeSnapshot(), + snapshotVersion: 2, + activeTabId: 'host-tab-2::leaf-1', + activeTabType: 'terminal' as const, + tabs: [ + { + type: 'terminal' as const, + id: 'host-tab-2::leaf-1', + parentTabId: 'host-tab-2', + leafId: 'leaf-1', + title: 'Terminal 2', + terminal: 'pty-2', + status: 'ready' as const, + isActive: true + } + ] + } + const runtimeCall = vi + .fn() + .mockResolvedValueOnce({ + id: 'status', + ok: true, + result: { + runtimeId: 'runtime-1', + graphStatus: 'ready', + runtimeProtocolVersion: 3, + minCompatibleRuntimeClientVersion: 2, + capabilities: ['agent-session.host-authority.v1'] + } + }) + .mockResolvedValueOnce({ + id: 'create-terminal', + ok: true, + result: { + terminal: { + id: 'pty-2', + handle: 'term_2', + title: 'Terminal 2', + cwd: '/repo/packages/app', + worktreeId: WORKTREE_ID, + tabId: 'host-tab-2', + paneKey: `host-tab-2:${FOCUS_LEAF_ID}` + }, + disposition: 'created' + } + }) + .mockResolvedValueOnce({ + id: 'move', + ok: true, + result: { moved: true } + }) + .mockResolvedValueOnce({ + id: 'list', + ok: true, + result: snapshot + }) + + vi.stubGlobal('window', { + api: { + runtimeEnvironments: { + call: runtimeCall + } + } + }) + + await expect( + createWebRuntimeSessionTerminal({ + worktreeId: WORKTREE_ID, + targetGroupId: 'group-left', + command: "codex 'linked issue context'", + cwd: '/repo/packages/app', + env: { CODEX_PROFILE: 'captured' }, + envToDelete: ['CODEX_HOME', 'ORCA_CODEX_HOME'], + startupCommandDelivery: 'shell-ready', + launchConfig: { + agentArgs: '--model gpt-5', + agentEnv: { CODEX_PROFILE: 'captured' } + }, + launchAgent: 'codex', + prompt: 'linked issue context', + promptDelivery: 'draft', + agentArgs: '--model gpt-5 --profile captured', + launchPreferences: { model: 'gpt-5', effort: 'high' }, + viewMode: 'chat', + activate: true + }) + ).resolves.toEqual({ status: 'created' }) + + expect(runtimeCall).toHaveBeenNthCalledWith(2, { + selector: ENVIRONMENT_ID, + expectedEnvironmentPairingRevision: undefined, + method: 'terminal.createAgentSession', + params: { + clientOperationId: expect.stringMatching(/^\d{13}-[0-9a-f]{32}$/), + worktree: `id:${WORKTREE_ID}`, + agent: 'codex', + prompt: 'linked issue context', + promptDelivery: 'draft', + agentArgs: '--model gpt-5 --profile captured', + launchPreferences: { model: 'gpt-5', effort: 'high' }, + startupCwd: '/repo/packages/app', + viewMode: 'chat', + presentation: 'background' + }, + timeoutMs: 15_000 + }) + expect(runtimeCall).toHaveBeenNthCalledWith(3, { + selector: ENVIRONMENT_ID, + method: 'session.tabs.move', + params: { + worktree: `id:${WORKTREE_ID}`, + tabId: 'host-tab-2', + targetGroupId: 'group-left', + kind: 'move-to-group' + }, + timeoutMs: 15_000 + }) + expect(runtimeCall).toHaveBeenNthCalledWith(4, { + selector: ENVIRONMENT_ID, + method: 'session.tabs.list', + params: { + worktree: `id:${WORKTREE_ID}` + }, + timeoutMs: 15_000 + }) + expect(mocks.applyFreshWebSessionTabsSnapshot).toHaveBeenCalledWith( + { state: 'before', activeWorktreeId: WORKTREE_ID }, + snapshot, + ENVIRONMENT_ID + ) + }) + + it('keeps exact legacy ordering when structured creation cannot express afterTabId', async () => { + const runtimeCall = vi + .fn() + .mockResolvedValueOnce({ + id: 'legacy-create', + ok: true, + result: { + tab: { id: 'host-tab-2' }, + publicationEpoch: 'epoch-1', + snapshotVersion: 2 + } + }) + .mockResolvedValueOnce({ id: 'list', ok: true, result: makeSnapshot() }) + vi.stubGlobal('window', { + api: { runtimeEnvironments: { call: runtimeCall } } + }) + + await expect( + createWebRuntimeSessionTerminal({ + worktreeId: WORKTREE_ID, + afterTabId: 'web-terminal-host-tab-1%3A%3Aleaf-1', + targetGroupId: 'group-left', + agentSessionKind: 'fresh', + agent: 'codex', + activate: true + }) + ).resolves.toEqual({ status: 'created' }) + + expect(runtimeCall).toHaveBeenNthCalledWith(1, { + selector: ENVIRONMENT_ID, + method: 'session.tabs.createTerminal', + params: { + worktree: `id:${WORKTREE_ID}`, + afterTabId: 'host-tab-1::leaf-1', + targetGroupId: 'group-left', + command: undefined, + cwd: undefined, + startupCommandDelivery: undefined, + agent: 'codex', + activate: false, + select: true, + navigation: 'caller' + }, + timeoutMs: 15_000 + }) + expect(runtimeCall.mock.calls.map(([request]) => request.method)).toEqual([ + 'session.tabs.createTerminal', + 'session.tabs.list' + ]) + }) + + it('forwards a host-resolved agentLaunch request without a client command or config', async () => { + const snapshot = { + ...makeSnapshot(), + snapshotVersion: 2, + activeTabId: 'host-tab-2::leaf-1', + activeTabType: 'terminal' as const, + tabs: [ + { + type: 'terminal' as const, + id: 'host-tab-2::leaf-1', + parentTabId: 'host-tab-2', + leafId: 'leaf-1', + title: 'Terminal 2', + terminal: 'pty-2', + status: 'ready' as const, + isActive: true + } + ] + } + const runtimeCall = withIdentityCapableStatus(async (request) => + request.method === 'session.tabs.createTerminal' + ? { + id: 'create-terminal', + ok: true, + result: { + tab: snapshot.tabs[0], + publicationEpoch: snapshot.publicationEpoch, + snapshotVersion: snapshot.snapshotVersion, + agentLaunch: { status: 'launched', receipt: { launchToken: 'tok-1' } } + } + } + : { id: 'list', ok: true, result: snapshot } + ) + + vi.stubGlobal('window', { + api: { runtimeEnvironments: { call: runtimeCall } } + }) + + await expect( + createWebRuntimeSessionTerminal({ + worktreeId: WORKTREE_ID, + targetGroupId: 'group-left', + agentLaunch: { + selection: { kind: 'agent', agent: 'claude' }, + prompt: 'fork context', + promptDelivery: 'draft' + }, + activate: true + }) + ).resolves.toEqual({ status: 'created' }) + + expect(runtimeCall).toHaveBeenNthCalledWith(2, { + selector: ENVIRONMENT_ID, + method: 'session.tabs.createTerminal', + params: { + worktree: `id:${WORKTREE_ID}`, + afterTabId: undefined, + targetGroupId: 'group-left', + command: undefined, + cwd: undefined, + startupCommandDelivery: undefined, + agentLaunch: { + selection: { kind: 'agent', agent: 'claude' }, + prompt: 'fork context', + promptDelivery: 'draft' + }, + activate: false, + select: true, + navigation: 'caller' + }, + timeoutMs: 15_000 + }) + }) + + it('treats a pre-spawn agentLaunch failure arm as a launch failure', async () => { + const runtimeCall = withIdentityCapableStatus(async () => ({ + id: 'create-terminal', + ok: true, + result: { + agentLaunch: { status: 'failed', failure: { code: 'invalid_launch_snapshot' } } + } + })) + + vi.stubGlobal('window', { + api: { runtimeEnvironments: { call: runtimeCall } } + }) + + await expect( + createWebRuntimeSessionTerminal({ + worktreeId: WORKTREE_ID, + agentLaunch: { selection: { kind: 'agent', agent: 'claude' }, allowEmptyPromptLaunch: true } + }) + ).resolves.toMatchObject({ status: 'failed' }) + // No snapshot refresh follows a failed create. + expect(runtimeCall.mock.calls.map(([request]) => request.method)).toEqual([ + 'status.get', + 'session.tabs.createTerminal' + ]) + }) + + // Security regression: a pre-identity host strips agentLaunch and answers with a + // bare login shell, which the client would report as a launched agent. + it('refuses an identity-only launch against a host without the capability', async () => { + const runtimeCall = withIdentityCapableStatus( + async () => ({ id: 'list', ok: true, result: makeSnapshot() }), + ['agent-session.host-authority.v1'] + ) + + vi.stubGlobal('window', { + api: { runtimeEnvironments: { call: runtimeCall } } + }) + + await expect( + createWebRuntimeSessionTerminal({ + worktreeId: WORKTREE_ID, + agentLaunch: { selection: { kind: 'agent', agent: 'claude' }, allowEmptyPromptLaunch: true } + }) + ).resolves.toEqual({ + status: 'failed', + message: AGENT_LAUNCH_IDENTITY_UNSUPPORTED_MESSAGE + }) + expect(runtimeCall.mock.calls.map(([request]) => request.method)).toEqual(['status.get']) + }) + + it('degrades to the client command when the host lacks the identity capability', async () => { + const runtimeCall = withIdentityCapableStatus( + async (request) => + request.method === 'session.tabs.createTerminal' + ? { + id: 'create-terminal', + ok: true, + result: { + tab: { + type: 'terminal', + id: 'host-tab-2::leaf-1', + parentTabId: 'host-tab-2', + leafId: 'leaf-1', + title: 'Terminal 2', + terminal: 'pty-2', + status: 'ready', + isActive: true + }, + publicationEpoch: 'epoch-1', + snapshotVersion: 2 + } + } + : { id: 'list', ok: true, result: makeSnapshot() }, + ['agent-session.host-authority.v1'] + ) + + vi.stubGlobal('window', { + api: { runtimeEnvironments: { call: runtimeCall } } + }) + + await expect( + createWebRuntimeSessionTerminal({ + worktreeId: WORKTREE_ID, + command: 'claude --resume abc', + agentLaunch: { selection: { kind: 'agent', agent: 'claude' }, allowEmptyPromptLaunch: true } + }) + ).resolves.toEqual({ status: 'created' }) + + const created = runtimeCall.mock.calls.find( + ([request]) => request.method === 'session.tabs.createTerminal' + )?.[0] as unknown as { params: Record } + expect(created.params.command).toBe('claude --resume abc') + // The stripped-on-arrival arm must not ride along on the legacy path. + expect(created.params.agentLaunch).toBeUndefined() + }) + + it('can create a terminal without selecting the target worktree', async () => { + const setStateResults: unknown[] = [] + mocks.setState.mockImplementation((updater: (state: unknown) => unknown) => { + const result = updater({ + state: 'before', + activeWorktreeId: 'main-worktree' + }) + setStateResults.push(result) + }) + const runtimeCall = vi + .fn() + .mockResolvedValueOnce({ + id: 'create-terminal', + ok: true, + result: { + tab: { + type: 'terminal', + id: 'host-tab-2::leaf-1', + parentTabId: 'host-tab-2', + leafId: 'leaf-1', + title: 'Terminal 2', + terminal: 'pty-2', + status: 'ready', + isActive: true + }, + publicationEpoch: 'epoch-1', + snapshotVersion: 2 + } + }) + .mockResolvedValueOnce({ + id: 'list', + ok: true, + result: makeSnapshot() + }) + + vi.stubGlobal('window', { + api: { + runtimeEnvironments: { + call: runtimeCall + } + } + }) + + await expect( + createWebRuntimeSessionTerminal({ + worktreeId: WORKTREE_ID, + activate: true, + selectWorktree: false + }) + ).resolves.toEqual({ status: 'created' }) + + expect(setStateResults).not.toContainEqual({ activeWorktreeId: WORKTREE_ID }) + }) + + it.each(['session.tabs.move', 'session.tabs.list'] as const)( + 'treats %s failure after host creation as accepted so callers do not duplicate the agent', + async (failedMethod) => { + const runtimeCall = vi.fn(async (request: { method: string }) => { + if (request.method === 'status.get') { + return { + id: 'status', + ok: true, + result: { + runtimeId: 'runtime-1', + graphStatus: 'ready', + runtimeProtocolVersion: 3, + minCompatibleRuntimeClientVersion: 2, + capabilities: ['agent-session.host-authority.v1'] + } + } + } + if (request.method === 'terminal.createAgentSession') { + return { + id: 'create', + ok: true, + result: { + terminal: { + id: 'pty-created', + handle: 'term_created', + title: 'Codex', + cwd: '/repo', + worktreeId: WORKTREE_ID, + tabId: 'host-tab-created', + paneKey: `host-tab-created:${FOCUS_LEAF_ID}` + }, + disposition: 'created' + } + } + } + if (request.method === failedMethod) { + throw new Error(`${failedMethod} unavailable`) + } + return { id: 'ok', ok: true, result: makeSnapshot() } + }) + vi.stubGlobal('window', { + api: { runtimeEnvironments: { call: runtimeCall } } + }) + + await expect( + createWebRuntimeSessionTerminal({ + worktreeId: WORKTREE_ID, + targetGroupId: failedMethod === 'session.tabs.move' ? 'group-left' : undefined, + launchAgent: 'codex', + activate: true + }) + ).resolves.toEqual({ status: 'created' }) + + expect( + runtimeCall.mock.calls.filter( + ([request]) => request.method === 'terminal.createAgentSession' + ) + ).toHaveLength(1) + } + ) + + it('replays an ambiguous fresh-create failure with the same operation ID', async () => { + const operationIds: string[] = [] + let createAttempts = 0 + const runtimeCall = vi.fn(async (request: { method: string; params?: unknown }) => { + if (request.method === 'status.get') { + return { + id: 'status', + ok: true, + result: { + runtimeId: 'runtime-1', + graphStatus: 'ready', + runtimeProtocolVersion: 3, + minCompatibleRuntimeClientVersion: 2, + capabilities: ['agent-session.host-authority.v1'] + } + } + } + if (request.method === 'terminal.createAgentSession') { + operationIds.push((request.params as { clientOperationId: string }).clientOperationId) + createAttempts += 1 + if (createAttempts === 1) { + throw new Error('connection closed before response') + } + return { + id: 'create', + ok: true, + result: { + terminal: { + handle: 'term_replayed', + worktreeId: WORKTREE_ID, + tabId: 'host-tab-replayed', + paneKey: `host-tab-replayed:${FOCUS_LEAF_ID}` + }, + disposition: 'replayed' + } + } + } + return { id: 'list', ok: true, result: makeSnapshot() } + }) + vi.stubGlobal('window', { + api: { runtimeEnvironments: { call: runtimeCall } } + }) + + await expect( + createWebRuntimeSessionTerminal({ + worktreeId: WORKTREE_ID, + launchAgent: 'codex', + targetGroupId: 'group-left' + }) + ).resolves.toEqual({ status: 'created' }) + + expect(operationIds).toHaveLength(2) + expect(operationIds[0]).toBe(operationIds[1]) + }) + + it('preserves the legacy fresh-agent path when host authority is unavailable', async () => { + const runtimeCall = vi.fn(async (request: { method: string; params?: unknown }) => { + if (request.method === 'status.get') { + return { + id: 'status', + ok: true, + result: { + runtimeId: 'old-runtime', + graphStatus: 'ready', + runtimeProtocolVersion: 3, + minCompatibleRuntimeClientVersion: 2, + capabilities: [] + } + } + } + if (request.method === 'session.tabs.createTerminal') { + return { + id: 'legacy-create', + ok: true, + result: { + tab: { id: 'legacy-tab-1' }, + publicationEpoch: 'epoch-1', + snapshotVersion: 1 + } + } + } + return { id: 'list', ok: true, result: makeSnapshot() } + }) + vi.stubGlobal('window', { + api: { runtimeEnvironments: { call: runtimeCall } } + }) + + await expect( + createWebRuntimeSessionTerminal({ + worktreeId: WORKTREE_ID, + launchAgent: 'codex', + targetGroupId: 'group-left' + }) + ).resolves.toEqual({ status: 'created' }) + + expect(runtimeCall).toHaveBeenNthCalledWith(2, { + selector: ENVIRONMENT_ID, + method: 'session.tabs.createTerminal', + params: { + worktree: `id:${WORKTREE_ID}`, + afterTabId: undefined, + targetGroupId: 'group-left', + command: undefined, + cwd: undefined, + startupCommandDelivery: undefined, + launchAgent: 'codex', + activate: false, + select: true, + navigation: 'caller' + }, + timeoutMs: 15_000 + }) + expect(runtimeCall.mock.calls.map(([request]) => request.method)).toEqual([ + 'status.get', + 'session.tabs.createTerminal', + 'session.tabs.list' + ]) + }) + + it('preserves the opaque legacy resume payload on an old host', async () => { + const runtimeCall = vi.fn(async (request: { method: string }) => { + if (request.method === 'status.get') { + return { + id: 'status', + ok: true, + result: { + runtimeId: 'old-runtime', + graphStatus: 'ready', + runtimeProtocolVersion: 3, + minCompatibleRuntimeClientVersion: 2, + capabilities: [] + } + } + } + if (request.method === 'session.tabs.createTerminal') { + return { + id: 'legacy-create', + ok: true, + result: { tab: { id: 'legacy-tab-1' }, publicationEpoch: 'epoch-1', snapshotVersion: 1 } + } + } + return { id: 'list', ok: true, result: makeSnapshot() } + }) + vi.stubGlobal('window', { + api: { runtimeEnvironments: { call: runtimeCall } } + }) + + await expect( + createWebRuntimeSessionTerminal({ + worktreeId: WORKTREE_ID, + agentSessionKind: 'resume', + launchAgent: 'codex', + command: "codex resume 'session-1'", + env: { CODEX_PROFILE: 'captured' }, + launchConfig: { + agentCommand: 'codex', + agentArgs: '', + agentEnv: { CODEX_PROFILE: 'captured' } + }, + providerSession: { key: 'session_id', id: 'session-1' } + }) + ).resolves.toEqual({ status: 'created' }) + + expect(runtimeCall).toHaveBeenNthCalledWith(2, { + selector: ENVIRONMENT_ID, + method: 'session.tabs.createTerminal', + params: { + worktree: `id:${WORKTREE_ID}`, + afterTabId: undefined, + targetGroupId: undefined, + command: "codex resume 'session-1'", + cwd: undefined, + env: { CODEX_PROFILE: 'captured' }, + startupCommandDelivery: undefined, + launchConfig: { + agentCommand: 'codex', + agentArgs: '', + agentEnv: { CODEX_PROFILE: 'captured' } + }, + launchAgent: 'codex', + activate: false, + select: true, + navigation: 'caller' + }, + timeoutMs: 15_000 + }) + }) + + it('uses the exact legacy OMP resume when an older host only advertises base authority', async () => { + const methods: string[] = [] + const runtimeCall = vi.fn(async (request: { method: string }) => { + methods.push(request.method) + if (request.method === 'status.get') { + return { + id: 'status', + ok: true, + result: { + runtimeId: 'new-runtime', + graphStatus: 'ready', + runtimeProtocolVersion: 3, + minCompatibleRuntimeClientVersion: 2, + capabilities: ['agent-session.host-authority.v1'] + } + } + } + if (request.method === 'terminal.ensureAgentSession') { + return { + id: 'ensure', + ok: false, + error: { + code: 'invalid_argument', + message: 'old host rejected OMP' + } + } + } + return { + id: 'legacy-create', + ok: true, + result: { tab: { id: 'legacy-tab-1' }, publicationEpoch: 'epoch-1', snapshotVersion: 1 } + } + }) + vi.stubGlobal('window', { api: { runtimeEnvironments: { call: runtimeCall } } }) + + await expect( + createWebRuntimeSessionTerminal({ + worktreeId: WORKTREE_ID, + agentSessionKind: 'resume', + launchAgent: 'omp', + command: "omp --resume '/custom/omp/project/session.jsonl'", + env: { PI_CODING_AGENT_DIR: '/custom/omp' }, + launchConfig: { + agentCommand: 'omp', + agentArgs: '', + agentEnv: { PI_CODING_AGENT_DIR: '/custom/omp' }, + ompResumeFilePath: '/custom/omp/project/session.jsonl' + }, + providerSession: { key: 'session_id', id: 'session-1' } + }) + ).resolves.toEqual({ status: 'created' }) + + expect(methods).toEqual(['status.get', 'session.tabs.createTerminal', 'session.tabs.list']) + expect(runtimeCall.mock.calls[1]?.[0]).toMatchObject({ + params: { + command: "omp --resume '/custom/omp/project/session.jsonl'", + env: { PI_CODING_AGENT_DIR: '/custom/omp' }, + launchAgent: 'omp' + } + }) + }) + + it('delivers generated continuation context after host-authoritative creation', async () => { + const runtimeCall = vi.fn(async (request: { method: string; params?: unknown }) => { + if (request.method === 'status.get') { + return { + id: 'status', + ok: true, + result: { + runtimeId: 'runtime-1', + graphStatus: 'ready', + runtimeProtocolVersion: 3, + minCompatibleRuntimeClientVersion: 2, + capabilities: ['agent-session.host-authority.v1'] + } + } + } + if (request.method === 'terminal.createAgentSession') { + return { + id: 'create', + ok: true, + result: { + terminal: { + handle: 'term_created', + worktreeId: WORKTREE_ID, + tabId: 'host-tab-2', + paneKey: `host-tab-2:${FOCUS_LEAF_ID}` + }, + disposition: 'created' + } + } + } + return { id: 'list', ok: true, result: makeSnapshot() } + }) + vi.stubGlobal('window', { + api: { runtimeEnvironments: { call: runtimeCall } } + }) + + await expect( + createWebRuntimeAgentSessionTerminal({ + worktreeId: WORKTREE_ID, + agentSessionKind: 'fresh', + agent: 'claude', + command: 'claude', + promptAfterReady: 'continue the unfinished task', + submitPrompt: true, + forcePromptPaste: true + }) + ).resolves.toEqual({ outcome: { status: 'created' }, promptDelivered: true }) + + const createRequest = runtimeCall.mock.calls.find( + ([request]) => request.method === 'terminal.createAgentSession' + )?.[0] + expect(createRequest).toMatchObject({ params: { agent: 'claude' } }) + expect(createRequest?.params).not.toHaveProperty('prompt') + expect(mocks.deliverLaunchPromptToAgentTab).toHaveBeenCalledWith({ + tabId: 'web-terminal-host-tab-2', + content: 'continue the unfinished task', + agent: 'claude', + submit: true, + forcePaste: true + }) + }) + + it('seeds the chat composer for a draft that rode in on the launch command', async () => { + const runtimeCall = vi.fn(async (request: { method: string; params?: unknown }) => { + if (request.method === 'status.get') { + return { + id: 'status', + ok: true, + result: { + runtimeId: 'runtime-1', + graphStatus: 'ready', + runtimeProtocolVersion: 3, + minCompatibleRuntimeClientVersion: 2, + capabilities: ['agent-session.host-authority.v1'] + } + } + } + if (request.method === 'terminal.createAgentSession') { + return { + id: 'create', + ok: true, + result: { + terminal: { + handle: 'term_created', + worktreeId: WORKTREE_ID, + tabId: 'host-tab-2', + paneKey: `host-tab-2:${FOCUS_LEAF_ID}` + }, + disposition: 'created' + } + } + } + return { id: 'list', ok: true, result: makeSnapshot() } + }) + vi.stubGlobal('window', { + api: { runtimeEnvironments: { call: runtimeCall } } + }) + + await expect( + createWebRuntimeAgentSessionTerminalWithLaunchDraft({ + worktreeId: WORKTREE_ID, + agentSessionKind: 'fresh', + agent: 'claude', + command: "claude --prefill 'https://github.com/o/r/issues/12'", + launchDraft: 'https://github.com/o/r/issues/12' + }) + ).resolves.toEqual({ status: 'created' }) + + // No paste runs for an argv-prefill draft, so this is the only thing that + // fills the mirrored tab's composer on this host class. + expect(mocks.deliverLaunchPromptToAgentTab).not.toHaveBeenCalled() + expect(mocks.seedNativeChatLaunchDraftForAgentTab).toHaveBeenCalledWith({ + tabId: 'web-terminal-host-tab-2', + agent: 'claude', + text: 'https://github.com/o/r/issues/12' + }) + }) +}) + +describe('moveWebRuntimeSessionTab', () => { + beforeEach(() => { + vi.stubGlobal('__ORCA_WEB_CLIENT__', true) + mocks.getState.mockReturnValue({ + settings: { + activeRuntimeEnvironmentId: ENVIRONMENT_ID + }, + setActiveWorktree: mocks.setActiveWorktree + }) + mocks.applyFreshWebSessionTabsSnapshot.mockReturnValue({ state: 'after' }) + }) + + afterEach(() => { + resetWebSessionFocusIntentForTests() + vi.unstubAllGlobals() + vi.clearAllMocks() + }) + + it('moves paired web tabs through the host session API without an eager stale refresh', async () => { + const runtimeCall = vi.fn().mockResolvedValueOnce({ + id: 'move', + ok: true, + result: { moved: true } + }) + + vi.stubGlobal('window', { + api: { + runtimeEnvironments: { + call: runtimeCall + } + } + }) + + await expect( + moveWebRuntimeSessionTab({ + worktreeId: WORKTREE_ID, + tabId: 'web-terminal-host-tab-1%3A%3Aleaf-1', + targetGroupId: 'group-right', + kind: 'split', + splitDirection: 'right' + }) + ).resolves.toBe(true) + + expect(runtimeCall).toHaveBeenNthCalledWith(1, { + selector: ENVIRONMENT_ID, + method: 'session.tabs.move', + params: { + worktree: `id:${WORKTREE_ID}`, + tabId: 'host-tab-1::leaf-1', + targetGroupId: 'group-right', + kind: 'split', + splitDirection: 'right' + }, + timeoutMs: 15_000 + }) + expect(runtimeCall).toHaveBeenCalledTimes(1) + expect(mocks.applyFreshWebSessionTabsSnapshot).not.toHaveBeenCalled() + }) + + it('maps mirrored local browser unified ids back to host session tab ids', async () => { + mocks.resolveHostSessionTabIdForWebSessionTab.mockImplementation( + (_state, args: { tabId: string }) => + args.tabId === 'local-browser-unified' + ? 'host-browser-unified' + : args.tabId === 'local-terminal-unified' + ? 'host-terminal' + : null + ) + const runtimeCall = vi.fn().mockResolvedValueOnce({ + id: 'move', + ok: true, + result: { moved: true } + }) + + vi.stubGlobal('window', { + api: { + runtimeEnvironments: { + call: runtimeCall + } + } + }) + + await expect( + moveWebRuntimeSessionTab({ + worktreeId: WORKTREE_ID, + tabId: 'local-browser-unified', + targetGroupId: 'group-right', + kind: 'reorder', + tabOrder: ['local-terminal-unified', 'local-only-unified', 'local-browser-unified'] + }) + ).resolves.toBe(true) + + expect(runtimeCall).toHaveBeenCalledWith({ + selector: ENVIRONMENT_ID, + method: 'session.tabs.move', + params: { + worktree: `id:${WORKTREE_ID}`, + tabId: 'host-browser-unified', + targetGroupId: 'group-right', + kind: 'reorder', + tabOrder: ['host-terminal', 'host-browser-unified'] + }, + timeoutMs: 15_000 + }) + }) + + it('counts only host-backed tabs for mirrored move-to-group indexes', async () => { + mocks.getState.mockReturnValue({ + settings: { + activeRuntimeEnvironmentId: ENVIRONMENT_ID + }, + setActiveWorktree: mocks.setActiveWorktree, + groupsByWorktree: { + [WORKTREE_ID]: [ + { + id: 'group-right', + activeTabId: 'local-only-unified', + tabOrder: ['local-only-unified', 'local-terminal-unified'] + } + ] + } + }) + mocks.resolveHostSessionTabIdForWebSessionTab.mockImplementation( + (_state, args: { tabId: string }) => + args.tabId === 'local-browser-unified' + ? 'host-browser-unified' + : args.tabId === 'local-terminal-unified' + ? 'host-terminal' + : null + ) + const runtimeCall = vi.fn().mockResolvedValueOnce({ + id: 'move', + ok: true, + result: { moved: true } + }) + + vi.stubGlobal('window', { + api: { + runtimeEnvironments: { + call: runtimeCall + } + } + }) + + await expect( + moveWebRuntimeSessionTab({ + worktreeId: WORKTREE_ID, + tabId: 'local-browser-unified', + targetGroupId: 'group-right', + kind: 'move-to-group', + index: 1 + }) + ).resolves.toBe(true) + + expect(runtimeCall).toHaveBeenCalledWith({ + selector: ENVIRONMENT_ID, + method: 'session.tabs.move', + params: { + worktree: `id:${WORKTREE_ID}`, + tabId: 'host-browser-unified', + targetGroupId: 'group-right', + kind: 'move-to-group', + index: 0 + }, + timeoutMs: 15_000 + }) + }) + + it('does not mirror a reorder when the dragged tab is local-only', async () => { + mocks.resolveHostSessionTabIdForWebSessionTab.mockImplementation( + (_state, args: { tabId: string }) => + args.tabId === 'local-terminal-unified' ? 'host-terminal' : null + ) + const runtimeCall = vi.fn().mockResolvedValueOnce({ + id: 'move', + ok: true, + result: { moved: true } + }) + + vi.stubGlobal('window', { + api: { + runtimeEnvironments: { + call: runtimeCall + } + } + }) + + await expect( + moveWebRuntimeSessionTab({ + worktreeId: WORKTREE_ID, + tabId: 'local-only-unified', + targetGroupId: 'group-right', + kind: 'reorder', + tabOrder: ['local-only-unified', 'local-terminal-unified'] + }) + ).resolves.toBe(false) + + expect(runtimeCall).not.toHaveBeenCalled() + }) +}) + +describe('web runtime session tab actions', () => { + beforeEach(() => { + vi.stubGlobal('__ORCA_WEB_CLIENT__', true) + mocks.getState.mockReturnValue({ + settings: { + activeRuntimeEnvironmentId: ENVIRONMENT_ID + }, + setActiveWorktree: mocks.setActiveWorktree + }) + mocks.resolveHostSessionTabIdForWebSessionTab.mockImplementation( + (_state, args: { tabId: string }) => + args.tabId === 'local-browser-unified' ? 'host-browser-unified' : null + ) + }) + + afterEach(() => { + resetWebSessionFocusIntentForTests() + vi.unstubAllGlobals() + vi.clearAllMocks() + }) + + it('maps mirrored local browser unified ids for activate and close', async () => { + const runtimeCall = vi + .fn() + .mockResolvedValueOnce({ + id: 'activate', + ok: true, + result: {} + }) + .mockResolvedValueOnce({ + id: 'close', + ok: true, + result: {} + }) + .mockResolvedValueOnce({ + id: 'list', + ok: true, + result: makeSnapshot() + }) + + vi.stubGlobal('window', { + api: { + runtimeEnvironments: { + call: runtimeCall + } + } + }) + + await expect( + activateWebRuntimeSessionTab({ + worktreeId: WORKTREE_ID, + tabId: 'local-browser-unified' + }) + ).resolves.toBe(true) + await expect( + closeWebRuntimeSessionTab({ + worktreeId: WORKTREE_ID, + tabId: 'local-browser-unified', + reason: 'user' + }) + ).resolves.toBe(true) + + expect(runtimeCall).toHaveBeenNthCalledWith(1, { + selector: ENVIRONMENT_ID, + method: 'session.tabs.activate', + params: { + worktree: `id:${WORKTREE_ID}`, + tabId: 'host-browser-unified', + notifyClients: false, + navigation: 'caller', + intent: 'user' + }, + timeoutMs: 15_000 + }) + expect(runtimeCall).toHaveBeenNthCalledWith(2, { + selector: ENVIRONMENT_ID, + method: 'session.tabs.close', + params: { + worktree: `id:${WORKTREE_ID}`, + tabId: 'host-browser-unified', + reason: 'user' + }, + timeoutMs: 15_000 + }) + expect(runtimeCall).toHaveBeenNthCalledWith(3, { + selector: ENVIRONMENT_ID, + method: 'session.tabs.list', + params: { + worktree: `id:${WORKTREE_ID}` + }, + timeoutMs: 15_000 + }) + expect(mocks.applyFreshWebSessionTabsSnapshot).toHaveBeenCalled() + }) + + it('supersedes browser focus intent when a terminal is activated next', async () => { + mocks.resolveHostSessionTabIdForWebSessionTab.mockImplementation( + (_state, args: { tabId: string }) => + args.tabId === 'local-browser-unified' ? 'host-browser-unified' : 'host-terminal' + ) + const runtimeCall = vi.fn().mockResolvedValue({ id: 'activate', ok: true, result: {} }) + vi.stubGlobal('window', { api: { runtimeEnvironments: { call: runtimeCall } } }) + + await activateWebRuntimeSessionTab({ + worktreeId: WORKTREE_ID, + tabId: 'local-browser-unified' + }) + await activateWebRuntimeSessionTab({ worktreeId: WORKTREE_ID, tabId: 'local-terminal' }) + + expect(peekWebSessionFocusIntent({ environmentId: ENVIRONMENT_ID }, WORKTREE_ID)).toEqual({ + hostTabId: 'host-terminal' + }) + }) + + it('sends lifecycle and explicit user close reasons on the wire', async () => { + const runtimeCall = vi + .fn() + .mockResolvedValueOnce({ id: 'close-1', ok: true, result: {} }) + .mockResolvedValueOnce({ id: 'list-1', ok: true, result: makeSnapshot() }) + .mockResolvedValueOnce({ id: 'close-2', ok: true, result: {} }) + .mockResolvedValueOnce({ id: 'list-2', ok: true, result: makeSnapshot() }) + vi.stubGlobal('window', { + api: { + runtimeEnvironments: { + call: runtimeCall + } + } + }) + + await expect( + closeWebRuntimeSessionTab({ + worktreeId: WORKTREE_ID, + tabId: 'local-browser-unified', + reason: 'pty-exit', + publicationEpoch: 'epoch-1', + terminalHandle: 'term-1' + }) + ).resolves.toBe(true) + await expect( + closeWebRuntimeSessionTab({ + worktreeId: WORKTREE_ID, + tabId: 'local-browser-unified', + reason: 'user' + }) + ).resolves.toBe(true) + + expect(runtimeCall).toHaveBeenNthCalledWith(1, { + selector: ENVIRONMENT_ID, + method: 'session.tabs.closeLifecycle', + params: { + worktree: `id:${WORKTREE_ID}`, + tabId: 'host-browser-unified', + reason: 'pty-exit', + publicationEpoch: 'epoch-1', + terminal: 'term-1' + }, + timeoutMs: 15_000 + }) + expect(runtimeCall).toHaveBeenNthCalledWith(3, { + selector: ENVIRONMENT_ID, + method: 'session.tabs.close', + params: { + worktree: `id:${WORKTREE_ID}`, + tabId: 'host-browser-unified', + reason: 'user' + }, + timeoutMs: 15_000 + }) + }) + + it('suppresses lifecycle closes when terminal-incarnation evidence is missing', async () => { + const runtimeCall = vi.fn().mockResolvedValueOnce({ + id: 'list', + ok: true, + result: makeSnapshot() + }) + vi.stubGlobal('window', { + api: { runtimeEnvironments: { call: runtimeCall } } + }) + + await expect( + closeWebRuntimeSessionTab({ + worktreeId: WORKTREE_ID, + tabId: 'local-browser-unified', + reason: 'pty-exit' + }) + ).resolves.toBe(false) + + expect(runtimeCall).toHaveBeenCalledTimes(1) + expect(runtimeCall).toHaveBeenCalledWith( + expect.objectContaining({ method: 'session.tabs.list' }) + ) + expect(mocks.acceptReplayedWebSessionTabsSnapshot).toHaveBeenCalledWith( + ENVIRONMENT_ID, + WORKTREE_ID + ) + }) + + it('fails closed when reconnect routes a lifecycle close to an older host', async () => { + const runtimeCall = vi + .fn() + .mockResolvedValueOnce({ + id: 'close', + ok: false, + error: { + code: 'method_not_found', + message: 'Unknown method: session.tabs.closeLifecycle' + } + }) + .mockResolvedValueOnce({ id: 'list', ok: true, result: makeSnapshot() }) + vi.stubGlobal('window', { + api: { runtimeEnvironments: { call: runtimeCall } } + }) + + await expect( + closeWebRuntimeSessionTab({ + worktreeId: WORKTREE_ID, + tabId: 'local-browser-unified', + reason: 'pty-exit', + publicationEpoch: 'epoch-1', + terminalHandle: 'term-1' + }) + ).resolves.toBe(false) + + expect(runtimeCall).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ method: 'session.tabs.closeLifecycle' }) + ) + expect(runtimeCall).not.toHaveBeenCalledWith( + expect.objectContaining({ method: 'session.tabs.close' }) + ) + expect(mocks.acceptReplayedWebSessionTabsSnapshot).toHaveBeenCalledWith( + ENVIRONMENT_ID, + WORKTREE_ID + ) + expect( + isWebSessionCloseIntentPending( + { environmentId: ENVIRONMENT_ID }, + WORKTREE_ID, + 'host-browser-unified', + Date.now() + ) + ).toBe(false) + }) + + it('restores reconciliation authority when the host refuses a lifecycle close', async () => { + const authoritative = makeSnapshot() + authoritative.snapshotVersion = 6 + const runtimeCall = vi + .fn() + .mockResolvedValueOnce({ + id: 'close', + ok: true, + result: { closed: true, refused: true, snapshotRepublished: true } + }) + .mockResolvedValueOnce({ id: 'list', ok: true, result: authoritative }) + + vi.stubGlobal('window', { + api: { + runtimeEnvironments: { + call: runtimeCall + } + } + }) + + await expect( + closeWebRuntimeSessionTab({ + worktreeId: WORKTREE_ID, + tabId: 'local-browser-unified', + reason: 'pty-exit', + publicationEpoch: 'epoch-1', + terminalHandle: 'term-1' + }) + ).resolves.toBe(true) + + expect( + isWebSessionCloseIntentPending( + { environmentId: ENVIRONMENT_ID }, + WORKTREE_ID, + 'host-browser-unified', + Date.now() + ) + ).toBe(false) + expect(mocks.acceptReplayedWebSessionTabsSnapshot).toHaveBeenCalledWith( + ENVIRONMENT_ID, + WORKTREE_ID + ) + expect(mocks.acceptReplayedWebSessionTabsSnapshot.mock.invocationCallOrder[0]).toBeLessThan( + mocks.applyFreshWebSessionTabsSnapshot.mock.invocationCallOrder[0]! + ) + }) + + it('keeps the close intent when a refused lifecycle close was not republished', async () => { + const runtimeCall = vi + .fn() + .mockResolvedValueOnce({ + id: 'close', + ok: true, + result: { closed: true, refused: true } + }) + .mockResolvedValueOnce({ id: 'list', ok: true, result: makeSnapshot() }) + + vi.stubGlobal('window', { + api: { + runtimeEnvironments: { + call: runtimeCall + } + } + }) + + recordWebSessionCloseIntent( + { environmentId: ENVIRONMENT_ID }, + WORKTREE_ID, + 'other-host-tab', + Date.now() + ) + await expect( + closeWebRuntimeSessionTab({ + worktreeId: WORKTREE_ID, + tabId: 'local-browser-unified', + reason: 'pty-exit', + publicationEpoch: 'epoch-1', + terminalHandle: 'term-1' + }) + ).resolves.toBe(true) + + expect( + isWebSessionCloseIntentPending( + { environmentId: ENVIRONMENT_ID }, + WORKTREE_ID, + 'host-browser-unified', + Date.now() + ) + ).toBe(true) + expect( + isWebSessionCloseIntentPending( + { environmentId: ENVIRONMENT_ID }, + WORKTREE_ID, + 'other-host-tab', + Date.now() + ) + ).toBe(true) + expect(mocks.acceptReplayedWebSessionTabsSnapshot).not.toHaveBeenCalled() + }) + + it('clears an optimistic close intent when pairing CAS rejects the host call', async () => { + const runtimeCall = vi.fn().mockResolvedValue({ + id: 'close-rejected', + ok: false, + error: { code: 'conflict', message: 'runtime_environment_replaced' } + }) + vi.stubGlobal('window', { api: { runtimeEnvironments: { call: runtimeCall } } }) + + await expect( + closeWebRuntimeSessionTab({ + worktreeId: WORKTREE_ID, + tabId: 'local-browser-unified', + reason: 'user' + }) + ).resolves.toBe(false) + + expect( + isWebSessionCloseIntentPending( + { environmentId: ENVIRONMENT_ID }, + WORKTREE_ID, + 'host-browser-unified', + Date.now() + ) + ).toBe(false) + }) +}) + +describe('splitWebRuntimeTerminal', () => { + beforeEach(() => { + vi.stubGlobal('__ORCA_WEB_CLIENT__', true) + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.clearAllMocks() + }) + + it('passes telemetry source to the host split while allowing the mirrored split event to be suppressed', async () => { + const runtimeCall = vi.fn().mockResolvedValue({ + id: 'split', + ok: true, + result: { + split: { + handle: 'terminal-2', + tabId: 'tab-1', + paneRuntimeId: -1 + } + } + }) + vi.stubGlobal('window', { + api: { + runtimeEnvironments: { + call: runtimeCall + } + } + }) + + expect(splitWebRuntimeTerminal('remote:web-env-1@@terminal-1', 'horizontal', 'keyboard')).toBe( + true + ) + expect( + consumePendingWebRuntimeSplitMirrorTelemetry('remote:web-env-1@@terminal-other', 'horizontal') + ).toBe(false) + expect( + consumePendingWebRuntimeSplitMirrorTelemetry('remote:web-env-1@@terminal-1', 'horizontal') + ).toBe(true) + + await vi.waitFor(() => expect(runtimeCall).toHaveBeenCalledTimes(1)) + expect(runtimeCall).toHaveBeenCalledWith({ + selector: 'web-env-1', + method: 'terminal.split', + params: { + terminal: 'terminal-1', + direction: 'horizontal', + telemetrySource: 'keyboard' + }, + timeoutMs: 15_000 + }) + }) + + it('does not track rejected host split RPCs', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const runtimeCall = vi.fn().mockResolvedValue({ + id: 'split', + ok: false, + error: { code: 'terminal_exited', message: 'Terminal exited' } + }) + vi.stubGlobal('window', { + api: { + runtimeEnvironments: { + call: runtimeCall + } + } + }) + + expect( + splitWebRuntimeTerminal('remote:web-env-1@@terminal-1', 'vertical', 'context_menu') + ).toBe(true) + + await vi.waitFor(() => expect(runtimeCall).toHaveBeenCalledTimes(1)) + await vi.waitFor(() => expect(warnSpy).toHaveBeenCalledTimes(1)) + expect(mocks.trackTerminalPaneSplit).not.toHaveBeenCalled() + }) + + it('ignores local panes but delegates remote runtime panes from desktop or web clients', async () => { + const runtimeCall = vi.fn().mockResolvedValue({ + id: 'split', + ok: true, + result: { + split: { + handle: 'terminal-2', + tabId: 'tab-1', + ptyId: 'pty-2' + } + } + }) + vi.stubGlobal('window', { + api: { + runtimeEnvironments: { + call: runtimeCall + } + } + }) + + expect(splitWebRuntimeTerminal('pty-local-1', 'horizontal', 'keyboard')).toBe(false) + vi.stubGlobal('__ORCA_WEB_CLIENT__', false) + expect(splitWebRuntimeTerminal('remote:web-env-1@@terminal-1', 'horizontal', 'keyboard')).toBe( + true + ) + + await vi.waitFor(() => expect(runtimeCall).toHaveBeenCalledTimes(1)) + }) +}) + +describe('closeWebRuntimeTerminal', () => { + beforeEach(() => { + vi.stubGlobal('__ORCA_WEB_CLIENT__', true) + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.clearAllMocks() + }) + + it('delegates remote pane close to the host runtime', async () => { + const runtimeCall = vi.fn().mockResolvedValue({ + id: 'close', + ok: true, + result: { + close: { + handle: 'terminal-1', + tabId: 'tab-1', + ptyKilled: true + } + } + }) + vi.stubGlobal('window', { + api: { + runtimeEnvironments: { + call: runtimeCall + } + } + }) + + expect(closeWebRuntimeTerminal('remote:web-env-1@@terminal-1')).toBe(true) + + await vi.waitFor(() => expect(runtimeCall).toHaveBeenCalledTimes(1)) + expect(runtimeCall).toHaveBeenCalledWith({ + selector: 'web-env-1', + method: 'terminal.close', + params: { + terminal: 'terminal-1' + }, + timeoutMs: 15_000 + }) + }) + + it('ignores local panes but delegates remote runtime panes from desktop or web clients', async () => { + const runtimeCall = vi.fn().mockResolvedValue({ + id: 'close', + ok: true, + result: { + close: { + handle: 'terminal-1', + tabId: 'tab-1', + ptyKilled: true + } + } + }) + vi.stubGlobal('window', { + api: { + runtimeEnvironments: { + call: runtimeCall + } + } + }) + + expect(closeWebRuntimeTerminal('pty-local-1')).toBe(false) + vi.stubGlobal('__ORCA_WEB_CLIENT__', false) + expect(closeWebRuntimeTerminal('remote:web-env-1@@terminal-1')).toBe(true) + + await vi.waitFor(() => expect(runtimeCall).toHaveBeenCalledTimes(1)) + }) + + it('treats any configured remote runtime environment as a shared session', () => { + vi.stubGlobal('__ORCA_WEB_CLIENT__', false) + + expect(isWebRuntimeSessionActive('env-1')).toBe(true) + expect(isWebRuntimeSessionActive(' ')).toBe(false) + expect(isWebRuntimeSessionActive(null)).toBe(false) + }) +}) + +describe('setWebRuntimeTabProps', () => { + afterEach(() => { + vi.unstubAllGlobals() + vi.clearAllMocks() + }) + + it('pushes pin to the host via session.tabs.setTabProps for a remote tab', async () => { + vi.stubGlobal('__ORCA_WEB_CLIENT__', false) + mocks.getRuntimeEnvironmentIdForWorktree.mockReturnValue(ENVIRONMENT_ID) + mocks.getState.mockReturnValue({}) + const runtimeCall = vi.fn().mockResolvedValue({ id: 'p', ok: true, result: { updated: true } }) + vi.stubGlobal('window', { api: { runtimeEnvironments: { call: runtimeCall } } }) + + expect( + setWebRuntimeTabProps({ + worktreeId: WORKTREE_ID, + tabId: 'web-terminal-host-tab-1', + isPinned: true + }) + ).toBe(true) + + await vi.waitFor(() => expect(runtimeCall).toHaveBeenCalledTimes(1)) + expect(runtimeCall).toHaveBeenCalledWith({ + selector: ENVIRONMENT_ID, + method: 'session.tabs.setTabProps', + params: { + worktree: `id:${WORKTREE_ID}`, + tabId: 'host-tab-1', + isPinned: true + }, + timeoutMs: 15_000 + }) + }) + + it('maps mirrored browser/editor unified ids before setting host tab props', async () => { + vi.stubGlobal('__ORCA_WEB_CLIENT__', false) + mocks.getRuntimeEnvironmentIdForWorktree.mockReturnValue(ENVIRONMENT_ID) + mocks.getState.mockReturnValue({}) + mocks.resolveHostSessionTabIdForWebSessionTab.mockImplementation( + (_state, args: { tabId: string }) => + args.tabId === 'local-browser-unified' ? 'host-browser-unified' : null + ) + const runtimeCall = vi.fn().mockResolvedValue({ id: 'p', ok: true, result: { updated: true } }) + vi.stubGlobal('window', { api: { runtimeEnvironments: { call: runtimeCall } } }) + + expect( + setWebRuntimeTabProps({ + worktreeId: WORKTREE_ID, + tabId: 'local-browser-unified', + color: '#3b82f6' + }) + ).toBe(true) + + await vi.waitFor(() => expect(runtimeCall).toHaveBeenCalledTimes(1)) + expect(runtimeCall).toHaveBeenCalledWith({ + selector: ENVIRONMENT_ID, + method: 'session.tabs.setTabProps', + params: { + worktree: `id:${WORKTREE_ID}`, + tabId: 'host-browser-unified', + color: '#3b82f6' + }, + timeoutMs: 15_000 + }) + }) + + it('no-ops for a worktree with no runtime environment (local tab)', () => { + vi.stubGlobal('__ORCA_WEB_CLIENT__', false) + mocks.getRuntimeEnvironmentIdForWorktree.mockReturnValue(null) + mocks.getState.mockReturnValue({}) + const runtimeCall = vi.fn() + vi.stubGlobal('window', { api: { runtimeEnvironments: { call: runtimeCall } } }) + + expect( + setWebRuntimeTabProps({ worktreeId: WORKTREE_ID, tabId: 'local-tab', color: '#fff' }) + ).toBe(false) + expect(runtimeCall).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/runtime/web-runtime-session.ts b/src/renderer/src/runtime/web-runtime-session.ts index fecd8aa0c32..bc5532ffd93 100644 --- a/src/renderer/src/runtime/web-runtime-session.ts +++ b/src/renderer/src/runtime/web-runtime-session.ts @@ -158,6 +158,7 @@ import { listRemoteRuntimeSessionTabsDeduped } from './remote-runtime-session-tabs-inflight' import { runRemoteAgentSessionLaunch } from './remote-agent-session-launch' +import { negotiateAgentLaunchIdentityArm } from './agent-launch-identity-negotiation' import { translate } from '../i18n/i18n' import { getRuntimeEnvironmentRevision } from './runtime-environment-revision' import { parsePaneKey } from '../../../shared/stable-pane-id' @@ -1802,7 +1803,13 @@ async function createWebRuntimeSessionTerminalResult( let createdTabId: string | undefined let createdLeafId: string | undefined try { - if (args.agentLaunch) { + // Negotiate before sending: a pre-identity host strips agentLaunch and spawns + // a bare shell it still answers as a created terminal. Vault resume keeps its + // client-assembled command for exactly this case; without one, refuse. + const agentLaunchArm = args.agentLaunch + ? await negotiateAgentLaunchIdentityArm(environmentId, Boolean(args.command)) + : 'legacy' + if (args.agentLaunch && agentLaunchArm === 'identity') { const response = await callEnvironment({ method: 'session.tabs.createTerminal', params: { diff --git a/src/renderer/src/web/preload-api/web-preferences-store.ts b/src/renderer/src/web/preload-api/web-preferences-store.ts index 13c755b1d87..e33a0cbd16d 100644 --- a/src/renderer/src/web/preload-api/web-preferences-store.ts +++ b/src/renderer/src/web/preload-api/web-preferences-store.ts @@ -112,7 +112,7 @@ export async function getRuntimeBackedStoredSettings(): Promise try { const result = await callRuntimeResult<{ settings: Partial }>( 'settings.get', - undefined, + { includeAgentCatalog: false }, 15_000 ) const runtimeSettings: Partial = {} diff --git a/src/renderer/src/web/preload-api/web-settings-api.ts b/src/renderer/src/web/preload-api/web-settings-api.ts index 96f1db54ff6..ce321cdf5f9 100644 --- a/src/renderer/src/web/preload-api/web-settings-api.ts +++ b/src/renderer/src/web/preload-api/web-settings-api.ts @@ -1,10 +1,12 @@ import type { PreloadApi } from '../../../../preload/api-types' +import type { GlobalSettings } from '../../../../shared/global-settings-types' import { computerAwakeSettingsForMode, normalizeComputerAwakeMode } from '../../../../shared/computer-awake-mode' import { normalizeTerminalCursorStyleDefault } from '../../../../shared/terminal-cursor-style-settings' import { mergeSettings } from './web-preference-normalization' +import { createWebAgentCatalogSync } from '../web-agent-catalog-sync' import { getRuntimeBackedStoredSettings, getStoredSettings, @@ -19,8 +21,23 @@ import { resolveEnvironment, webRuntimeState } from './web-runtime-session' +import { callRuntimeEnvelope } from './web-runtime-calls' import { noopUnsubscribe } from './web-storage' +const webSettingsListeners = new Set<(updates: Partial) => void>() + +function notifyWebSettingsListeners(updates: Partial): void { + for (const listener of Array.from(webSettingsListeners)) { + listener(updates) + } +} + +export const webAgentCatalogSync = createWebAgentCatalogSync({ + call: (method, params) => callRuntimeEnvelope(method, params, 15_000), + isPaired: () => requireActiveEnvironmentOrNull() !== null, + onRevisionApplied: (revision) => notifyWebSettingsListeners({ agentCatalogRevision: revision }) +}) + export function createWebSettingsApi(): Partial { return { settings: { @@ -99,9 +116,14 @@ export function createWebSettingsApi(): Partial { }, updatePRBotAuthorOverride: (args) => updateRuntimePRBotAuthorOverride(args), listFonts: () => Promise.resolve([]), - onChanged: () => noopUnsubscribe, + onChanged: (callback) => { + webSettingsListeners.add(callback) + return () => { + webSettingsListeners.delete(callback) + } + }, agentCatalog: { - getLocal: () => Promise.reject(new Error('not_available_on_paired_web')), + getLocal: () => webAgentCatalogSync.getLocal(), mutate: () => Promise.reject(new Error('not_available_on_paired_web')), getLocalDraft: () => Promise.reject(new Error('not_available_on_paired_web')), referenceSummary: () => Promise.reject(new Error('not_available_on_paired_web')), diff --git a/src/renderer/src/web/web-agent-catalog-sync.test.ts b/src/renderer/src/web/web-agent-catalog-sync.test.ts new file mode 100644 index 00000000000..aeb0af980a0 --- /dev/null +++ b/src/renderer/src/web/web-agent-catalog-sync.test.ts @@ -0,0 +1,214 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' +import type { AgentCatalogSnapshot } from '../../../shared/agent-catalog-snapshot' +import { createWebAgentCatalogSync, projectWebAgentCatalog } from './web-agent-catalog-sync' + +const CUSTOM_ID = 'custom-agent:codex:01234567-89ab-4cde-8f01-23456789abcd' + +function hostSnapshot(overrides: Partial = {}): AgentCatalogSnapshot { + return { + version: 1, + revision: 7, + defaultAgent: CUSTOM_ID, + disabledAgents: ['gemini'], + customAgents: [ + { + id: CUSTOM_ID, + baseAgent: 'codex', + label: 'Reviewer', + args: '--review', + syncEnv: true, + status: 'ready', + envState: 'available', + availabilityCheck: 'host-preflight' + } + ], + deletedCustomAgents: [], + ...overrides + } as AgentCatalogSnapshot +} + +function ok(result: unknown): RuntimeRpcResponse { + return { id: 'call-1', ok: true, result, _meta: { runtimeId: 'runtime-1' } } +} + +function failure(code: string): RuntimeRpcResponse { + return { + id: 'call-1', + ok: false, + error: { code, message: code }, + _meta: { runtimeId: 'runtime-1' } + } +} + +describe('web agent catalog sync', () => { + it('projects the host catalog into the local snapshot shape the desktop UI reads', async () => { + const call = vi.fn(async (method: string) => + method === 'settings.agentCatalog.get' + ? ok({ agentCatalog: hostSnapshot() }) + : failure('method_not_found') + ) + const sync = createWebAgentCatalogSync({ + call, + isPaired: () => true, + onRevisionApplied: vi.fn() + }) + + const snapshot = await sync.getLocal() + + expect(snapshot.revision).toBe(7) + expect(snapshot.defaultAgent).toBe(CUSTOM_ID) + expect(snapshot.disabledAgents).toEqual(['gemini']) + expect(snapshot.customAgents).toEqual([ + { + status: 'ready', + definition: { + id: CUSTOM_ID, + baseAgent: 'codex', + label: 'Reviewer', + args: '--review', + syncEnv: true + }, + envSummary: { entryCount: 0, bytes: 0 }, + availabilityReason: 'custom-path' + } + ]) + // One fetch is cached for every consumer of the shared catalog store. + await sync.getLocal() + expect(call).toHaveBeenCalledTimes(1) + }) + + it('falls back to the settings.get piggyback on a host without the dedicated read', async () => { + const call = vi.fn(async (method: string) => + method === 'settings.agentCatalog.get' + ? failure('method_not_found') + : ok({ settings: {}, agentCatalog: hostSnapshot({ revision: 3 }) }) + ) + const sync = createWebAgentCatalogSync({ + call, + isPaired: () => true, + onRevisionApplied: vi.fn() + }) + + const snapshot = await sync.getLocal() + + expect(snapshot.revision).toBe(3) + expect(snapshot.customAgents).toHaveLength(1) + expect(call.mock.calls.map(([method]) => method)).toEqual([ + 'settings.agentCatalog.get', + 'settings.get' + ]) + }) + + it('degrades to the built-in catalog when the host publishes none', async () => { + const call = vi.fn(async (method: string) => + method === 'settings.agentCatalog.get' ? failure('method_not_found') : ok({ settings: {} }) + ) + const sync = createWebAgentCatalogSync({ + call, + isPaired: () => true, + onRevisionApplied: vi.fn() + }) + + const snapshot = await sync.getLocal() + + expect(snapshot).toMatchObject({ + version: 1, + revision: 0, + defaultAgent: null, + disabledAgents: [], + customAgents: [], + projection: { status: 'ready' } + }) + }) + + it('keeps the cached catalog through a transient failure', async () => { + let dedicated: RuntimeRpcResponse = ok({ agentCatalog: hostSnapshot() }) + const sync = createWebAgentCatalogSync({ + call: async () => dedicated, + isPaired: () => true, + onRevisionApplied: vi.fn() + }) + await sync.getLocal() + + dedicated = failure('runtime_unavailable') + sync.announceRevision(8) + await Promise.resolve() + await Promise.resolve() + + expect((await sync.getLocal()).revision).toBe(7) + }) + + it('refetches on an announced revision and notifies so consumers reload', async () => { + let snapshot = hostSnapshot() + const call = vi.fn(async () => ok({ agentCatalog: snapshot })) + const onRevisionApplied = vi.fn() + const sync = createWebAgentCatalogSync({ call, isPaired: () => true, onRevisionApplied }) + expect((await sync.getLocal()).revision).toBe(7) + + snapshot = hostSnapshot({ revision: 9, customAgents: [] }) + sync.announceRevision(9) + await vi.waitFor(() => expect(onRevisionApplied).toHaveBeenCalledWith(9)) + + const next = await sync.getLocal() + expect(next.revision).toBe(9) + expect(next.customAgents).toEqual([]) + // An already-applied revision costs no round-trip. + sync.announceRevision(9) + expect(call).toHaveBeenCalledTimes(2) + }) + + it('answers with the built-in catalog while unpaired instead of calling the runtime', async () => { + const call = vi.fn() + const sync = createWebAgentCatalogSync({ + call, + isPaired: () => false, + onRevisionApplied: vi.fn() + }) + + expect((await sync.getLocal()).customAgents).toEqual([]) + expect(call).not.toHaveBeenCalled() + }) + + it('reports the oversize projection without inventing custom agents', () => { + const snapshot = projectWebAgentCatalog({ + version: 1, + revision: 12, + code: 'agent_catalog_payload_too_large', + maxBytes: 524_288 + }) + + expect(snapshot.revision).toBe(12) + expect(snapshot.customAgents).toEqual([]) + expect(snapshot.projection.status).toBe('too-large') + }) + + it('keeps a repair-required row visible without a local repair token', () => { + const snapshot = projectWebAgentCatalog( + hostSnapshot({ + customAgents: [ + { + id: CUSTOM_ID, + baseAgent: 'codex', + label: null, + status: 'repair-required', + envState: 'none' + } + ] + }) + ) + + expect(snapshot.customAgents).toEqual([ + { + status: 'repair-required', + id: CUSTOM_ID, + baseAgent: 'codex', + label: null, + repairToken: '', + issues: [], + rawBytes: 0, + draftAvailability: 'too-large' + } + ]) + }) +}) diff --git a/src/renderer/src/web/web-agent-catalog-sync.ts b/src/renderer/src/web/web-agent-catalog-sync.ts new file mode 100644 index 00000000000..2d96fcb7022 --- /dev/null +++ b/src/renderer/src/web/web-agent-catalog-sync.ts @@ -0,0 +1,207 @@ +// Why: paired web has no local agent catalog, so it mirrors the host's env-free +// revisioned snapshot and re-projects it into the local snapshot shape the desktop +// catalog UI already consumes — one renderer code path, read-only here (authoring +// stays desktop preload IPC). An old host that publishes no catalog degrades to the +// built-in agents rather than an empty list or a rejected promise. +import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope' +import type { + AgentCatalogProjectionError, + AgentCatalogSnapshot, + LocalAgentCatalogSnapshot, + LocalCustomTuiAgent, + SyncedCustomTuiAgent +} from '../../../shared/agent-catalog-snapshot' +import { + MAX_AGENT_CATALOG_PROJECTION_BYTES, + MAX_LOCAL_AGENT_CATALOG_BYTES +} from '../../../shared/custom-tui-agent-fields' + +/** The host snapshot or its oversize projection error; both carry version+revision. */ +export type WebAgentCatalogValue = AgentCatalogSnapshot | AgentCatalogProjectionError + +export type WebAgentCatalogSyncDeps = { + /** Full envelope so an old host's `method_not_found` can pick the legacy path. */ + call: (method: string, params?: unknown) => Promise> + isPaired: () => boolean + /** Feeds settings.onChanged so `useLocalAgentCatalog` refetches after a change. */ + onRevisionApplied: (revision: number) => void +} + +export type WebAgentCatalogSync = { + getLocal: () => Promise + /** Host announced a new catalog revision: drop the cache and refetch. */ + announceRevision: (revision: number) => void +} + +function readyRow(agent: Extract): LocalCustomTuiAgent { + return { + status: 'ready', + definition: { + id: agent.id, + baseAgent: agent.baseAgent, + label: agent.label, + ...(agent.commandOverride ? { commandOverride: agent.commandOverride } : {}), + args: agent.args, + syncEnv: agent.syncEnv + }, + // Env never crosses the wire, so its size is unknowable here; no UI reads it. + envSummary: { entryCount: 0, bytes: 0 }, + // `host-preflight` means the host, not stock base detection, vouches for the + // row — keep it out of `baseline-stock` so clients don't gate it on detection. + availabilityReason: agent.commandOverride + ? 'configured-executable' + : agent.availabilityCheck === 'host-preflight' + ? 'custom-path' + : 'baseline-stock' + } +} + +function repairRow( + agent: Extract +): LocalCustomTuiAgent { + return { + status: 'repair-required', + id: agent.id, + baseAgent: agent.baseAgent, + label: agent.label, + // Repair tokens are host-local and paired web cannot author: no draft path. + repairToken: '', + issues: [], + rawBytes: 0, + draftAvailability: 'too-large' + } +} + +/** Built-in-only fallback: no custom agents, nothing disabled, no stored default. */ +function builtInOnlySnapshot(revision: number): LocalAgentCatalogSnapshot { + return { + version: 1, + revision, + defaultAgent: null, + disabledAgents: [], + customAgents: [], + deletedCustomAgents: [], + repairIssues: [], + projection: { status: 'ready', bytes: 0, maxBytes: MAX_AGENT_CATALOG_PROJECTION_BYTES }, + localStorage: { status: 'ready', bytes: 0, maxBytes: MAX_LOCAL_AGENT_CATALOG_BYTES } + } +} + +export function projectWebAgentCatalog( + value: WebAgentCatalogValue | null +): LocalAgentCatalogSnapshot { + if (!value) { + return builtInOnlySnapshot(0) + } + if ('code' in value) { + return { + ...builtInOnlySnapshot(value.revision), + projection: { status: 'too-large', bytes: value.maxBytes, maxBytes: value.maxBytes } + } + } + const customAgents = value.customAgents.map((agent) => + agent.status === 'ready' ? readyRow(agent) : repairRow(agent) + ) + return { + version: 1, + revision: value.revision, + defaultAgent: value.defaultAgent, + disabledAgents: [...value.disabledAgents], + customAgents, + deletedCustomAgents: [...value.deletedCustomAgents], + repairIssues: [], + projection: { status: 'ready', bytes: 0, maxBytes: MAX_AGENT_CATALOG_PROJECTION_BYTES }, + localStorage: { status: 'ready', bytes: 0, maxBytes: MAX_LOCAL_AGENT_CATALOG_BYTES }, + ...(value.migrationBlocked ? { migrationBlocked: value.migrationBlocked } : {}) + } +} + +function parseCatalogValue(raw: unknown): WebAgentCatalogValue | null { + if (!raw || typeof raw !== 'object') { + return null + } + const candidate = raw as { version?: unknown; revision?: unknown } + if (candidate.version !== 1 || typeof candidate.revision !== 'number') { + return null + } + return raw as WebAgentCatalogValue +} + +function readCatalogField(result: unknown): unknown { + return (result as { agentCatalog?: unknown } | null)?.agentCatalog +} + +export function createWebAgentCatalogSync(deps: WebAgentCatalogSyncDeps): WebAgentCatalogSync { + let cached: WebAgentCatalogValue | null = null + let loading: Promise | null = null + let generation = 0 + + // null = transient failure (keep whatever is cached); { value: null } = this host + // publishes no catalog, so the built-in fallback is the honest answer. + const fetchValue = async (): Promise<{ value: WebAgentCatalogValue | null } | null> => { + try { + const dedicated = await deps.call('settings.agentCatalog.get') + if (dedicated.ok) { + return { value: parseCatalogValue(readCatalogField(dedicated.result)) } + } + if (dedicated.error.code !== 'method_not_found') { + return null + } + } catch { + return null + } + try { + // Old host: the catalog ships only piggybacked on settings.get. + const legacy = await deps.call('settings.get') + return legacy.ok ? { value: parseCatalogValue(readCatalogField(legacy.result)) } : null + } catch { + return null + } + } + + const load = (): Promise => { + if (loading) { + return loading + } + const token = generation + const pending = fetchValue().then( + (outcome) => { + if (loading === pending) { + loading = null + } + // A revision announcement during the fetch invalidated this result. + if (outcome && token === generation) { + cached = outcome.value + } + return cached + }, + () => { + if (loading === pending) { + loading = null + } + return cached + } + ) + loading = pending + return pending + } + + return { + getLocal: async () => { + if (!deps.isPaired()) { + return projectWebAgentCatalog(null) + } + return projectWebAgentCatalog(cached ?? (await load())) + }, + announceRevision: (revision) => { + if (!deps.isPaired() || (cached !== null && cached.revision >= revision)) { + return + } + // Keep the last-known catalog until the refetch lands: a failed refresh must + // not blank out custom agents the host still has. + generation += 1 + loading = null + void load().then(() => deps.onRevisionApplied(revision)) + } + } +} diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index a53b5e0ca06..77770b8f518 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -41,6 +41,7 @@ import { createWebRuntimeApi } from './preload-api/web-runtime-api' import { createRuntimeEnvironmentsApi } from './preload-api/web-runtime-environments-api' import { webRuntimeState } from './preload-api/web-runtime-session' import { createWebSettingsApi } from './preload-api/web-settings-api' +import { webAgentCatalogSync } from './preload-api/web-settings-api' import { createShellApi } from './preload-api/web-shell-api' import { createWebStarNagApi } from './preload-api/web-star-nag-api' import { createWebTelemetryApi } from './preload-api/web-telemetry-api' @@ -51,11 +52,13 @@ import { createWebWorkspacePortsApi } from './preload-api/web-workspace-ports-ap import { createWebWorkspaceSessionApi } from './preload-api/web-workspace-session-api' import { createWorktreesApi } from './preload-api/web-worktrees-api' import { readStoredWebRuntimeEnvironment } from './web-runtime-environment' +import { subscribeAgentCatalogRevision } from '@/runtime/agent-catalog-revision-event' export function installWebPreloadApi(): void { webRuntimeState.activeEnvironment = readStoredWebRuntimeEnvironment() const webWindow = window as unknown as { __ORCA_WEB_CLIENT__?: boolean } webWindow.__ORCA_WEB_CLIENT__ = true + subscribeAgentCatalogRevision((revision) => webAgentCatalogSync.announceRevision(revision)) window.electron = createFallbackProxy(['electron']) as Window['electron'] window.api = withFallback(createWebPreloadApi(), []) as PreloadApi } diff --git a/src/shared/agent-catalog-snapshot.ts b/src/shared/agent-catalog-snapshot.ts index 9a0ad4a098d..e87418766f2 100644 --- a/src/shared/agent-catalog-snapshot.ts +++ b/src/shared/agent-catalog-snapshot.ts @@ -15,6 +15,7 @@ import type { MAX_AGENT_CATALOG_PROJECTION_BYTES, MAX_LOCAL_AGENT_CATALOG_BYTES } from './custom-tui-agents' +import type { AgentCatalogSchemaTooNew } from './data-recovery' /** Local repair metadata only. It never contains raw field text or an env key/value. */ export type AgentCatalogRepairIssue = { @@ -104,6 +105,9 @@ export type LocalAgentCatalogSnapshot = Omit CMD_COMMAND_LINE_MAX_CHARS + } + if (shell === 'powershell') { + return powerShellEncodedLength(commandText) > POWERSHELL_ENCODED_COMMAND_MAX_CHARS + } + return utf8ByteLength(commandText) > POSIX_COMMAND_MAX_BYTES +} + +/** Same check for an argv that has not been quoted into shell text yet, plus any + * trailing command clause the plan appends. */ +export function startupArgvExceedsShellLimit( + argv: readonly string[], + shell: AgentStartupShell, + commandSuffix = '' +): boolean { + return startupCommandExceedsShellLimit( + `${buildShellCommandFromArgv(argv, shell)}${commandSuffix}`, + shell + ) +} diff --git a/src/shared/data-recovery.ts b/src/shared/data-recovery.ts index fb7ecaff128..478a107093b 100644 --- a/src/shared/data-recovery.ts +++ b/src/shared/data-recovery.ts @@ -10,14 +10,24 @@ export type RecoveryPointDto = { compatibility: 'previous-binary' | 'current-build' createdAtMs: number | null sizeBytes: number | null + /** False when the backup exists but cannot be read (EISDIR/EACCES/EIO): listed + * for diagnosis, not restorable. Optional so an older host still lists points. */ + restorable?: boolean } export type RestoreRecoveryPointMode = 'prepare-downgrade' | 'restore-and-restart' export type DataRecoveryOperationResult = { ok: true } | { ok: false; error: string } +/** Persisted agent-catalog stamp newer than this build understands. Distinct from + * a blocked migration: no retry can clear it, only a newer Orca. */ +export type AgentCatalogSchemaTooNew = { persistedVersion: number; supportedVersion: number } + export type DataRecoveryMigrationStatus = { /** Non-null while the agent-catalog v1 migration is blocked by a failed * pinned backup; catalog/reference writes are fail-closed until it clears. */ agentCatalogMigrationError: string | null + /** Non-null while the profile is read-only because a newer build wrote it. + * Optional so an older host's status still parses. */ + agentCatalogSchemaTooNew?: AgentCatalogSchemaTooNew | null } diff --git a/src/shared/legacy-agent-prefix-tokenizer.test.ts b/src/shared/legacy-agent-prefix-tokenizer.test.ts index 4ab418ff297..f23b75305d2 100644 Binary files a/src/shared/legacy-agent-prefix-tokenizer.test.ts and b/src/shared/legacy-agent-prefix-tokenizer.test.ts differ diff --git a/src/shared/resolved-agent-startup-plan.ts b/src/shared/resolved-agent-startup-plan.ts index fec3b98bd41..4b4c2768234 100644 --- a/src/shared/resolved-agent-startup-plan.ts +++ b/src/shared/resolved-agent-startup-plan.ts @@ -18,6 +18,7 @@ import { resolveStartupShell, type AgentStartupShell } from './tui-agent-startup-shell' +import { startupArgvExceedsShellLimit } from './agent-startup-command-limit' import { TUI_AGENT_CONFIG } from './tui-agent-config' import { planHermesStartupQueryFromArgv } from './hermes-startup-query' import { inlineAgentDraftFitsPlatform } from './agent-draft-platform-limit' @@ -188,17 +189,18 @@ export function buildAgentStartupPlanFromResolvedLaunch( args.maxInlineDraftChars ?? Number.POSITIVE_INFINITY ) : submitParts(launch, trimmedPrompt) + const offArgvParts: PromptParts = { + ...NO_PROMPT, + ...((args.promptDelivery ?? 'submit') === 'draft' + ? { draftPrompt: trimmedPrompt } + : { followupPrompt: trimmedPrompt }) + } // Why: the cmd quoter cannot encode % ! ^ " inside a double-quoted argv // element (cmd_metachar) — an embedded quote would re-split the command line. // Fail closed off argv and deliver the FULL prompt via the readiness writer. - const parts = + const encodableParts = shell === 'cmd' && plannedParts.argvSuffix.some((el) => CMD_UNENCODABLE_CHAR_RE.test(el)) - ? { - ...NO_PROMPT, - ...((args.promptDelivery ?? 'submit') === 'draft' - ? { draftPrompt: trimmedPrompt } - : { followupPrompt: trimmedPrompt }) - } + ? offArgvParts : plannedParts // The durable launch config records only the base command (no resume flags), so @@ -206,6 +208,23 @@ export function buildAgentStartupPlanFromResolvedLaunch( // in the one-shot launchCommand between the base argv and any prompt suffix. const baseCommand = buildShellCommandFromArgv(launch.argv, shell) const resumeSuffix = launch.resumeArgvSuffix ?? [] + // Why: the resolver capped the resolved argv, but the prompt is appended HERE + // and the RPC admits up to 100k chars — an argv/flag prompt can push the final + // command past the target's hard ceiling (cmd.exe truncates at 8191), which no + // delivery path can carry. Move the FULL prompt off argv, as above. + // The env-var draft path adds no argv element but still appends a cleanup + // clause, which alone can cross the ceiling on a near-full base command. + const addsCommandText = + encodableParts.argvSuffix.length > 0 || encodableParts.commandSuffix.length > 0 + const parts = + addsCommandText && + startupArgvExceedsShellLimit( + [...launch.argv, ...resumeSuffix, ...encodableParts.argvSuffix], + shell, + encodableParts.commandSuffix + ) + ? offArgvParts + : encodableParts const finalArgv = [...launch.argv, ...resumeSuffix, ...parts.argvSuffix] const spawnEnv = { ...launch.agentEnv, ...parts.extraEnv } return { diff --git a/src/shared/tui-agent-selection.test.ts b/src/shared/tui-agent-selection.test.ts index c92f5e62d37..0a013458944 100644 --- a/src/shared/tui-agent-selection.test.ts +++ b/src/shared/tui-agent-selection.test.ts @@ -2,7 +2,8 @@ import { describe, expect, it } from 'vitest' import { haveSameDisabledTuiAgents, normalizeDisabledTuiAgents, - pickTuiAgent + pickTuiAgent, + toLegacyAutoPreference } from './tui-agent-selection' describe('pickTuiAgent', () => { @@ -26,6 +27,27 @@ describe('pickTuiAgent', () => { }) }) +describe('toLegacyAutoPreference', () => { + it('maps the migrated Auto spellings to the legacy Auto null', () => { + expect(toLegacyAutoPreference('auto')).toBeNull() + expect(toLegacyAutoPreference(undefined)).toBeNull() + }) + + it('passes concrete and blank preferences through unchanged', () => { + expect(toLegacyAutoPreference('codex')).toBe('codex') + expect(toLegacyAutoPreference('blank')).toBe('blank') + }) + + it('does not let a repaired/cleared null default fall back to Auto', () => { + // Deleting the default with `clear`, or disabling a base that the default + // derived from, stores null. Auto would launch whatever is installed. + expect(toLegacyAutoPreference(null)).toBe('blank') + expect(pickTuiAgent(toLegacyAutoPreference(null), ['claude', 'codex'])).toBeNull() + // Auto still auto-picks. + expect(pickTuiAgent(toLegacyAutoPreference('auto'), ['claude', 'codex'])).toBe('claude') + }) +}) + describe('normalizeDisabledTuiAgents', () => { it('dedupes supported agent ids and drops unsupported values', () => { expect(normalizeDisabledTuiAgents(['codex', 'unknown', 'codex', null, 'claude'])).toEqual([ diff --git a/tests/e2e/helpers/docker-ssh-custom-agent-remote.ts b/tests/e2e/helpers/docker-ssh-custom-agent-remote.ts index 87eb528c442..59ec37139b8 100644 --- a/tests/e2e/helpers/docker-ssh-custom-agent-remote.ts +++ b/tests/e2e/helpers/docker-ssh-custom-agent-remote.ts @@ -112,7 +112,9 @@ export async function connectDockerRemoteWorktree( const { target: createdTarget, repoReadoptions } = await window.api.ssh.addTarget({ target: { label: `Docker SSH Custom Agent ${Date.now()}`, - host: '127.0.0.1', + // Why target.host, not a literal loopback: ORCA_E2E_SSH_TARGET_HOST + // moves the container off 127.0.0.1 for non-local runners. + host: target.host, port: target.port, username: 'root', identityFile: target.identityFile, diff --git a/tests/e2e/helpers/docker-ssh-custom-agent-remote.unit.test.ts b/tests/e2e/helpers/docker-ssh-custom-agent-remote.unit.test.ts new file mode 100644 index 00000000000..8a646208fa4 --- /dev/null +++ b/tests/e2e/helpers/docker-ssh-custom-agent-remote.unit.test.ts @@ -0,0 +1,118 @@ +import type { Page } from '@stablyai/playwright-test' +import { afterEach, describe, expect, it } from 'vitest' +import type { DockerSshRelayTarget } from './docker-ssh-relay-target' +import { connectDockerRemoteWorktree } from './docker-ssh-custom-agent-remote' + +// The connect routine runs inside page.evaluate, so it can only reach `window`. +// Running its callback against a stubbed window is what proves it consumes the +// real IPC result shapes — a mis-read wrapper here made all four SSH +// custom-agent specs fail with `SSH target "undefined" not found` before +// reaching the feature. +const TARGET_ID = 'ssh-target-1' +const EXECUTION_HOST_ID = `ssh:${TARGET_ID}` + +type FetchWorktreesCall = { repoId: string; executionHostId?: string } +type AddedTarget = { host: string; port: number } + +function stubWindow(): { + addedTargets: AddedTarget[] + connectedTargetIds: string[] + fetchWorktreesCalls: FetchWorktreesCall[] +} { + const addedTargets: AddedTarget[] = [] + const connectedTargetIds: string[] = [] + const fetchWorktreesCalls: FetchWorktreesCall[] = [] + const state = { + setSshConnectionState: () => {}, + sshTargetLabels: new Map(), + setSshTargetLabels: () => {}, + recordSshRepoReadoptions: () => {}, + fetchRepos: async () => {}, + fetchWorktrees: async (repoId: string, options?: { executionHostId?: string }) => { + fetchWorktreesCalls.push({ repoId, executionHostId: options?.executionHostId }) + return true + }, + worktreesByRepo: { + 'repo-1': [ + { id: 'local-worktree', hostId: 'local' }, + { id: 'remote-worktree', hostId: EXECUTION_HOST_ID } + ] + }, + setActiveWorktree: () => {} + } + const fakeWindow = { + __store: { getState: () => state }, + api: { + ssh: { + onCredentialRequest: () => () => {}, + submitCredential: async () => {}, + addTarget: async ({ target }: { target: AddedTarget }) => { + addedTargets.push({ host: target.host, port: target.port }) + return { + target: { id: TARGET_ID, label: 'Docker SSH Custom Agent' }, + repoReadoptions: [] + } + }, + // Mirrors the real handler: an unknown target id never reports connected. + connect: async ({ targetId }: { targetId: string }) => { + connectedTargetIds.push(targetId) + return targetId === TARGET_ID + ? { status: 'connected', providerEpoch: 'epoch-1', connectionGeneration: 0 } + : { status: 'error', error: `SSH target "${targetId}" not found` } + } + }, + repos: { + addRemote: async () => ({ repo: { id: 'repo-1', path: '/srv/repo' } }) + } + } + } + ;(globalThis as unknown as { window?: unknown }).window = fakeWindow + return { addedTargets, connectedTargetIds, fetchWorktreesCalls } +} + +const fakePage = { + evaluate: async (fn: (arg: Arg) => Result | Promise, arg: Arg) => fn(arg) +} as unknown as Page + +// A non-loopback host, as ORCA_E2E_SSH_TARGET_HOST produces for non-local runners. +const fakeTarget = { + containerName: 'orca-e2e', + host: '10.1.2.3', + port: 2222, + identityFile: '/tmp/id_ed25519' +} as DockerSshRelayTarget + +describe('connectDockerRemoteWorktree', () => { + afterEach(() => { + delete (globalThis as unknown as { window?: unknown }).window + }) + + it('connects the target returned inside the add result, not the result itself', async () => { + const { connectedTargetIds } = stubWindow() + + const connected = await connectDockerRemoteWorktree(fakePage, fakeTarget) + + expect(connectedTargetIds).toEqual([TARGET_ID]) + expect(connected).toEqual({ + targetId: TARGET_ID, + repoId: 'repo-1', + worktreeId: 'remote-worktree' + }) + }) + + it('hydrates and selects the worktree on the SSH execution host', async () => { + const { fetchWorktreesCalls } = stubWindow() + + await connectDockerRemoteWorktree(fakePage, fakeTarget) + + expect(fetchWorktreesCalls).toEqual([{ repoId: 'repo-1', executionHostId: EXECUTION_HOST_ID }]) + }) + + it('adds the container address the fixture published', async () => { + const { addedTargets } = stubWindow() + + await connectDockerRemoteWorktree(fakePage, fakeTarget) + + expect(addedTargets).toEqual([{ host: fakeTarget.host, port: fakeTarget.port }]) + }) +})