Integrate merge-ready PR review fixes

This commit is contained in:
Brennan Benson
2026-07-14 02:30:21 -07:00
1143 changed files with 90637 additions and 16517 deletions
+1
View File
@@ -1,6 +1,7 @@
name: Bug report
description: Report a problem with Orca
title: '[Bug]: '
type: Bug
labels:
- bug
body:
@@ -1,6 +1,7 @@
name: Feature request
description: Suggest an improvement for Orca
title: '[Feature]: '
type: Feature
labels:
- enhancement
body:
+7
View File
@@ -6,6 +6,8 @@ on:
- '.github/workflows/computer-e2e.yml'
- 'config/electron-builder.config.cjs'
- 'config/scripts/build-computer-macos.mjs'
- 'config/scripts/build-windows-cli-launcher.mjs'
- 'config/scripts/build-windows-cli-launcher.test.mjs'
- 'config/scripts/computer-e2e-workflow.test.mjs'
- 'config/scripts/computer-use-skill-guidance.test.mjs'
- 'config/scripts/computer-use-smoke.mjs'
@@ -22,12 +24,15 @@ on:
- 'native/computer-use-macos/**'
- 'native/computer-use-linux/**'
- 'native/computer-use-windows/**'
- 'native/windows-cli-launcher/**'
- 'skills/computer-use/SKILL.md'
- 'src/cli/**'
- 'src/main/computer/**'
- 'src/main/runtime/rpc/dispatcher.ts'
- 'src/main/runtime/rpc/errors.ts'
- 'src/main/runtime/rpc/methods/computer*.ts'
- 'src/main/ssh/ssh-remote-cli-launcher.ts'
- 'src/main/ssh/ssh-remote-cli-launcher.test.ts'
- 'src/shared/computer-use-*.ts'
- 'tests/e2e/computer-linux.e2e.ts'
- 'tests/e2e/computer-mac.e2e.ts'
@@ -72,6 +77,8 @@ jobs:
- run: pnpm install --frozen-lockfile
- run: >-
pnpm vitest run
config/scripts/build-windows-cli-launcher.test.mjs
src/main/ssh/ssh-remote-cli-launcher.test.ts
config/scripts/computer-e2e-workflow.test.mjs
config/scripts/computer-use-skill-guidance.test.mjs
config/scripts/computer-use-smoke.test.mjs
+56
View File
@@ -173,3 +173,59 @@ jobs:
path: test-results/
retention-days: 7
if-no-files-found: ignore
ssh-docker-watcher-isolation:
name: ssh docker watcher isolation
needs: build
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ inputs.ref || github.ref }}
- name: Install native build and headless UI tools
run: sudo apt-get update && sudo apt-get install -y build-essential openssh-client python3 xvfb
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version-file: package.json
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
run_install: false
# Why: same Linux-only node-gyp pin as build/e2e jobs so the workaround
# stays consistent across workflows even while this job is ubuntu-latest.
- name: Use external node-gyp to avoid pnpm's bundled copy (Linux only)
if: runner.os == 'Linux'
run: |
npm install -g node-gyp@11.5.0
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Download E2E build output
uses: actions/download-artifact@v8
with:
name: e2e-build-out
path: out/
# Why: this is the release-path proof that the deployed Linux relay keeps
# its PTY and explorer live across a real watcher SIGSEGV.
- name: Run Docker SSH watcher isolation E2E
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker-watcher-isolation
- name: Upload watcher isolation traces
if: failure()
uses: actions/upload-artifact@v7
with:
name: playwright-traces-ssh-docker-watcher-isolation
path: test-results/
retention-days: 7
if-no-files-found: ignore
+14
View File
@@ -863,6 +863,20 @@ jobs:
ORCA_DIAGNOSTICS_TOKEN_URL: https://www.onorca.dev/diagnostics/token
ORCA_POSTHOG_WRITE_KEY: ${{ secrets.ORCA_POSTHOG_WRITE_KEY }}
- name: Gate runtime file-watcher process isolation
if: runner.os == 'Linux'
run: |
# Why: #8212 is a native-process crash contract. Prove both the Node
# host and the exact Electron runtime survive SIGSEGV before packaging.
node config/scripts/runtime-file-watcher-fault-harness.mjs
ELECTRON_RUN_AS_NODE=1 pnpm exec electron config/scripts/runtime-file-watcher-fault-harness.mjs
- name: Gate SSH relay watcher process isolation
run: |
# Why: the remote native watcher shares a daemon with live PTYs.
# Kill only its child and require both PTY and watch recovery before packaging.
node config/scripts/relay-watcher-fault-harness.mjs
- name: Publish release artifacts (Linux)
if: matrix.platform == 'linux-x64' || matrix.platform == 'linux-arm64'
uses: nick-fields/retry@v4
+13
View File
@@ -120,6 +120,19 @@ jobs:
ORCA_DIAGNOSTICS_TOKEN_URL: https://www.onorca.dev/diagnostics/token
ORCA_POSTHOG_WRITE_KEY: ${{ secrets.ORCA_POSTHOG_WRITE_KEY }}
- name: Gate runtime file-watcher process isolation
run: |
# Why: #8212 is a native-process crash contract. Prove both the Node
# host and the exact Electron runtime survive SIGSEGV before packaging.
node config/scripts/runtime-file-watcher-fault-harness.mjs
ELECTRON_RUN_AS_NODE=1 pnpm exec electron config/scripts/runtime-file-watcher-fault-harness.mjs
- name: Gate SSH relay watcher process isolation
run: |
# Why: the remote native watcher shares a daemon with live PTYs.
# Kill only its child and require both PTY and watch recovery before packaging.
node config/scripts/relay-watcher-fault-harness.mjs
- name: Publish release artifacts (macOS)
uses: nick-fields/retry@v4
with:
@@ -73,7 +73,7 @@ jobs:
uses: actions/cache@v4
with:
path: dist/orca-windows-setup.exe
key: branch-installer-${{ hashFiles('src/**', 'config/**', 'package.json', 'pnpm-lock.yaml') }}
key: branch-installer-${{ hashFiles('src/**', 'config/**', 'native/**', 'resources/win32/**', 'package.json', 'pnpm-lock.yaml') }}
- name: Build Windows installer (unsigned)
if: steps.cache-installer.outputs.cache-hit != 'true'
+4 -4
View File
@@ -100,9 +100,9 @@ docs/reference/react-performance-audit.md
validation-screenshots/
.stably-browser
# PR verification evidence screenshots are referenced from notes but should not
# be committed.
notes/artifacts/
# Local scratch notes and PR evidence screenshots (not part of the product).
/notes/
/pr-evidence/
# Playwright
test-results/
@@ -123,5 +123,5 @@ src/renderer/src/i18n/locales/.ko-catalog-cache.json
src/renderer/src/i18n/locales/.ja-catalog-cache.json
src/renderer/src/i18n/locales/.es-catalog-cache.json
# Bench result JSONs are working artifacts; headline numbers live in notes/terminal-performance-initiative.md
# Bench result JSONs are working artifacts
tools/benchmarks/results/terminal-pipeline-*.json
-7
View File
@@ -1,9 +1,2 @@
shamefully-hoist=true
minimum-release-age=4320
minimum-release-age-exclude[]=electron@43.1.0
minimum-release-age-exclude[]=node-abi@4.33.0
minimum-release-age-exclude[]=typescript@7.0.2
minimum-release-age-exclude[]=@typescript/typescript-*
minimum-release-age-exclude[]=mermaid@11.15.0
minimum-release-age-exclude[]=@mermaid-js/parser@1.1.1
minimum-release-age-exclude[]=serve-sim@0.1.40
+1 -4
View File
@@ -12,9 +12,7 @@ Keep comments short — one or two lines. Capture only the non-obvious reason (s
## Lint Rules: Do Not Disable Max Lines
Never add a `max-lines` disable (`eslint-disable max-lines`, `oxlint-disable max-lines`, or line-specific variants), and never add a per-file `max-lines` bump in `mobile/.oxlintrc.json`. Split the file, extract focused modules, move fixtures/builders into named files, or otherwise reduce the counted lines instead.
This is enforced in CI by `pnpm check:max-lines-ratchet` (`config/scripts/check-max-lines-ratchet.mjs`). Files already over the limit are grandfathered in `config/max-lines-baseline.txt`; that list may only **shrink**. Adding a new suppression fails the build. If you remove a suppression (great — split it!), run `pnpm check:max-lines-ratchet --prune` to drop its baseline line.
Never add a `max-lines` disable (`eslint-disable max-lines`, `oxlint-disable max-lines`, or line-specific variants), and never add a per-file `max-lines` bump in `mobile/.oxlintrc.json`.
## File and Module Naming
@@ -55,6 +53,5 @@ Source-control and review changes must consider GitLab and other supported git p
## GitHub CLI Usage
Be mindful of the user's `gh` CLI API rate limit — batch requests where possible and avoid unnecessary calls. All code, commands, and scripts must be compatible with macOS, Linux, and Windows.
Never commit PR evidence images; attach them to the PR conversation instead (but never use gh-attach).
## Type Declarations: Prefer `.ts` Over `.d.ts`
+4
View File
@@ -185,6 +185,10 @@ module.exports = {
from: 'resources/win32/bin/orca.cmd',
to: 'bin/orca.cmd'
},
{
from: 'native/windows-cli-launcher/.build/orca.exe',
to: 'bin/orca.exe'
},
{
from: 'node_modules/agent-browser/bin/agent-browser-win32-x64.exe',
to: 'agent-browser-win32-x64.exe'
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
@@ -2,6 +2,11 @@
import { spawnSync } from 'node:child_process'
if (process.platform === 'win32') {
runNodeScript('config/scripts/build-windows-cli-launcher.mjs')
process.exit(0)
}
if (process.platform !== 'darwin') {
console.log(`[native-build] no macOS native computer build required on ${process.platform}`)
process.exit(0)
@@ -28,3 +33,13 @@ function runPnpmScript(scriptName) {
process.exit(result.status ?? 1)
}
}
function runNodeScript(scriptPath) {
const result = spawnSync(process.execPath, [scriptPath], { stdio: 'inherit' })
if (result.signal) {
process.kill(process.pid, result.signal)
}
if (result.status !== 0 || result.error) {
process.exit(result.status ?? 1)
}
}
@@ -4,6 +4,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { runInNewContext } from 'node:vm'
import { describe, expect, it } from 'vitest'
const projectRoot = resolve(import.meta.dirname, '../..')
@@ -37,6 +38,14 @@ WindowsTerminal.prototype._deferNoArgs = function (deferredFn) {
};
exports.WindowsTerminal = WindowsTerminal;
`
const vanillaConptyConsoleListAgent = `'use strict';
var utils_1 = require("./utils");
var getConsoleProcessList = utils_1.loadNativeModule('conpty_console_list').module.getConsoleProcessList;
var shellPid = parseInt(process.argv[2], 10);
var consoleProcessList = getConsoleProcessList(shellPid);
process.send({ consoleProcessList: consoleProcessList });
process.exit(0);
`
function loadHarness(modulePath) {
delete require.cache[require.resolve(modulePath)]
@@ -67,9 +76,11 @@ describe('packaged Windows SSH relay node-pty patch', () => {
for (const platform of ['win32-x64', 'win32-arm64']) {
const relayDir = join(projectRoot, 'out', 'relay', platform)
const relayContent = readFileSync(join(relayDir, 'relay.js'))
const watcherContent = readFileSync(join(relayDir, 'relay-watcher.js'))
const patchContent = readFileSync(join(relayDir, patchName))
const expectedHash = createHash('sha256')
.update(relayContent)
.update(watcherContent)
.update(patchContent)
.digest('hex')
.slice(0, 12)
@@ -92,9 +103,17 @@ describe('packaged Windows SSH relay node-pty patch', () => {
try {
const modulePath = join(tempRoot, 'node_modules', 'node-pty', 'lib', 'windowsTerminal.js')
const watcherPath = join(tempRoot, 'node_modules', '@parcel', 'watcher', 'index.js')
const consoleListPath = join(
tempRoot,
'node_modules',
'node-pty',
'lib',
'conpty_console_list_agent.js'
)
mkdirSync(dirname(modulePath), { recursive: true })
mkdirSync(dirname(watcherPath), { recursive: true })
writeFileSync(modulePath, vanillaWindowsTerminal, 'utf8')
writeFileSync(consoleListPath, vanillaConptyConsoleListAgent, 'utf8')
writeFileSync(watcherPath, 'module.exports = {}\n', 'utf8')
expect(loadHarness(modulePath)).toEqual({
@@ -102,7 +121,6 @@ describe('packaged Windows SSH relay node-pty patch', () => {
nativeKillCount: 0,
deferredCount: 1
})
const patch = spawnSync(
process.execPath,
[join(projectRoot, 'out', 'relay', 'win32-x64', patchName), '--apply', tempRoot],
@@ -115,6 +133,24 @@ describe('packaged Windows SSH relay node-pty patch', () => {
nativeKillCount: 1,
deferredCount: 0
})
const sent = []
runInNewContext(readFileSync(consoleListPath, 'utf8'), {
require: () => ({
loadNativeModule: () => ({
module: {
getConsoleProcessList: () => {
throw new Error('AttachConsole failed')
}
}
})
}),
process: {
argv: ['node', consoleListPath, '42'],
exit: () => {},
send: (message) => sent.push(message)
}
})
expect(sent).toEqual([{ consoleProcessList: [42] }])
const secondPatch = spawnSync(
process.execPath,
+24 -6
View File
@@ -1,9 +1,9 @@
#!/usr/bin/env node
/**
* Bundle the relay daemon into a single relay.js file per platform.
* Bundle the relay daemon and its crash-isolated watcher child per platform.
*
* The relay runs on remote hosts via `node relay.js`, so it must be a
* self-contained CommonJS bundle with no external dependencies beyond
* The relay runs on remote hosts via `node relay.js`, so both outputs use
* self-contained CommonJS bundles with no external dependencies beyond
* Node.js built-ins. Native addons (node-pty, @parcel/watcher) are
* marked external and expected to be installed on the remote or
* gracefully degraded.
@@ -17,6 +17,7 @@ const __dirname = import.meta.dirname
// Why: the script lives under config/scripts, so go two levels up to reach the repo root.
const ROOT = join(__dirname, '..', '..')
const RELAY_ENTRY = join(ROOT, 'src', 'relay', 'relay.ts')
const WATCHER_ENTRY = join(ROOT, 'src', 'main', 'ipc', 'parcel-watcher-process-entry.ts')
const WINDOWS_NODE_PTY_PATCH_SOURCE = join(
ROOT,
'config',
@@ -49,7 +50,22 @@ for (const platform of PLATFORMS) {
outfile: join(outDir, 'relay.js'),
// Native addons cannot be bundled — they must exist on the remote host.
// The relay gracefully degrades when they are absent.
external: ['node-pty', '@parcel/watcher'],
external: ['node-pty', '@parcel/watcher', 'electron'],
sourcemap: false,
minify: true,
define: {
'process.env.NODE_ENV': '"production"'
}
})
await build({
entryPoints: [WATCHER_ENTRY],
bundle: true,
platform: 'node',
target: 'node18',
format: 'cjs',
outfile: join(outDir, 'relay-watcher.js'),
external: ['@parcel/watcher'],
sourcemap: false,
minify: true,
define: {
@@ -58,9 +74,11 @@ for (const platform of PLATFORMS) {
})
// Why: include a content hash so the deploy check detects code changes
// even when RELAY_VERSION hasn't been bumped (common during development).
// even when RELAY_VERSION hasn't been bumped. Hash both process artifacts
// and the Windows-only patch so every runtime dependency deploys together.
const relayContent = readFileSync(join(outDir, 'relay.js'))
const artifactHash = createHash('sha256').update(relayContent)
const watcherContent = readFileSync(join(outDir, 'relay-watcher.js'))
const artifactHash = createHash('sha256').update(relayContent).update(watcherContent)
if (platform.startsWith('win32-')) {
// Why: SSH relays install node-pty with npm on the remote, outside pnpm's
// patchedDependencies. Hashing the patch forces safe upgrade redeployment.
@@ -0,0 +1,60 @@
#!/usr/bin/env node
import { spawnSync } from 'node:child_process'
import { existsSync, mkdirSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
if (process.platform !== 'win32') {
// Why: electron-builder treats a skipped native build like success and can
// continue toward a Windows package whose declared orca.exe does not exist.
throw new Error(
'Windows CLI launcher compilation requires a Windows host; refusing to package without it.'
)
}
const repoRoot = resolve(import.meta.dirname, '../..')
const sourcePath = join(repoRoot, 'native', 'windows-cli-launcher', 'OrcaCliLauncher.cs')
const outputPath = readArg('--output') ?? defaultOutputPath(repoRoot)
const compilerPath = findFrameworkCompiler(process.env)
if (!compilerPath) {
throw new Error('Unable to find the .NET Framework C# compiler required for orca.exe.')
}
mkdirSync(dirname(outputPath), { recursive: true })
const result = spawnSync(
compilerPath,
['/nologo', '/target:exe', '/optimize+', '/warnaserror+', `/out:${outputPath}`, sourcePath],
{ cwd: repoRoot, stdio: 'inherit' }
)
if (result.signal) {
process.kill(process.pid, result.signal)
}
if (result.error) {
throw result.error
}
if (result.status !== 0) {
process.exit(result.status ?? 1)
}
function defaultOutputPath(projectRoot) {
return join(projectRoot, 'native', 'windows-cli-launcher', '.build', 'orca.exe')
}
function findFrameworkCompiler(env) {
const windowsDirectory = env.WINDIR ?? env.SystemRoot
if (!windowsDirectory) {
return null
}
const candidates = [
join(windowsDirectory, 'Microsoft.NET', 'Framework64', 'v4.0.30319', 'csc.exe'),
join(windowsDirectory, 'Microsoft.NET', 'Framework', 'v4.0.30319', 'csc.exe')
]
return candidates.find((candidate) => existsSync(candidate)) ?? null
}
function readArg(name) {
const index = process.argv.indexOf(name)
return index >= 0 ? process.argv[index + 1] : undefined
}
@@ -0,0 +1,87 @@
import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { spawnSync } from 'node:child_process'
import { describe, expect, it } from 'vitest'
const itWindows = process.platform === 'win32' ? it : it.skip
const itCrossHost = process.platform === 'win32' ? it.skip : it
const projectRoot = resolve(import.meta.dirname, '../..')
describe('Windows CLI launcher', () => {
itCrossHost('fails closed when the Windows launcher cannot be compiled on this host', () => {
const outputRoot = mkdtempSync(join(tmpdir(), 'orca cross-host launcher '))
try {
const result = spawnSync(
process.execPath,
['config/scripts/build-windows-cli-launcher.mjs', '--output', join(outputRoot, 'orca.exe')],
{ cwd: projectRoot, encoding: 'utf8' }
)
expect(result.status).not.toBe(0)
expect(result.stderr).toContain('Windows CLI launcher')
expect(result.stderr).toContain('Windows host')
} finally {
rmSync(outputRoot, { recursive: true, force: true })
}
})
itWindows('preserves a multiline argument from PowerShell through the native launcher', () => {
const appRoot = mkdtempSync(join(tmpdir(), 'orca cli launcher '))
try {
const resourcesPath = join(appRoot, 'resources')
const launcherPath = join(resourcesPath, 'bin', 'orca.exe')
const cliPath = join(resourcesPath, 'app.asar.unpacked', 'out', 'cli', 'index.js')
mkdirSync(join(resourcesPath, 'bin'), { recursive: true })
mkdirSync(dirname(cliPath), { recursive: true })
copyFileSync(process.execPath, join(appRoot, 'Orca.exe'))
writeFileSync(
cliPath,
`process.stdout.write(JSON.stringify({
argv: process.argv.slice(2),
electronRunAsNode: process.env.ELECTRON_RUN_AS_NODE,
nodeOptions: process.env.NODE_OPTIONS ?? null,
orcaNodeOptions: process.env.ORCA_NODE_OPTIONS ?? null
}))\n`,
'utf8'
)
const build = spawnSync(
process.execPath,
['config/scripts/build-windows-cli-launcher.mjs', '--output', launcherPath],
{ cwd: projectRoot, encoding: 'utf8' }
)
expect(build.status, `${build.stdout}\n${build.stderr}`).toBe(0)
const body = 'paragraph one line one\nparagraph one line two\n\nparagraph two'
const powershell = spawnSync(
'powershell.exe',
[
'-NoProfile',
'-NonInteractive',
'-Command',
'& $env:ORCA_TEST_LAUNCHER orchestration send --body $env:ORCA_TEST_BODY --json'
],
{
encoding: 'utf8',
env: {
...process.env,
NODE_OPTIONS: '--no-warnings',
ORCA_TEST_BODY: body,
ORCA_TEST_LAUNCHER: launcherPath
}
}
)
expect(powershell.status, powershell.stderr).toBe(0)
expect(JSON.parse(powershell.stdout)).toEqual({
argv: ['orchestration', 'send', '--body', body, '--json'],
electronRunAsNode: '1',
nodeOptions: null,
orcaNodeOptions: '--no-warnings'
})
} finally {
rmSync(appRoot, { recursive: true, force: true })
}
})
})
@@ -20,6 +20,9 @@ const BUDGETS = {
maxWorstKeyLatencyMs: 300,
maxRevisitLatencyMs: 300,
maxTimerDriftMs: 150,
// Why: mirrors MAX_TIMER_DRIFT_UNDER_LOAD_MS in artificial-opencode-terminal-load.spec.ts
// so injected multi-pane redraw rows are not judged against the unloaded ceiling.
maxTimerDriftUnderLoadMs: 2_500,
maxScrollLatencyMs: 150,
maxRestoreLatencyMs: 1000,
maxRendererQueuedChars: 2 * 1024 * 1024,
@@ -27,6 +30,17 @@ const BUDGETS = {
maxRendererDroppedBacklogs: 0
}
// Why: only these annotation types assert against MAX_TIMER_DRIFT_UNDER_LOAD_MS
// in the e2e suite; other rows keep the unloaded smoke ceiling.
function isUnderLoadTimerDriftScenario(scenario) {
return (
scenario === 'opencode-same-workspace-typing' ||
scenario === 'opencode-cross-workspace-typing' ||
scenario.startsWith('opencode-scale-same-workspace-') ||
scenario.startsWith('opencode-scale-cross-workspace-')
)
}
function parseMs(value, fieldName, row, failures) {
if (value == null || value === '') {
return null
@@ -90,7 +104,9 @@ function validateRow(row) {
addBudgetCheck(
'timer drift',
parseMs(row.maxTimerDrift, 'maxTimerDrift', row, failures),
BUDGETS.maxTimerDriftMs,
isUnderLoadTimerDriftScenario(row.scenario)
? BUDGETS.maxTimerDriftUnderLoadMs
: BUDGETS.maxTimerDriftMs,
'ms'
)
addBudgetCheck(
@@ -107,6 +107,41 @@ describe('check-terminal-perf-report-budgets', () => {
expect(result.stderr).toContain('renderer dropped backlogs 1 exceeded budget 0')
})
// Why: covers every isUnderLoadTimerDriftScenario branch (two exact + two
// prefix matches) so a predicate regression cannot silently re-apply the
// unloaded 150ms ceiling to multi-pane redraw rows.
it.each([
'opencode-same-workspace-typing',
'opencode-cross-workspace-typing',
'opencode-scale-same-workspace-50',
'opencode-scale-cross-workspace-50'
])('applies the under-load timer-drift budget to %s', (scenario) => {
const passPath = writeReport(
['panes=50', 'frames=60', 'median=12.0ms', 'worst=40.0ms', 'maxTimerDrift=1510.0ms'].join(
' '
),
scenario
)
const passOutput = execFileSync(process.execPath, [scriptPath, passPath], {
cwd: process.cwd(),
encoding: 'utf8'
})
expect(passOutput).toContain('Terminal perf budget check passed for 1 annotation row(s).')
})
it('fails multi-pane redraw scenarios that exceed the under-load timer-drift budget', () => {
const failPath = writeReport(
['panes=50', 'frames=60', 'median=12.0ms', 'worst=40.0ms', 'maxTimerDrift=2501.0ms'].join(
' '
),
'opencode-cross-workspace-typing'
)
const failResult = runChecker(failPath)
expect(failResult.status).toBe(1)
expect(failResult.stderr).toContain('timer drift 2501ms exceeded budget 2500ms')
})
it('fails malformed metric values instead of treating them as absent', () => {
const reportPath = writeReport('panes=1 median=999 worst=abcms rendererQueuedChars=wat')
@@ -33,4 +33,11 @@ describe('computer-use skill guidance', () => {
expect(skill).toContain('use `--restore-window` so another window does not cover')
expect(skill).toContain('trust the tree over potentially occluded pixels')
})
it('points JSON users to the public accessibility-tree field', () => {
const skill = readFileSync(skillPath, 'utf8')
expect(skill).toContain('`result.snapshot.treeText`')
expect(skill).not.toContain('`result.elements`')
})
})
@@ -58,6 +58,10 @@ describe('electron-builder config', () => {
expect.objectContaining({
from: 'native/computer-use-windows/runtime.ps1',
to: 'computer-use-windows/runtime.ps1'
}),
expect.objectContaining({
from: 'native/windows-cli-launcher/.build/orca.exe',
to: 'bin/orca.exe'
})
])
)
@@ -9,6 +9,12 @@ function electronBuilderNativeRebuild(context) {
function runElectronBuilderNativeRebuild(context, runner = execFileSync) {
const args = buildNativeRebuildArgs(context)
if (readPlatformName(context?.platform) === 'win32') {
runner(process.execPath, ['config/scripts/build-windows-cli-launcher.mjs'], {
cwd: projectDir,
stdio: 'inherit'
})
}
runner(process.execPath, args, {
cwd: projectDir,
stdio: 'inherit'
@@ -42,6 +42,31 @@ describe('electron-builder native rebuild hook', () => {
])
})
it('builds the native CLI launcher before packaging Windows resources', () => {
const calls = []
const result = runElectronBuilderNativeRebuild(
{
platform: { nodeName: 'win32' },
arch: 'x64'
},
(...args) => calls.push(args)
)
expect(result).toBe(false)
expect(calls).toEqual([
[
process.execPath,
['config/scripts/build-windows-cli-launcher.mjs'],
expect.objectContaining({ stdio: 'inherit' })
],
[
process.execPath,
['config/scripts/rebuild-native-deps.mjs', '--platform=win32', '--arch=x64', '--force'],
expect.objectContaining({ stdio: 'inherit' })
]
])
})
it('rejects incomplete electron-builder contexts', () => {
expect(() => buildNativeRebuildArgs({ arch: 'x64' })).toThrow(/platform/)
expect(() => buildNativeRebuildArgs({ platform: { nodeName: 'linux' } })).toThrow(/arch/)
@@ -4,9 +4,11 @@ import { describe, expect, it } from 'vitest'
const projectDir = resolve(import.meta.dirname, '../..')
const skillPath = join(projectDir, 'skills', 'orca-cli', 'SKILL.md')
const orchestrationSkillPath = join(projectDir, 'skills', 'orchestration', 'SKILL.md')
const emulatorSkillPath = join(projectDir, 'skills', 'orca-emulator', 'SKILL.md')
function readSkill() {
return readFileSync(skillPath, 'utf8')
function readSkill(path = skillPath) {
return readFileSync(path, 'utf8')
}
describe('orca CLI skill guidance', () => {
@@ -45,6 +47,43 @@ describe('orca CLI skill guidance', () => {
expect(skill).toContain('send the prompt, and stop')
})
it('prefers agent-first workers without duplicating terminal delivery', () => {
const skill = readSkill()
expect(skill).toContain('Prefer agent-first create for agent workers')
expect(skill).toContain('fallback shell plus a later `terminal create')
expect(skill).toContain('Repo setup or default-terminal settings may still add tabs or splits')
expect(skill).toContain(
'when no repo default-terminal configuration supplies a primary terminal'
)
expect(skill).toContain('Configured default tabs are materialized instead')
expect(skill).toContain(
'only after `terminal list` or `terminal show` confirms it is an unused shell'
)
expect(skill).not.toContain('bare `worktree create` (no `--agent`) still opens')
expect(skill).not.toContain('ends with **one** tab')
expect(skill).toContain('Use `startupTerminal.handle` as the sole agent handle')
expect(skill).toContain('never dual-send to old and replacement handles')
expect(skill).toContain(
"this checks the caller's inbox and does not remotely deliver input to another terminal"
)
})
it('requires full worktree ids across bundled agent guidance', () => {
const cliSkill = readSkill()
const orchestrationSkill = readSkill(orchestrationSkillPath)
const emulatorSkill = readSkill(emulatorSkillPath)
for (const skill of [cliSkill, orchestrationSkill, emulatorSkill]) {
expect(skill).toContain('<repo-id>::<path>')
expect(skill).toContain('bare repo id')
}
expect(cliSkill).toContain('id:<repoId>::<worktreePath>')
expect(cliSkill).toContain('two-part address')
expect(orchestrationSkill).toContain('id:<newFullWorktreeId>')
expect(emulatorSkill).not.toContain('id:abc123')
})
it('keeps browser injection guidance narrow and avoids literal secret examples', () => {
const skill = readSkill()
@@ -11,7 +11,9 @@ function readSkill() {
function getSection(markdown, heading) {
const escapedHeading = heading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const match = markdown.match(new RegExp(`## ${escapedHeading}\\n([\\s\\S]*?)(?=\\n## |$)`))
const match = markdown.match(
new RegExp(`## ${escapedHeading}\\r?\\n([\\s\\S]*?)(?=\\r?\\n## |$)`)
)
expect(match).not.toBeNull()
@@ -188,4 +190,48 @@ describe('orchestration skill guidance', () => {
expect(skill).not.toContain('post-completion polling messages')
expect(skill).not.toContain('every 2 minutes')
})
it('documents @grok in the Messaging group address list', () => {
const skill = readSkill()
const messaging = getSection(skill, 'Messaging')
expect(messaging).toContain('`@grok`')
})
it('documents @cursor in the Messaging group address list', () => {
const skill = readSkill()
const messaging = getSection(skill, 'Messaging')
expect(messaging).toContain('`@cursor`')
})
it('keeps agent-first launch, handle recovery, and inbox injection distinct', () => {
const skill = readSkill()
const messaging = getSection(skill, 'Messaging')
const workerTerminals = getSection(skill, 'Worker Terminals')
const agentFirstExample = workerTerminals.match(
/```bash\norca worktree create --name <task-name> --agent codex --json\n[\s\S]*?```/
)?.[0]
expect(workerTerminals).toContain('Agent-first (required for ordinary agent workers)')
expect(workerTerminals).toContain('fallback shell + agent pair')
expect(workerTerminals).toContain(
'Repo setup or default-terminal settings may still add tabs or splits'
)
expect(workerTerminals).toContain('without configured default tabs')
expect(workerTerminals).toContain(
'only after `terminal list` or `terminal show` confirms it is an unused shell'
)
expect(workerTerminals).not.toContain('bare create opens a default shell')
expect(workerTerminals).not.toContain('ends with **one** agent tab')
expect(agentFirstExample).toBeDefined()
expect(agentFirstExample).not.toContain('orca terminal list')
expect(agentFirstExample).toContain('startupTerminal.handle')
expect(messaging).toContain(
'Use `startupTerminal.handle` from the create response when present'
)
expect(messaging).toContain('continue with the replacement only')
expect(messaging).toContain('it does not remotely wake another terminal')
expect(messaging).toContain('Use `orchestration dispatch --inject` to deliver a tracked task')
})
})
@@ -91,6 +91,77 @@ describe('Electron runtime package contract', () => {
)
})
it('blocks Linux and macOS release packaging on watcher process fault recovery', () => {
const releaseWorkflow = parse(
readFileSync(join(projectDir, '.github/workflows/release-cut.yml'), 'utf8')
)
const macWorkflow = parse(
readFileSync(join(projectDir, '.github/workflows/release-mac-build.yml'), 'utf8')
)
const assertFaultGate = (steps, publishStepName, expectedCondition) => {
const names = steps.map((step) => step.name)
const gate = steps.find((step) => step.name === 'Gate runtime file-watcher process isolation')
expect(gate.if).toBe(expectedCondition)
expect(gate['continue-on-error']).toBeUndefined()
expect(gate.run).toContain('node config/scripts/runtime-file-watcher-fault-harness.mjs')
expect(gate.run).toContain('ELECTRON_RUN_AS_NODE=1 pnpm exec electron')
expect(names.indexOf('Build app')).toBeLessThan(names.indexOf(gate.name))
expect(names.indexOf(gate.name)).toBeLessThan(names.indexOf(publishStepName))
}
assertFaultGate(
releaseWorkflow.jobs.build.steps,
'Publish release artifacts (Linux)',
"runner.os == 'Linux'"
)
assertFaultGate(
macWorkflow.jobs['build-mac'].steps,
'Publish release artifacts (macOS)',
undefined
)
})
it('packages and release-gates the SSH relay watcher child', () => {
const relayBuild = readFileSync(join(projectDir, 'config/scripts/build-relay.mjs'), 'utf8')
const builderConfig = readFileSync(
join(projectDir, 'config/electron-builder.config.cjs'),
'utf8'
)
const remoteCommands = readFileSync(
join(projectDir, 'src/main/ssh/ssh-remote-commands.ts'),
'utf8'
)
const releaseWorkflow = parse(
readFileSync(join(projectDir, '.github/workflows/release-cut.yml'), 'utf8')
)
const macWorkflow = parse(
readFileSync(join(projectDir, '.github/workflows/release-mac-build.yml'), 'utf8')
)
expect(relayBuild).toContain("'parcel-watcher-process-entry.ts'")
expect(relayBuild).toContain("outfile: join(outDir, 'relay-watcher.js')")
expect(relayBuild).toContain("readFileSync(join(outDir, 'relay-watcher.js'))")
expect(builderConfig).toContain("from: 'out/relay'")
expect(remoteCommands).toContain("joinRemotePath(host, remoteRelayDir, 'relay-watcher.js')")
const assertRelayGate = (steps, publishStepName) => {
const names = steps.map((step) => step.name)
const gate = steps.find((step) => step.name === 'Gate SSH relay watcher process isolation')
expect(gate['continue-on-error']).toBeUndefined()
expect(gate.run).toContain('node config/scripts/relay-watcher-fault-harness.mjs')
expect(names.indexOf('Build app')).toBeLessThan(names.indexOf(gate.name))
expect(names.indexOf(gate.name)).toBeLessThan(names.indexOf(publishStepName))
}
assertRelayGate(releaseWorkflow.jobs.build.steps, 'Publish release artifacts (Linux)')
assertRelayGate(macWorkflow.jobs['build-mac'].steps, 'Publish release artifacts (macOS)')
const releaseNames = releaseWorkflow.jobs.build.steps.map((step) => step.name)
expect(releaseNames.indexOf('Gate SSH relay watcher process isolation')).toBeLessThan(
releaseNames.indexOf('Build Windows release artifacts')
)
})
it('pins the Windows release builder to the VS 2022 runner image', () => {
const releaseWorkflow = parse(
readFileSync(join(projectDir, '.github/workflows/release-cut.yml'), 'utf8')
@@ -30,6 +30,15 @@ const PATCHED_KILL = ` WindowsTerminal.prototype.kill = function (signal) {
this._close();
this._agent.kill();
};`
const VANILLA_CONPTY_CONSOLE_LIST = `var consoleProcessList = getConsoleProcessList(shellPid);`
const PATCHED_CONPTY_CONSOLE_LIST = `var consoleProcessList;
try {
consoleProcessList = getConsoleProcessList(shellPid);
}
catch (_a) {
// Orca: AttachConsole can fail after the shell exits; preserve the root pid fallback.
consoleProcessList = [shellPid];
}`
function replaceExactlyOnce(source, before, after, label) {
const first = source.indexOf(before)
@@ -51,20 +60,45 @@ function patchWindowsTerminal(source) {
return replaceExactlyOnce(withDestroy, VANILLA_KILL, PATCHED_KILL, 'kill()')
}
function applyPatch(rootDir = process.cwd()) {
const target = join(rootDir, 'node_modules', 'node-pty', 'lib', 'windowsTerminal.js')
const source = readFileSync(target, 'utf8')
const patched = patchWindowsTerminal(source)
if (patched !== source) {
const pendingTarget = `${target}.orca-patch-pending`
try {
writeFileSync(pendingTarget, patched, 'utf8')
renameSync(pendingTarget, target)
} finally {
rmSync(pendingTarget, { force: true })
}
function patchConptyConsoleListAgent(source) {
if (source.includes(PATCHED_CONPTY_CONSOLE_LIST)) {
return source
}
return target
return replaceExactlyOnce(
source,
VANILLA_CONPTY_CONSOLE_LIST,
PATCHED_CONPTY_CONSOLE_LIST,
'ConPTY console-list fallback'
)
}
function writeAtomicallyIfChanged(target, source, patched) {
if (patched === source) {
return
}
const pendingTarget = `${target}.orca-patch-pending`
try {
writeFileSync(pendingTarget, patched, 'utf8')
renameSync(pendingTarget, target)
} finally {
rmSync(pendingTarget, { force: true })
}
}
function applyPatch(rootDir = process.cwd()) {
const moduleRoot = join(rootDir, 'node_modules', 'node-pty', 'lib')
const terminalTarget = join(moduleRoot, 'windowsTerminal.js')
const terminalSource = readFileSync(terminalTarget, 'utf8')
writeAtomicallyIfChanged(terminalTarget, terminalSource, patchWindowsTerminal(terminalSource))
const consoleListTarget = join(moduleRoot, 'conpty_console_list_agent.js')
const consoleListSource = readFileSync(consoleListTarget, 'utf8')
writeAtomicallyIfChanged(
consoleListTarget,
consoleListSource,
patchConptyConsoleListAgent(consoleListSource)
)
return terminalTarget
}
function checkNativeDeps(rootDir = process.cwd()) {
@@ -73,6 +107,16 @@ function checkNativeDeps(rootDir = process.cwd()) {
if (!source.includes(PATCHED_DESTROY) || !source.includes(PATCHED_KILL)) {
throw new Error('node-pty Windows pre-ready shutdown patch is missing')
}
const consoleListTarget = join(
rootDir,
'node_modules',
'node-pty',
'lib',
'conpty_console_list_agent.js'
)
if (!readFileSync(consoleListTarget, 'utf8').includes(PATCHED_CONPTY_CONSOLE_LIST)) {
throw new Error('node-pty ConPTY console-list fallback patch is missing')
}
require.resolve('@parcel/watcher', { paths: [rootDir] })
return target
}
@@ -91,4 +135,10 @@ if (require.main === module) {
}
}
module.exports = { PATCH_MARKER, applyPatch, checkNativeDeps, patchWindowsTerminal }
module.exports = {
PATCH_MARKER,
applyPatch,
checkNativeDeps,
patchConptyConsoleListAgent,
patchWindowsTerminal
}
@@ -0,0 +1,286 @@
import { build } from 'esbuild'
import { spawn } from 'node:child_process'
import { createRequire } from 'node:module'
import { existsSync } from 'node:fs'
import { mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
const WAIT_TIMEOUT_MS = 20_000
const require = createRequire(import.meta.url)
function withTimeout(promise, label, stderr) {
return new Promise((resolvePromise, rejectPromise) => {
const timer = setTimeout(() => {
rejectPromise(new Error(`Timed out waiting for ${label}\n${stderr()}`))
}, WAIT_TIMEOUT_MS)
promise.then(
(value) => {
clearTimeout(timer)
resolvePromise(value)
},
(error) => {
clearTimeout(timer)
rejectPromise(error)
}
)
})
}
function pollUntil(readValue, label, stderr) {
const deadline = Date.now() + WAIT_TIMEOUT_MS
return new Promise((resolveValue, rejectValue) => {
const poll = async () => {
try {
const value = await readValue()
if (value !== undefined) {
resolveValue(value)
return
}
} catch {}
if (Date.now() >= deadline) {
rejectValue(new Error(`Timed out waiting for ${label}\n${stderr()}`))
return
}
setTimeout(poll, 10)
}
void poll()
})
}
function waitForExit(proc) {
if (proc.exitCode !== null || proc.signalCode !== null) {
return Promise.resolve()
}
return new Promise((resolveExit) => proc.once('exit', resolveExit))
}
async function loadProtocol(bundleDir) {
const outfile = join(bundleDir, 'relay-protocol.cjs')
await build({
entryPoints: [resolve('src/relay/protocol.ts')],
bundle: true,
platform: 'node',
format: 'cjs',
outfile,
logLevel: 'silent'
})
return require(outfile)
}
function createRelayClient(entryPath, args, env, protocol) {
const proc = spawn(process.execPath, [entryPath, ...args], {
cwd: dirname(entryPath),
env,
stdio: ['pipe', 'pipe', 'pipe']
})
const messages = []
let stderr = ''
let nextSequence = 1
let stdoutBuffer = Buffer.alloc(0)
let ready = false
let resolveReady
const sentinelReceived = new Promise((resolvePromise) => {
resolveReady = resolvePromise
})
const decoder = new protocol.FrameDecoder((frame) => {
if (frame.type === protocol.MessageType.Regular) {
messages.push(protocol.parseJsonRpcMessage(frame.payload))
}
})
proc.stderr.on('data', (chunk) => {
stderr = `${stderr}${String(chunk)}`.slice(-8_000)
})
proc.stdout.on('data', (chunk) => {
if (ready) {
decoder.feed(chunk)
return
}
stdoutBuffer = Buffer.concat([stdoutBuffer, chunk])
const sentinel = Buffer.from(protocol.RELAY_SENTINEL)
const index = stdoutBuffer.indexOf(sentinel)
if (index < 0) {
return
}
ready = true
resolveReady()
const remainder = stdoutBuffer.subarray(index + sentinel.length)
if (remainder.length > 0) {
decoder.feed(remainder)
}
})
const waitForMessage = (startIndex, predicate, label) =>
pollUntil(
() => messages.slice(startIndex).find(predicate),
label,
() => stderr
)
const request = async (method, params = {}) => {
const id = nextSequence++
const startIndex = messages.length
proc.stdin.write(protocol.encodeJsonRpcFrame({ jsonrpc: '2.0', id, method, params }, id, 0))
const response = await waitForMessage(
startIndex,
(message) => message.id === id,
`response to ${method}`
)
if (response.error) {
throw new Error(`${method} failed: ${response.error.message}`)
}
return response.result
}
const notify = (method, params = {}) => {
const sequence = nextSequence++
proc.stdin.write(protocol.encodeJsonRpcFrame({ jsonrpc: '2.0', method, params }, sequence, 0))
}
return {
proc,
request,
notify,
sentinelReceived: withTimeout(sentinelReceived, 'relay sentinel', () => stderr),
messageCount: () => messages.length,
waitForNotification: (startIndex, method, predicate = () => true) =>
waitForMessage(
startIndex,
(message) => message.method === method && predicate(message.params ?? {}),
`${method} notification`
),
stderr: () => stderr
}
}
async function waitForWatcherPid(pidFile, previousPid, stderr) {
return pollUntil(
async () => {
const pid = Number((await readFile(pidFile, 'utf8')).trim())
if (!Number.isInteger(pid) || pid <= 0 || pid === previousPid) {
return undefined
}
// Why: replacement children reuse this exclusive path after fault injection.
await rm(pidFile, { force: true })
return pid
},
previousPid ? 'replacement watcher child pid' : 'initial watcher child pid',
stderr
)
}
function includesWatchPath(params, targetPath) {
return Array.isArray(params.events)
? params.events.some((event) => event.absolutePath === targetPath)
: false
}
async function main() {
const platform = `${process.platform}-${process.arch}`
const relayEntry = resolve('out', 'relay', platform, 'relay.js')
const watcherEntry = resolve('out', 'relay', platform, 'relay-watcher.js')
if (!existsSync(relayEntry) || !existsSync(watcherEntry)) {
throw new Error(`Missing built relay artifacts for ${platform}; run pnpm run build:relay first`)
}
let tempRoot
let relay
try {
tempRoot = await mkdtemp(join(tmpdir(), 'orca-relay-watcher-fault-'))
const watchRoot = await realpath(tempRoot)
const pidFile = join(tempRoot, 'watcher.pid')
const protocol = await loadProtocol(tempRoot)
const socketPath =
process.platform === 'win32'
? `\\\\.\\pipe\\orca-relay-watcher-fault-${process.pid}-${Date.now()}`
: join(tempRoot, 'relay.sock')
relay = createRelayClient(
relayEntry,
['--sock-path', socketPath, '--endpoint-dir', join(tempRoot, 'agent-hooks')],
{ ...process.env, ORCA_WATCHER_CHILD_PID_FILE: pidFile },
protocol
)
await relay.sentinelReceived
const spawned = await relay.request('pty.spawn', { cols: 80, rows: 24, cwd: watchRoot })
const beforePtyMarker = `ORCA_PTY_BEFORE_${Date.now()}`
let startIndex = relay.messageCount()
relay.notify('pty.data', { id: spawned.id, data: `echo ${beforePtyMarker}\r` })
await relay.waitForNotification(
startIndex,
'pty.data',
(params) => params.id === spawned.id && String(params.data).includes(beforePtyMarker)
)
await relay.request('fs.watch', { rootPath: watchRoot })
const firstWatcherPid = await waitForWatcherPid(pidFile, undefined, relay.stderr)
const beforePath = join(watchRoot, 'before.txt')
startIndex = relay.messageCount()
await writeFile(beforePath, 'before')
await relay.waitForNotification(startIndex, 'fs.changed', (params) =>
includesWatchPath(params, beforePath)
)
const faultSignal = process.platform === 'win32' ? 'SIGTERM' : 'SIGSEGV'
startIndex = relay.messageCount()
process.kill(firstWatcherPid, faultSignal)
const replacementWatcherPid = await waitForWatcherPid(pidFile, firstWatcherPid, relay.stderr)
await relay.waitForNotification(startIndex, 'fs.changed', (params) =>
Array.isArray(params.events)
? params.events.some(
(event) => event.kind === 'overflow' && event.absolutePath === watchRoot
)
: false
)
const status = await relay.request('relay.status')
if (status.pid !== relay.proc.pid) {
throw new Error('relay.status did not come from the original surviving relay process')
}
const afterPtyMarker = `ORCA_PTY_AFTER_${Date.now()}`
startIndex = relay.messageCount()
relay.notify('pty.data', { id: spawned.id, data: `echo ${afterPtyMarker}\r` })
await relay.waitForNotification(
startIndex,
'pty.data',
(params) => params.id === spawned.id && String(params.data).includes(afterPtyMarker)
)
const afterPath = join(watchRoot, 'after.txt')
startIndex = relay.messageCount()
await writeFile(afterPath, 'after')
await relay.waitForNotification(startIndex, 'fs.changed', (params) =>
includesWatchPath(params, afterPath)
)
relay.notify('fs.unwatch', { rootPath: watchRoot })
await relay.request('pty.shutdown', { id: spawned.id })
console.log(
JSON.stringify({
relayPid: relay.proc.pid,
killedWatcherPid: firstWatcherPid,
replacementWatcherPid,
faultSignal,
relaySurvived: true,
existingPtySurvived: true,
overflowRefreshDelivered: true,
postCrashEventDelivered: true
})
)
} finally {
if (relay && relay.proc.exitCode === null && relay.proc.signalCode === null) {
relay.proc.kill('SIGTERM')
try {
await withTimeout(waitForExit(relay.proc), 'relay shutdown', relay.stderr)
} catch {
relay.proc.kill('SIGKILL')
await withTimeout(waitForExit(relay.proc), 'forced relay shutdown', relay.stderr)
}
}
if (tempRoot) {
await rm(tempRoot, { recursive: true, force: true })
}
}
}
await main()
@@ -0,0 +1,41 @@
import { spawnSync } from 'node:child_process'
const rawExtraArgs = process.argv.slice(2)
const extraArgs = rawExtraArgs[0] === '--' ? rawExtraArgs.slice(1) : rawExtraArgs
const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
const env = {
...process.env,
ORCA_E2E_SSH_DOCKER: '1'
}
// Why: Node's CVE-2024-27980 hardening rejects .cmd spawns without shell on Windows.
const spawnOptions = {
stdio: 'inherit',
env,
shell: process.platform === 'win32'
}
const runtime = spawnSync(pnpm, ['run', 'ensure:electron-runtime'], spawnOptions)
if (runtime.status !== 0) {
process.exit(runtime.status ?? 1)
}
const result = spawnSync(
pnpm,
[
'exec',
'playwright',
'test',
'tests/e2e/ssh-docker-watcher-isolation.spec.ts',
'--config',
'tests/playwright.config.ts',
'--project',
'electron-headless',
'--workers=1',
...extraArgs
],
spawnOptions
)
process.exit(result.status ?? 1)
@@ -0,0 +1,180 @@
import { createRequire } from 'node:module'
import { existsSync } from 'node:fs'
import { mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { build } from 'esbuild'
const ENTRY_PATH = resolve('out/main/parcel-watcher-process-entry.js')
const SUPERVISOR_SOURCE = resolve('src/main/ipc/parcel-watcher-process-supervisor.ts')
const WAIT_TIMEOUT_MS = 15_000
const require = createRequire(import.meta.url)
function withTimeout(promise, label) {
return new Promise((resolvePromise, rejectPromise) => {
const timer = setTimeout(
() => rejectPromise(new Error(`Timed out waiting for ${label}`)),
WAIT_TIMEOUT_MS
)
promise.then(
(value) => {
clearTimeout(timer)
resolvePromise(value)
},
(error) => {
clearTimeout(timer)
rejectPromise(error)
}
)
})
}
function nextMatchingEvent(register, predicate, label) {
return withTimeout(
new Promise((resolveEvent) => {
register((events) => {
if (events.some(predicate)) {
resolveEvent(events)
}
})
}),
label
)
}
async function loadSupervisor(bundleDir) {
const outfile = join(bundleDir, 'watcher-supervisor.cjs')
await build({
entryPoints: [SUPERVISOR_SOURCE],
bundle: true,
platform: 'node',
format: 'cjs',
outfile,
external: ['@parcel/watcher', 'electron'],
logLevel: 'silent'
})
return require(outfile).WatcherProcessSupervisor
}
async function main() {
if (process.platform === 'win32') {
console.log('[runtime-file-watcher-fault] SKIP: SIGSEGV oracle is macOS/Linux only')
return
}
if (!existsSync(ENTRY_PATH)) {
throw new Error(`Missing ${ENTRY_PATH}; run pnpm run build:electron-vite first`)
}
// Why: mkdtemp/realpath/bundle/construction can fail before the body runs.
// Keep cleanup in finally from the first successful mkdtemp onward, and clean
// the original temp path if realpath never succeeds.
let createdRootPath
let bundleDir
let rootPath
let supervisor
let subscription
let watcherCanaryDir
let eventListener = () => undefined
let rejectWatcherError
const watcherError = new Promise((_, reject) => {
rejectWatcherError = reject
})
// Attach early so a callback rejection before the race cannot become unhandled.
watcherError.catch(() => undefined)
try {
createdRootPath = await mkdtemp(join(tmpdir(), 'orca-runtime-watcher-fault-'))
bundleDir = await mkdtemp(join(tmpdir(), 'orca-runtime-watcher-harness-'))
// Parcel reports canonical event paths on macOS, where tmpdir() may use the
// /var symlink spelling. Keep the oracle in the same path domain.
rootPath = await realpath(createdRootPath)
const WatcherProcessSupervisor = await loadSupervisor(bundleDir)
supervisor = new WatcherProcessSupervisor()
let resolveInterruption
const interrupted = withTimeout(
new Promise((resolveWait) => {
resolveInterruption = resolveWait
}),
'automatic watcher resubscription'
)
subscription = await supervisor.subscribe(
rootPath,
(error, events) => {
if (error) {
// Why: throws from the async watcher callback escape main()'s try/finally
// and skip teardown. Surface failures through a harness promise instead.
rejectWatcherError(error)
return
}
eventListener(events)
},
{ ignore: ['.git', 'node_modules'] },
{
delivery: { includeDirectoryMetadata: true, maxEventsPerBatch: 200 },
onInterruption: () => resolveInterruption()
}
)
watcherCanaryDir = supervisor.canaryDir
const beforeEvent = nextMatchingEvent(
(listener) => {
eventListener = listener
},
(event) => event.path === join(rootPath, 'before.txt'),
'pre-crash watch event'
)
await writeFile(join(rootPath, 'before.txt'), 'before')
await Promise.race([beforeEvent, watcherError])
const firstChildPid = supervisor.child?.pid
if (!firstChildPid) {
throw new Error('Watcher supervisor did not expose a live child')
}
process.kill(firstChildPid, 'SIGSEGV')
await Promise.race([interrupted, watcherError])
const replacementChildPid = supervisor.child?.pid
if (!replacementChildPid || replacementChildPid === firstChildPid) {
throw new Error('Watcher supervisor did not replace the faulted child')
}
const afterEvent = nextMatchingEvent(
(listener) => {
eventListener = listener
},
(event) => event.path === join(rootPath, 'after.txt'),
'post-crash watch event'
)
await writeFile(join(rootPath, 'after.txt'), 'after')
await Promise.race([afterEvent, watcherError])
console.log(
JSON.stringify({
hostPid: process.pid,
killedWatcherPid: firstChildPid,
replacementWatcherPid: replacementChildPid,
hostSurvived: true,
automaticResubscribe: true,
postCrashEventDelivered: true
})
)
} finally {
try {
await subscription?.unsubscribe()
} finally {
supervisor?.dispose()
await Promise.all([
createdRootPath ? rm(createdRootPath, { recursive: true, force: true }) : Promise.resolve(),
rootPath && rootPath !== createdRootPath
? rm(rootPath, { recursive: true, force: true })
: Promise.resolve(),
bundleDir ? rm(bundleDir, { recursive: true, force: true }) : Promise.resolve()
])
}
}
if (watcherCanaryDir && existsSync(watcherCanaryDir)) {
throw new Error(`Watcher supervisor leaked its canary directory: ${watcherCanaryDir}`)
}
}
await main()
+1 -1
View File
@@ -25,7 +25,7 @@ function getPackagedCliPath(appDir) {
return join(appDir, 'Contents', 'Resources', 'bin', 'orca')
}
if (process.platform === 'win32') {
return join(appDir, 'resources', 'bin', 'orca.cmd')
return join(appDir, 'resources', 'bin', 'orca.exe')
}
return join(appDir, 'resources', 'bin', 'orca-ide')
}
@@ -0,0 +1,394 @@
#!/usr/bin/env node
import { spawn } from 'node:child_process'
import {
accessSync,
chmodSync,
constants as fsConstants,
mkdtempSync,
readFileSync,
rmSync,
statSync,
symlinkSync,
writeFileSync
} from 'node:fs'
import { createServer } from 'node:http'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
const MANAGED_SCRIPTS = [
['antigravity-hook.sh', 'antigravity'],
['claude-hook.sh', 'claude'],
['codex-hook.sh', 'codex'],
['command-code-hook.sh', 'command-code'],
['copilot-hook.sh', 'copilot'],
['cursor-hook.sh', 'cursor'],
['devin-hook.sh', 'devin'],
['droid-hook.sh', 'droid'],
['gemini-hook.sh', 'gemini'],
['grok-hook.sh', 'grok'],
['kimi-hook.sh', 'kimi'],
['openclaude-hook.sh', 'claude']
]
const REQUIRED_JSON_STDOUT = new Set(['antigravity-hook.sh', 'copilot-hook.sh', 'gemini-hook.sh'])
function parseArgs(argv) {
const result = { home: process.env.HOME ?? '', minMtime: 0 }
for (let index = 0; index < argv.length; index += 1) {
const value = argv[index]
if (value === '--home') {
result.home = argv[index + 1] ?? ''
index += 1
} else if (value === '--min-mtime') {
result.minMtime = Number(argv[index + 1] ?? 0)
index += 1
} else {
throw new Error(['Unknown argument: ', value].join(''))
}
}
if (!result.home) {
throw new Error('Pass --home or set HOME to the isolated Electron home directory')
}
return result
}
function withoutOrcaEnvironment(extra = {}) {
return {
...Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith('ORCA_'))),
...extra
}
}
function runShell(command, payload, env) {
return new Promise((resolve, reject) => {
const child = spawn('/bin/sh', ['-c', command], {
env,
stdio: ['pipe', 'pipe', 'pipe']
})
const stdout = []
const stderr = []
const stdinErrors = []
const timeout = setTimeout(() => {
child.kill('SIGKILL')
reject(new Error(['Timed out running: ', command.slice(0, 120)].join('')))
}, 10_000)
child.stdout.on('data', (chunk) => stdout.push(chunk))
child.stderr.on('data', (chunk) => stderr.push(chunk))
child.stdin.on('error', (error) => stdinErrors.push(error))
child.on('error', (error) => {
clearTimeout(timeout)
reject(error)
})
child.on('close', (exitCode) => {
clearTimeout(timeout)
resolve({
exitCode,
stdinErrors,
stdout: Buffer.concat(stdout).toString('utf8'),
stderr: Buffer.concat(stderr).toString('utf8')
})
})
child.stdin.end(payload)
})
}
function assertSuccessfulWrite(result, label) {
if (result.exitCode !== 0) {
throw new Error(
[label, ' exited ', String(result.exitCode), ': ', result.stderr.slice(0, 500)].join('')
)
}
if (result.stdinErrors.length > 0) {
throw new Error(
[
label,
' produced stdin errors: ',
result.stdinErrors.map((error) => error.message).join(', ')
].join('')
)
}
}
function assertProtocolStdout(fileName, stdout) {
if (!REQUIRED_JSON_STDOUT.has(fileName)) {
return
}
const firstLine = stdout.trim().split(/\r?\n/, 1)[0]
try {
JSON.parse(firstLine)
} catch {
throw new Error([fileName, ' did not emit protocol JSON: ', stdout.slice(0, 200)].join(''))
}
}
function readGeneratedScripts(home, minMtime) {
const hooksDir = join(home, '.orca', 'agent-hooks')
return MANAGED_SCRIPTS.map(([fileName, source]) => {
const path = join(hooksDir, fileName)
const stats = statSync(path)
if (!stats.isFile()) {
throw new Error([fileName, ' is not a regular file'].join(''))
}
try {
accessSync(path, fsConstants.R_OK | fsConstants.X_OK)
} catch {
throw new Error([fileName, ' is not readable and executable'].join(''))
}
if (minMtime > 0 && stats.mtimeMs < minMtime) {
throw new Error([fileName, ' predates the Electron launch'].join(''))
}
const body = readFileSync(path, 'utf8')
const captureIndex = body.indexOf('payload=$(cat)')
const firstExitIndex = body.indexOf('exit 0')
if (captureIndex < 0 || firstExitIndex <= captureIndex) {
throw new Error([fileName, ' can exit before capturing stdin'].join(''))
}
return { body, fileName, path, source }
})
}
function findStrings(value, matches = []) {
if (typeof value === 'string') {
matches.push(value)
return matches
}
if (Array.isArray(value)) {
for (const child of value) {
findStrings(child, matches)
}
return matches
}
if (value && typeof value === 'object') {
for (const child of Object.values(value)) {
findStrings(child, matches)
}
}
return matches
}
function nextRequest(server) {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
server.removeListener('request', onRequest)
reject(new Error('Generated hook did not reach the loopback server'))
}, 8_000)
const onRequest = (request, response) => {
const chunks = []
request.on('data', (chunk) => chunks.push(chunk))
request.on('end', () => {
clearTimeout(timeout)
response.writeHead(200, { 'Content-Type': 'application/json' })
response.end('{}')
resolve({
body: Buffer.concat(chunks).toString('utf8'),
headers: request.headers,
url: request.url
})
})
}
server.once('request', onRequest)
})
}
async function verifyNoOpWrites(scripts, home, payload) {
const commandCodeBin = mkdtempSync(join(tmpdir(), 'orca-hook-command-code-bin-'))
symlinkSync('/bin/cat', join(commandCodeBin, 'cat'))
try {
for (const script of scripts) {
const path =
script.fileName === 'command-code-hook.sh'
? commandCodeBin
: (process.env.PATH ?? '/usr/bin:/bin')
const result = await runShell(
['/bin/sh ', JSON.stringify(script.path)].join(''),
payload,
withoutOrcaEnvironment({
HOME: home,
PATH: path,
ORCA_AGENT_HOOK_ENDPOINT: ''
})
)
assertSuccessfulWrite(result, [script.fileName, ' no-op'].join(''))
assertProtocolStdout(script.fileName, result.stdout)
}
} finally {
rmSync(commandCodeBin, { recursive: true, force: true })
}
}
async function verifyClaudeDevinSkip(scripts, home, payload) {
const claude = scripts.find((script) => script.fileName === 'claude-hook.sh')
let unexpectedRequests = 0
const server = createServer((_request, response) => {
unexpectedRequests += 1
response.writeHead(200, { 'Content-Type': 'application/json' })
response.end('{}')
})
await new Promise((resolve, reject) => {
server.once('error', reject)
server.listen(0, '127.0.0.1', resolve)
})
try {
const address = server.address()
if (!address || typeof address === 'string') {
throw new Error('Claude skip verifier did not receive a TCP port')
}
const result = await runShell(
['/bin/sh ', JSON.stringify(claude.path)].join(''),
payload,
withoutOrcaEnvironment({
DEVIN_PROJECT_DIR: join(home, 'devin-project'),
HOME: home,
ORCA_AGENT_HOOK_ENDPOINT: '',
ORCA_AGENT_HOOK_PORT: String(address.port),
ORCA_AGENT_HOOK_TOKEN: 'electron-verification-token',
ORCA_PANE_KEY: 'electron-verification-pane'
})
)
assertSuccessfulWrite(result, 'Claude Devin-import skip')
if (unexpectedRequests !== 0) {
throw new Error('Claude forwarded a hook imported by Devin')
}
} finally {
await new Promise((resolve) => server.close(resolve))
}
}
async function verifyForwarding(scripts, home, payload) {
const server = createServer()
await new Promise((resolve, reject) => {
server.once('error', reject)
server.listen(0, '127.0.0.1', resolve)
})
try {
const address = server.address()
if (!address || typeof address === 'string') {
throw new Error('Loopback verifier did not receive a TCP port')
}
for (const script of scripts) {
const requestPromise = nextRequest(server)
const result = await runShell(
['/bin/sh ', JSON.stringify(script.path)].join(''),
payload,
withoutOrcaEnvironment({
HOME: home,
ORCA_AGENT_HOOK_ENDPOINT: '',
ORCA_AGENT_HOOK_PORT: String(address.port),
ORCA_AGENT_HOOK_TOKEN: 'electron-verification-token',
ORCA_PANE_KEY: 'electron-verification-pane',
ORCA_TAB_ID: 'electron-verification-tab',
ORCA_WORKTREE_ID: 'electron-verification-worktree',
ORCA_AGENT_HOOK_ENV: 'test',
ORCA_AGENT_HOOK_VERSION: '1',
ORCA_ANTIGRAVITY_EVENT: 'PostInvocation',
ORCA_COPILOT_HOOK_EVENT: 'PostToolUse'
})
)
assertSuccessfulWrite(result, [script.fileName, ' forwarding'].join(''))
assertProtocolStdout(script.fileName, result.stdout)
const request = await requestPromise
const form = new URLSearchParams(request.body)
if (request.url !== ['/hook/', script.source].join('')) {
throw new Error(
[script.fileName, ' posted to ', String(request.url), ' instead of ', script.source].join(
''
)
)
}
if (request.headers['x-orca-agent-hook-token'] !== 'electron-verification-token') {
throw new Error([script.fileName, ' lost the hook token header'].join(''))
}
if (form.get('payload') !== payload) {
throw new Error([script.fileName, ' changed the forwarded payload'].join(''))
}
if (form.get('paneKey') !== 'electron-verification-pane') {
throw new Error([script.fileName, ' changed the forwarded pane key'].join(''))
}
}
} finally {
await new Promise((resolve) => server.close(resolve))
}
}
// Why: rewrite from the path embedded in the installed command, not a
// reconstructed join(home, ...). That way missing/failing-script cases cannot
// silently re-run the real script if the install layout changes.
function rewriteLauncherScriptPath(command, nextPath) {
const match = /if \[ -f '([^']+)'/.exec(command)
if (!match) {
throw new Error('Installed launcher command did not reference a quoted script path')
}
return command.replaceAll(match[1], nextPath)
}
async function verifyInstalledLauncher(home, payload) {
const settingsPath = join(home, '.claude', 'settings.json')
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'))
const command = findStrings(settings).find(
(value) => value.includes('claude-hook.sh') && value.includes('if [ -f ')
)
if (!command || !command.includes('] && [ -r ') || !command.includes('else cat >/dev/null')) {
throw new Error('Electron did not install the guarded Claude launcher')
}
const scratch = mkdtempSync(join(tmpdir(), 'orca-hook-launcher-'))
try {
const missingPath = join(scratch, 'missing-hook.sh')
const missingResult = await runShell(
rewriteLauncherScriptPath(command, missingPath),
payload,
withoutOrcaEnvironment({ HOME: home })
)
assertSuccessfulWrite(missingResult, 'installed missing-script launcher')
const failingPath = join(scratch, 'failing-hook.sh')
writeFileSync(failingPath, '#!/bin/sh\ncat >/dev/null\nexit 7\n', 'utf8')
chmodSync(failingPath, 0o755)
const failingResult = await runShell(
rewriteLauncherScriptPath(command, failingPath),
payload,
withoutOrcaEnvironment({ HOME: home })
)
if (failingResult.exitCode !== 7 || failingResult.stdinErrors.length > 0) {
throw new Error('Installed launcher did not preserve a running script failure')
}
} finally {
rmSync(scratch, { recursive: true, force: true })
}
}
async function main() {
const args = parseArgs(process.argv.slice(2))
const payload = JSON.stringify({
hook_event_name: 'PostToolUse',
tool_name: 'shell',
tool_output: 'x'.repeat(1_200_000)
})
const scripts = readGeneratedScripts(args.home, args.minMtime)
await verifyNoOpWrites(scripts, args.home, payload)
await verifyClaudeDevinSkip(scripts, args.home, payload)
await verifyForwarding(scripts, args.home, payload)
await verifyInstalledLauncher(args.home, payload)
process.stdout.write(
[
JSON.stringify(
{
forwardingPayloadBytes: Buffer.byteLength(payload),
claudeDevinSkipCases: 1,
launcherCases: 2,
noOpScripts: scripts.length,
forwardedScripts: scripts.length,
status: 'passed'
},
null,
2
),
'\n'
].join('')
)
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack : error)
process.exitCode = 1
})
+1
View File
@@ -3,6 +3,7 @@
"include": [
"../src/cli/**/*",
"../src/shared/**/*",
"../src/main/agent-hooks/hook-stdin-contract.ts",
"../src/main/agent-hooks/installer-utils.ts",
"../src/main/agent-hooks/installer-utils-remote.ts",
"../src/main/agent-hooks/managed-agent-hook-controls.ts",
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 4.5m">
<title>downloads: 4.5m</title>
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 5.0m">
<title>downloads: 5.0m</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@@ -15,7 +15,7 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
<text x="37" y="14">downloads</text>
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">4.5m</text>
<text x="93.5" y="14">4.5m</text>
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">5.0m</text>
<text x="93.5" y="14">5.0m</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 943 B

After

Width:  |  Height:  |  Size: 943 B

+84
View File
@@ -0,0 +1,84 @@
# Claude Scoped OAuth Usage Limits
## Problem
Anthropic's current OAuth usage response reports Fable in `limits` as a model-scoped weekly limit instead of one of the legacy top-level Fable fields. Orca ignores `limits`, maps `fableWeekly` to `null`, and then depends on a hidden Claude `/usage` PTY read that is disabled on Windows and can fail silently elsewhere.
- `src/main/rate-limits/claude-fetcher.ts:300` models only top-level OAuth windows.
- `src/main/rate-limits/claude-fetcher.ts:393` maps only legacy Fable field names.
- `src/main/rate-limits/service.ts:1203` disables the PTY supplement on Windows.
- `src/renderer/src/components/status-bar/tooltip.tsx:172` renders Fable whenever `fableWeekly` is populated.
## Root Cause
The OAuth response contract evolved from dedicated model fields to generic entries shaped like `kind: "weekly_scoped"`, `percent`, `resets_at`, and `scope.model.display_name`. Orca's response type and mapper were not updated for that shape.
## Non-goals
- Do not change polling, credentials, token refresh, account switching, renderer layout, or usage percentage semantics.
- Do not remove the existing PTY supplement or legacy field compatibility.
- Do not generalize shared renderer state to arbitrary model windows in this targeted bug fix.
## Design
1. Extend the private OAuth response type with an optional `limits` array containing only the fields needed for safe parsing.
2. Select a Fable entry only when `kind` is `weekly_scoped`, the model display name is Fable (case-insensitive), and `percent` is finite.
3. Map the scoped entry to the existing seven-day `fableWeekly` window, including its reset timestamp.
4. Prefer the current scoped entry, then retain the three legacy top-level fields as fallbacks.
5. Keep malformed, unrelated, inactive, or absent entries non-fatal. Treat `is_active: false` as unavailable so stale promotional limits disappear; accept a missing activity flag for compatibility.
## Data Flow
- OAuth response
- `limits[].weekly_scoped` Fable -> `fableWeekly`
- otherwise legacy explicit Fable field -> `fableWeekly`
- otherwise existing optional PTY supplement
- Existing provider state -> existing status-bar and details rendering
## Edge Cases
- `limits` is missing, null, malformed, or contains null entries.
- A scoped entry names another model.
- Fable percent is missing, non-numeric, or non-finite.
- Fable is inactive and should not be rendered.
- `is_active` is omitted by an older server response but the remaining scoped entry is valid.
- Both current and legacy fields exist; the current scoped entry wins.
- Reset timestamps may be ISO strings, epoch seconds, epoch milliseconds, or absent.
- Windows, WSL, SSH, and remote runtimes use the same OAuth mapper and require no platform-specific execution.
## Test Plan
- Unit: reproduce a current real-response shape and assert Fable maps without a PTY attempt.
- Unit: assert scoped data wins over a legacy field.
- Unit: assert inactive, malformed, and unrelated scoped entries are ignored while legacy fallback remains available.
- Regression: retain existing legacy-field and bare-`fable` behavior tests.
- Verification: focused Claude fetcher tests, typecheck, lint, and max-lines ratchet.
- Electron: refresh Claude usage and confirm Session, Weekly, and Fable remain visible in the existing status-bar details surface.
## UI Quality Bar
No UI implementation changes. The existing Fable row must reappear with the same typography, spacing, progress bar, percentage semantics, and reset copy as adjacent Session and Weekly rows.
## Review Screenshots
1. Claude usage details showing Session, Weekly, and Fable from a live OAuth refresh.
2. Adjacent status-bar context showing the Claude provider remains visually unchanged outside the restored row.
## Rollout
1. Add the scoped OAuth response types and mapper.
2. Add focused current-schema and compatibility regression tests.
3. Run focused and repository checks.
4. Validate the restored row in Electron and capture review screenshots.
5. Commit, push, and open an unmerged PR.
## Lightweight Eng Review
- Scope: Kept to the private OAuth mapper and tests; no shared-state or renderer generalization is required to restore Fable.
- Architecture/data flow: OAuth remains authoritative, with structured scoped data preferred over legacy fields and PTY used only as the existing final supplement.
- Failure modes covered: malformed optional data, unrelated models, inactive limits, missing activity flags, duplicate old/new representations, missing reset metadata, and platform-neutral execution.
- Test coverage required: current-schema success without PTY, precedence, inactivity, malformed/unrelated entries, and legacy fallback.
- Performance/blast radius: One bounded linear scan of the small response `limits` array per existing OAuth refresh; no new requests, polling, subprocesses, IPC, storage, or renderer work.
- UI quality bar: Existing status-bar visuals must remain unchanged except for the restored Fable row.
- Required review screenshots: Live Claude details with all three rows; surrounding status-bar context.
- Residual risks: Anthropic may rename the scoped model display label; legacy and PTY fallbacks remain available.
@@ -0,0 +1,198 @@
# Agent Hook Stdin Lifecycle
## Problem
Orca installs agent hooks in global agent configuration, so a managed hook can
run in an Orca pane, an SSH/WSL runtime, a detached session, or an ordinary
terminal with no Orca environment. Hook runners write an event payload to the
child process's stdin after spawn.
The generated scripts currently inspect Orca environment variables before they
read stdin. When a guard exits first, the hook runner can still be writing to a
pipe whose reader has closed. POSIX reports `EPIPE`; Windows reports
`ERROR_BROKEN_PIPE`. The same failure exists when a managed launcher finds that
its script was removed or is no longer executable.
This is an ownership bug rather than an agent-specific parsing bug. Every
process that accepts a hook payload owns the read end until it reaches EOF,
including processes that decide the event is irrelevant.
## Goals
- Make successful hook no-ops consume stdin to EOF on macOS, Linux, Windows,
WSL, and SSH hosts.
- Encode stdin ownership once per platform instead of copying ad hoc drains
into every early-exit branch.
- Preserve payload bytes, hook output, transport timeouts, exit-code behavior,
and provider-specific event metadata on the forwarding path.
- Cover every generated script and missing-script launcher with behavioral
regression tests.
## Non-goals
- Do not redesign hook configuration schemas, endpoint discovery, relay HTTP
payloads, or event selection.
- Do not make hook payloads unbounded; existing agent and config timeouts remain
the outer lifecycle bound.
- Do not suppress failures from a script that exists and actually runs. Only a
deliberate no-op path exits successfully after consuming stdin.
- Do not change plugin-based integrations that do not receive piped hook stdin.
## Contract
1. A generated hook script consumes its input exactly once.
2. No whole-script success exit may occur before that consumption completes.
3. A launcher that declines to start a missing, unreadable, or non-executable
managed script becomes the stdin owner and drains to EOF before returning
success.
4. A launcher propagates the exit code of a managed script that was started.
5. Output required by an agent protocol may be emitted before stdin is read,
but the process must still retain the read end until EOF.
6. Drain commands are platform-qualified where the platform searches the
working directory implicitly.
## Design
### POSIX generated scripts
Capture stdin near the start of the generated script, after any protocol output
that must be immediate and before endpoint refresh, environment guards, or
provider-specific skips:
```sh
payload=$(cat)
if [ -z "$payload" ]; then
exit 0
fi
```
Antigravity is the one semantic exception: events without payload still post an
empty object, so its shared capture policy maps empty input to `{}` instead of
exiting. Claude's Devin-import skip happens after capture. Command Code captures
before ancestor/endpoint recovery so its comparatively expensive discovery
cannot leave the writer blocked.
The common payload-capture fragments live in
`src/main/agent-hooks/hook-stdin-contract.ts`. Templates choose the required or
empty-object policy instead of spelling the lifecycle independently.
### Windows batch generated scripts
Batch scripts stream stdin directly into system `curl.exe`; buffering arbitrary
JSON in an environment variable would corrupt metacharacters and hit size
limits. Their posting path therefore remains streaming.
All environment guard failures jump to one epilogue:
```bat
if "%ORCA_AGENT_HOOK_PORT%"=="" goto :orca_agent_hook_drain_stdin
...
exit /b 0
:orca_agent_hook_drain_stdin
"%SystemRoot%\System32\more.com" >nul 2>nul
exit /b 0
```
`more.com` is qualified because Windows searches the current working directory
for executables. Shared guard and epilogue builders keep labels and commands
identical across templates. Command Code's existing endpoint-discovery labels
remain subroutines and the drain epilogue is placed after them.
### PowerShell generated scripts
PowerShell captures with `[Console]::In.ReadToEnd()` before endpoint and
environment guards. Copilot then parses the captured value only on the posting
path. This mirrors POSIX ownership without starting an additional process.
### Managed launchers
- POSIX `/bin/sh` launchers require a regular, readable, executable file. The
rejected path drains with `cat`; the started-script branch keeps propagating
status.
- Encoded PowerShell launchers use `Test-Path -PathType Leaf`. A missing script
calls `[Console]::In.ReadToEnd()` and exits zero; an existing script preserves
`$LASTEXITCODE`.
- Codex's cmd.exe fast path remains PowerShell-free. It rejects missing paths
and directories, using the same qualified `more.com` drain for both.
- Claude's Git Bash fast path uses a POSIX file guard and drain while continuing
to execute the `.cmd` directly rather than interpreting it as shell source.
- Agent-specific direct launchers, including Antigravity event wrappers and
Copilot's PowerShell-file command, adopt the same missing-file behavior.
## Data Flow
```text
hook runner spawns command
-> runner writes payload and closes stdin
-> launcher starts managed script
-> script consumes payload
-> refreshes endpoint / evaluates guards
-> posts or exits zero
-> OR launcher cannot start script
-> launcher drains payload
-> exits zero
```
Local, WSL, and SSH installs serialize the same POSIX template. Windows local
installs use the batch or PowerShell template. No host assumes another host's
path syntax or shell.
## Failure Semantics
- Missing Orca environment: consume input, exit zero, emit only protocol-required
output.
- Empty payload: consume EOF, then follow the agent's existing empty-event rule.
- Missing/unreadable/non-executable script: launcher consumes input and exits
zero.
- Endpoint parse/read failure: preserve the existing fail-open behavior after
stdin ownership has been satisfied.
- Existing script returns nonzero: propagate its status; do not drain again or
disguise the script failure.
- Hook runner never closes stdin: the existing config-level timeout terminates
the hook. Reading to EOF does not introduce an unbounded lifecycle beyond that
already required by normal payload parsing.
## Verification
Add one cross-agent lifecycle suite rather than per-service string-position
assertions.
- Generate every SSH-compatible POSIX managed script, spawn it with Orca
environment removed, write a payload larger than pipe buffers, and assert:
exit zero, no stdin error, and required protocol output remains valid.
- Exercise the Claude Devin skip independently because it is a second no-op
condition before endpoint forwarding.
- Exercise POSIX, encoded PowerShell, cmd.exe, and Git Bash missing-script
launchers with a large payload and verify zero write errors.
- On Windows CI, install and execute every local batch/PowerShell managed script
with missing Orca environment and a large payload.
- Keep structural assertions for shared batch guard/epilogue generation, but do
not use substring placement as the primary regression gate.
- Preserve existing successful-post, timeout, quoting, SSH install, WSL, and
nonzero-exit propagation tests.
For a macOS smoke test, launch the real Electron app with an isolated `HOME`
and `ORCA_DEV_USER_DATA_PATH`, record the launch time, then run:
```sh
node config/scripts/verify-agent-hook-stdin-lifecycle.mjs \
--home "$ISOLATED_HOME" \
--min-mtime "$LAUNCH_START_MS"
```
The verifier rejects stale files, then exercises the 12 POSIX scripts written
by that Electron launch with a payload larger than pipe buffers. It covers
successful no-ops, loopback forwarding without payload changes, required JSON
stdout, Claude's Devin skip, and both branches of the installed missing-script
launcher.
## Rollout And Compatibility
The generated scripts are rewritten by existing install/update flows, so no
config migration is needed. Commands use POSIX `sh` primitives, Windows inbox
executables, and PowerShell features available on supported Windows releases.
No Git behavior or provider-specific review behavior changes.
The change should ship atomically across templates and launchers. A partial
rollout would leave global hooks with platform- or agent-dependent pipe safety,
which is the inconsistency this design removes.
@@ -0,0 +1,132 @@
# Cold-park reveal cost — measured
Companion to [`terminal-hidden-view-parking.md`](./terminal-hidden-view-parking.md).
Quantifies the reveal latency a user pays when returning to a terminal whose
hidden view was cold-parked, so the keep-warm policy is tuned from data rather
than guessed. Motivated by field reports that "terminal mounting is jumpy —
switching to a worktree after a while takes ~1s".
## How to reproduce
```bash
pnpm bench:cold-park-reveal -- --cycles=12 # empty shell
pnpm bench:cold-park-reveal -- --cycles=10 --scrollback-lines=5000
pnpm bench:cold-park-reveal -- --cycles=10 --scrollback-lines=20000
```
The bench (`tools/benchmarks/terminal-cold-park-reveal-bench.mjs`) drives the
real dev app over CDP. Each cycle times a **cold** reveal (tab confirmed parked
via `window.__terminalParkingDebug.parkedTabIds()` before revealing) and a
**warm** reveal (revealed inside the hot-retain window, confirmed not parked),
so the cold−warm delta is the isolated cost of parking. It shrinks the 30s
hysteresis with `ORCA_E2E_TERMINAL_PARKING_DELAY_MS` and roots userData under a
short `~/.ocpb` path — the default `/var/folders` tmp path overruns the macOS
104-char Unix-socket limit, the daemon fails to bind, and terminals fall back to
non-snapshot PTYs that never park.
Phases per reveal (ms from the switch-back click): `activationMs` (store active
worktree flips), `ptyBindMs` (revealed pane has a bound pty — xterm remounted
and attached), `paintSettleMs` (two RAFs after ptyBind).
## Results (macOS, dev app, medians / p95)
| Scrollback | warm paintSettle | cold paintSettle | **cold-park penalty** |
| ---------- | ---------------- | ---------------- | --------------------- |
| empty shell | 104 ms | 264 / 298 ms | **+160 ms** |
| 5 000 lines | 131 ms | 318 / 423 ms | **+187 ms** |
| 20 000 lines | 102 ms | 266 / 286 ms | **+164 ms** |
All cold samples confirmed parked (`parkedConfirmed = N/N`); all warm samples
confirmed not parked.
## Finding
**The cold-park reveal penalty is ~160–190 ms and effectively flat across buffer
size.** Growing scrollback 0 → 20 000 lines did not grow the penalty (the 20k
run is within noise of empty). So the cost is **fixed remount overhead** — React
subtree teardown/rebuild, fresh xterm construction, WebGL attach, fit, and the
reattach reset — **not** daemon snapshot replay, which scales fine.
Implications for tuning:
- The lever is **how often a remount happens**, not how much is replayed.
Widening the keep-warm working set (worktree hot-retain limit is 4, tabs 12)
and never parking the last-active tab per worktree remove remounts entirely
for the common switch-away-and-back, which is worth more than shaving the
~170 ms remount itself.
- Pre-warming a predicted reveal (sidebar hover, jump-palette selection) hides
the fixed cost behind the navigation instead of eliminating it.
- This synthetic terminal is a plain shell. A live agent TUI in the alternate
screen pays additional reattach-reset + mode-rehydrate work on reveal (see the
Grok-scrollback reattach path), so real-world cold reveals of a busy agent
pane can exceed these numbers — the ~170 ms here is a floor for the mount
overhead, not a ceiling for the full experience.
## Is parking worth having? (benefit side)
`tools/benchmarks/terminal-cold-park-resource-bench.mjs`
(`pnpm bench:cold-park-resource`) A/Bs the `terminalHiddenViewParking` setting
in one session: mount N worktree terminals, background all but one, then read
resources with parking off vs on. Measured at 8 backgrounded worktrees
(5 000-line scrollback each):
| metric | parking off | parking on | released |
| ------ | ----------- | ---------- | -------- |
| mounted pane managers | 9 | 1 | **8** |
| DOM nodes | 2 941 | 1 389 | **1 552** |
| attached WebGL contexts | 1 | 1 | **0** |
So parking's unique win is **releasing every hidden terminal's mounted view** —
8 xterm instances + their DOM subtrees (~1 550 nodes) here. At xterm's default
5 000-row scrollback, each terminal buffer alone is ≥ ~4–5 MB of typed-array
cell data (12 bytes/cell × 80 cols × 5 000 rows), so the renderer-memory floor
is on the order of **several MB per hidden terminal** — tens of MB across a
many-worktree session, before DOM and addon overhead.
Two honest caveats on the numbers:
- The **WebGL-context budget is NOT parking's win.** Even with parking off, only
one context was attached across 9 mounted terminals — the separate
WebGL-suspend-on-hide path (`suspendRendering`) already releases hidden panes'
contexts. So the #6874 context-exhaustion protection stands with or without
parking; parking's benefit is renderer memory + DOM, not contexts.
- The measured JS-heap delta was small (~1.7 MB) because detached xterm
typed-array buffers GC lazily and the first measurement ran before they were
reclaimed. The bench was upgraded to force multi-pass GC and read whole-app
process memory (`window.api.memory.getSnapshot`), but a machine under disk +
memory pressure could not complete the upgraded run (esbuild dev-service
crashes). Re-run the upgraded bench on a healthy machine to get the settled
whole-app memory figure; the pane-manager / DOM release above is the robust,
reproducible part.
### Verdict
Parking earns its keep for the **many-worktree power user** (the reporter's
profile): it is the only mechanism that frees hidden terminals' view memory and
DOM, which is the dominant renderer cost at scale. It does **not** provide the
GPU-context protection (that is suspend-on-hide's job). The reveal price is a
flat ~170 ms remount. Net: keep it, and tune the keep-warm caps so the common
few-worktree rotation never pays the remount.
### Tuning applied
Because the reveal cost is a fixed remount, the levers are all about
**frequency**, not replay size. Three changes to
`terminal-hidden-view-parking.ts`:
- **Worktree hot-retain limit 4 → 8.** Eight covers the ordinary working set;
at the ~4–5 MB renderer floor per hidden worktree that is ~16–20 MB worst
case to eliminate remounts for the common rotation. Beyond 8, parking still
reclaims for the heavy tail it was built for.
- **Hot-retain TTL 5 min → 15 min** (worktree and tab). The cap, not the clock,
is the primary evictor now; 5 min was aggressive enough that a meeting-length
absence parked the whole warm set. The tab count cap stays 12 (already
generous).
- **Last-active exemption.** The single most-recently-hidden worktree/tab is
never parked, regardless of TTL or cap, so returning to the view you just
left is always instant. Implemented in `selectIdsBeyondHotRetain`; it holds
one warm slot and counts against the cap.
Not done (deliberately): pre-warm on predicted reveal — it hides the cost
behind navigation rather than removing it and adds hover/prediction complexity;
and the 30 s cold-park delay is unchanged (it is the quick-flip guard).
@@ -29,7 +29,11 @@ A pure policy module decides which hidden terminal tabs may park:
- Cold-park hysteresis: a tab must be hidden for 30s before parking.
- Hot-retain working set: recently visible worktrees/tabs are retained
(5 minutes, bounded count) so quick tab switches never pay a re-hydrate.
(15 minutes; up to 8 worktrees / 12 tabs) so quick tab switches never pay a
re-hydrate. The single most-recently-hidden (last-active) worktree/tab is
exempt from both the retain TTL and the count cap, so switching back to the
view you just left is always instant regardless of how long you were away.
See `terminal-cold-park-reveal-cost.md` for the data behind these caps.
- Eligibility excludes: visible panes, hidden-measuring startup probes,
activity-portal panes, tabs with pending startup commands or pending
activation spawns, floating-panel tabs, and any tab whose PTY is not
@@ -0,0 +1,552 @@
# Terminal Session Ownership and Teardown
**Status:** Incident fix and pane-authority transfer implemented
**Date:** 2026-07-13
**Incident:** [`../../pty-exhaustion-agent-session-leak.md`](../../pty-exhaustion-agent-session-leak.md)
**Related contracts:** [`terminal-model-view-contract.md`](./terminal-model-view-contract.md),
[`terminal-hidden-view-parking.md`](./terminal-hidden-view-parking.md)
## Summary
Terminal process lifetime must be owned by explicit product intent, never by
whether a React view happens to be mounted.
Orca currently preserves a PTY when a terminal view detaches, which is correct
for view parking, tab moves, renderer reload, and warm reattach. It also relies
on `TerminalPane` unmount to destroy a PTY after a tab is closed. Hidden-view
parking invalidated that implicit invariant: a parked tab has no mounted pane,
so closing it removes renderer state and observers without terminating the PTY.
Agent resume records survive the same close and later interpret the missing pane
as failed recovery, launching the deliberately closed session again.
This design establishes one authoritative lifecycle contract:
- **Close tab or pane:** permanently retire the surface, terminate every PTY it
owns, and revoke every resume record for its panes.
- **Detach or park:** remove only the view connection; preserve the PTY and its
durable identity.
- **Sleep:** intentionally checkpoint resumable agents, terminate the PTYs, and
preserve only the checkpoint needed for wake.
- **Quit or reload:** follow the configured persistence policy; warm detach is
allowed, but it is not a tab close.
The immediate implementation makes the existing terminal-tab state close the
authoritative compatibility boundary for explicit retirement while adding an
explicit natural-exit reason for callers that are only reconciling an already
dead PTY. A follow-up separates the command and reducer APIs once all direct
store callers have migrated.
## Implementation status
The incident patch ships the retirement planner, provider-aware close routing,
unified-only retirement, parked watcher/candidate disposal, resume-authority
revocation, explicit natural-exit handling, late-binding rejection, and direct
background-launch ownership checks. It also adds exact pane retirement and a
persisted physical-to-owner pane-key alias for detach, plus a live Electron
parked-close gate. Focused tests cover local, SSH, split, parked, shared,
paired-host, ordinary runtime, pane-transfer, and late-spawn paths.
Origin-aware defensive expiry remains a follow-up. Immediate deletion on
explicit close fixes the incident without applying a wall-clock policy that
could invalidate intentional long-lived `worktree-sleep` checkpoints.
## Goals
1. Closing a terminal tab submits retirement for all exclusively owned local,
daemon, WSL, SSH, or runtime PTYs, including every split pane, whether its
views are mounted or parked.
2. Closing a terminal pane terminates only that pane's PTY; detaching a pane to
another tab preserves its PTY, hook identity, and resume authority under the
new owning pane.
3. Closing a tab permanently removes its agent resume authority. App restart,
worktree activation, mobile wake, and periodic capture must not resurrect it.
4. Parking, tab-group moves, renderer reload, and warm reattach keep their
existing process-preservation behavior.
5. Remote-runtime and web-session surfaces are terminated by their owning host,
not by sending a host-scoped ID to the local PTY provider.
6. Teardown is idempotent and safe under duplicate close requests, late PTY
exits, late agent-hook events, in-flight spawns, and provider disconnects.
7. A future bookkeeping regression cannot grow without bound: unowned defensive
resume records have an origin-aware expiry, and provider-owned detached
sessions gain a bounded orphan policy in the daemon-lease phase.
## Non-goals
- Changing terminal output, snapshot, replay, query-response, or hidden-delivery
behavior.
- Killing processes that deliberately daemonize away from the terminal process
group. This contract covers the PTY and its attached foreground process tree.
- Changing agent-provider resume commands or permission flags.
- Making a UI close wait for a remote process to exit before the tab disappears.
- Replacing worktree sleep with tab close. Sleep remains resumable by design.
- Claiming that a disconnected SSH relay process is already dead when main can
only tombstone its app-scoped ID. Durable relay-side kill-on-reconnect is a
follow-up.
## Terminology
- **Surface:** a terminal tab or a pane within a split terminal tab.
- **View:** a renderer xterm or pane-less parked watcher observing a surface.
- **Session:** the provider-owned PTY identified by a PTY ID.
- **Resume authority:** a `sleepingAgentSessionsByPaneKey` record that permits
Orca to launch an agent-provider resume command.
- **Retire:** permanently close a surface and revoke its process and resume
ownership.
- **Detach:** disconnect a view while preserving the session for reattachment.
## Open-source prior art
The lifecycle distinction is established in mature terminal implementations:
- VS Code's terminal instance disposal calls process-manager disposal, and the
process manager marks the process `KilledByUser` before shutdown. Its separate
`detachFromProcess` path deliberately clears the client reference without
shutting down the process. It also shuts down a process whose asynchronous
creation completes after the manager was disposed. See
[`terminalInstance.ts`](https://github.com/microsoft/vscode/blob/3f5c62a95ddb886424da463b41ac3ac5e45aa04f/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts#L1287-L1325)
and
[`terminalProcessManager.ts`](https://github.com/microsoft/vscode/blob/3f5c62a95ddb886424da463b41ac3ac5e45aa04f/src/vs/workbench/contrib/terminal/browser/terminalProcessManager.ts#L207-L239).
- tmux exposes the same contract as separate commands: `detach-client` removes
a client while leaving the session intact, whereas `kill-session` destroys
the session and its windows. See
[`tmux.1`](https://github.com/tmux/tmux/blob/42b3ea0d7411acb4cd0357a3c2829d986b455918/tmux.1#L1177-L1218).
Orca needs the same semantic split, extended across local, WSL, SSH, persistent
daemon, and remote-runtime ownership. The important precedent is the explicit
intent boundary, not a particular framework or process API.
## Non-negotiable invariants
1. View lifetime and process lifetime are independent. Mount and unmount may
attach or detach observers; they do not decide whether a session should live.
2. Every live PTY has an owning surface or an explicit bounded detached state.
3. A user close is terminal: once accepted, no status event, session snapshot,
or runtime replay may recreate the closed surface or resume its agent.
4. A tab's retirement candidates are the union of its live index, tab row,
persisted split layout, last-known relay ID, deferred SSH ID, and pending
reconnect ID. A candidate is killable only when no other live surface claims
it.
5. Provider routing follows the execution owner:
- local/daemon/WSL and app-scoped SSH IDs use `pty.kill`;
- `remote:` runtime mirror IDs are never passed to the local PTY provider;
- paired web tabs are closed through the host session RPC.
6. Teardown requests are idempotent. Repeated kill, exit-after-kill, and
close-after-exit must converge on the same empty state.
7. Resume-record validity is origin-aware. Intentional `worktree-sleep` records
are not expired by agent-status freshness; unowned defensive `live` and
`quit` records use a documented decision-time horizon.
8. Natural process exit and explicit user retirement are distinct intents.
Reconciliation after an exit must not silently adopt destructive user-close
semantics.
## Lifecycle matrix
| Intent | View | PTY | Resume record | Persistent tab state |
| --------------------- | ------------------------------------------ | ---------------------------------- | --------------------------------------------------- | -------------------- |
| Switch tabs/worktrees | hidden or parked | keep | keep/update | keep |
| Move pane/tab group | detach, then remount | keep | keep | move |
| Renderer reload | detach | keep when warm persistence applies | keep | keep |
| App quit | detach or terminate per persistence policy | policy-dependent | capture live agents | keep |
| Sleep worktree | unmount | terminate | capture intentional sleep checkpoint | keep identifiers |
| Close split pane | destroy | terminate that pane | delete that pane record | remove pane |
| Close terminal tab | destroy or already absent | terminate all tab PTYs | delete all tab records | remove tab |
| Remove worktree | destroy | terminate all worktree PTYs | delete all worktree records | remove worktree |
| PTY exits naturally | disconnect | already dead | clear or retain completed evidence per agent policy | reconcile surface |
## Current failure
The current close path deletes `ptyIdsByTabId[tabId]` and the tab layout, then
relies on a mounted `TerminalPane` cleanup to call `transport.destroy()`. A
parked tab has already run the detach branch and has no mounted cleanup left.
`disposeParkedTabWatchers` only unregisters observers. The provider session is
therefore alive after Orca discards its last renderer-side owner.
The same state close drops live agent status but not
`sleepingAgentSessionsByPaneKey`. Worktree activation intentionally fresh-resumes
records that no preserved pane can own. A deliberately closed pane therefore
looks like a failed restore. The existing age check compares `capturedAt` with
`updatedAt`; that detects a status that was stale at capture time but never
expires a record as wall time advances.
## Proposed architecture
### 1. Authoritative tab retirement plan
Before deleting any tab-scoped state, build a `TerminalTabRetirementPlan` from
one store snapshot:
```ts
type TerminalTabRetirementPlan = {
tabId: string
worktreeId: string | null
ptyIds: string[]
localOrSshPtyIds: string[]
runtimeTerminals: Array<{ environmentId: string; handle: string }>
sharedPtyIds: string[]
paneKeys: string[]
}
```
`ptyIds` is the deduplicated union of:
- `ptyIdsByTabId[tabId]`;
- the tab row's legacy `ptyId`;
- every value in `terminalLayoutsByTabId[tabId].ptyIdsByLeafId`;
- `lastKnownRelayPtyIdByTabId[tabId]`;
- `deferredSshSessionIdsByTabId[tabId]`;
- `pendingReconnectPtyIdByTabId[tabId]`.
This snapshot must be built before any of those maps are pruned. PTY IDs are
classified structurally with `parseRemoteRuntimePtyId`, not by assuming a host
or platform from the current active workspace. Before shutdown, the planner
subtracts IDs referenced by another tab row, live index, split layout, or relay
and reconnect map. A partially completed pane move must not let closing the
source tab kill the target tab's session.
The immediate compatibility boundary is `closeTab`, because several production
paths still call the store action directly. Its options carry an explicit
`reason: 'user' | 'pty-exit' | 'cleanup'`; `user` and `cleanup` retire, while
`pty-exit` reconciles an already-dead session without killing siblings or
revoking crash-recovery policy by accident. Making only the rendered tab-bar
action destructive would leave background launch cleanup, floating terminal,
onboarding, runtime notifications, and future direct callers vulnerable.
### 2. Teardown ordering
A local tab retirement executes in this order:
1. Snapshot the retirement plan and its execution-owner classifications.
2. Mark the tab recently closed so late agent-hook events cannot reintroduce
status or resume authority.
3. Dispose the whole tab's parked registry entry and captured candidates, and
silence teardown side-effect handlers while preserving deterministic exit
observation.
4. Submit `pty.kill` for exclusive local or SSH IDs and `terminal.close` for
exclusive ordinary runtime handles. A paired host-session close remains
owned by `session.tabs.close` and is not duplicated locally.
5. Atomically remove the tab, layout/binding maps, agent status, and resume
records from renderer state.
6. Let subsequent React unmount cleanup run idempotently. It is no longer the
process-lifetime authority.
Steps 3 and 4 are issued before the state loses its IDs. The UI state mutation
remains synchronous and does not await provider exit. Completion is observed
with `Promise.allSettled` so rejections cannot become unhandled promises. The
current Phase 1 fallback for a rejected retirement is provider `listSessions`
plus Resource Manager orphan cleanup; durable retry inventory belongs to the
main-owned retirement phase.
Remote-runtime mirrors are different: their `remote:` IDs are excluded from
local `pty.kill`. Ordinary runtime terminals retire through `terminal.close`.
Paired host-session tabs retire through `session.tabs.close`, which owns the
entire host tab graph. The local mirror may be pruned optimistically only after
the host close intent is recorded, as it is today.
### 3. Resume-authority revocation
Tab retirement removes every sleeping record whose:
- map key begins with `${tabId}:`; or
- record has `tabId === tabId`.
The explicit `record.tabId` check covers migrated or legacy keys whose key no
longer encodes the current tab identity. Sibling tabs and other worktrees retain
their records by reference when unchanged.
The existing recently-closed tab registry remains the short-lived race guard
for hook events already in flight. It must prevent those events from creating a
new `origin: 'live'` resume record after retirement. Tests must cover both event
orders: close then late status, and status queued in the same turn as close.
Periodic capture iterates only current live status entries. Once close removes
the live entry and resume record, the periodic pass has nothing to persist.
### 4. Origin-aware defensive record expiry
An unowned defensive `live` or `quit` record may expire when:
```ts
Date.now() - record.capturedAt > DEFENSIVE_AGENT_RESUME_MAX_AGE_MS
```
Intentional `worktree-sleep` records are exempt from the agent-status freshness
window. Originless legacy behavior remains unchanged until its migration policy
is explicitly chosen. The same validator must run before desktop activation and
mobile/background wake so one entry point cannot bypass it.
Expiry is defense in depth, not the primary close mechanism. A correctly closed
record is deleted immediately; it is not retained for 30 minutes.
### 5. Pane close remains pane-scoped
`PaneManager.closePane` already distinguishes `reason: 'close'` from
`reason: 'detach'`. Preserve that distinction with explicit pane authority:
- `close` must clear the exact pane's resume authority, add a pane-scoped late
event tombstone, and destroy the pane transport;
- `detach` must atomically transfer or alias resume ownership because the agent
process retains its immutable source `ORCA_PANE_KEY`.
The last-pane path routes to terminal-tab retirement so it receives the same
parked, split, SSH, and resume cleanup behavior as a tab-bar close.
### 6. Provider-side safety net
Client correctness is necessary but not sufficient for a daemon deliberately
designed to outlive renderer and app processes. Add a follow-up provider-owned
orphan policy:
- track attachments/ownership leases rather than treating `detach` as a no-op;
- distinguish warm-reattach grace from ownerless retirement;
- reap sessions that have no owner after a bounded grace period;
- never idle-reap a session merely because it produces no output;
- scope leases to native, WSL, SSH provider, relay connection, or runtime host;
- expose orphan count and oldest orphan age in Resource Manager diagnostics.
This hardening is not required to land the immediate close fix, because a lease
protocol changes daemon compatibility and needs its own migration. It is the
defense against a future client bookkeeping regression.
## Provider behavior
| Provider/session kind | Close operation | Notes |
| ----------------------- | ----------------------------------- | ---------------------------------------------------------------------------------------------- |
| Local native | `pty.kill(id)` | Main routes to the owning local/daemon provider. |
| Persistent daemon | `pty.kill(id)` | Explicit close overrides warm-reattach persistence. |
| Windows ConPTY | `pty.kill(id)` | Provider shutdown owns Windows process-tree semantics. |
| WSL | `pty.kill(id)` | Route by PTY ownership; do not infer from path separators. |
| SSH | `pty.kill(appScopedId)` | Main resolves the connection-specific SSH provider. |
| Disconnected SSH | `pty.kill(appScopedId)` | Tombstones locally and prevents fallback; relay-side death requires reconnect-aware follow-up. |
| Ordinary remote runtime | host `terminal.close` | Classify environment and handle from the `remote:` ID. |
| Paired web host tab | host `session.tabs.close` | Host owns the complete tab and pane graph. |
| Web client local mirror | optimistic prune after close intent | Host snapshot confirms final removal. |
## Failure and race handling
### Duplicate close
The first close snapshots and retires the tab. Later closes find no target and
no-op. Provider shutdown is idempotent for an already exited session.
### PTY exit races close
Observers are unregistered before shutdown. If an exit event was already
queued, the recently-closed tab guard and missing tab make it a no-op. It must
not recreate a tab, completion row, resume record, or notification.
### Spawn completes after close
The transport already kills a PTY when its spawn resolves after `destroyed` is
set, but the two direct background launchers do not use that transport. Each
direct launcher must revalidate tab ownership immediately after `pty.spawn` or
`terminal.create` resolves and before writing any binding, layout, eager buffer,
subscription, or mount request. A retired local or SSH result is killed; a
retired runtime result is closed through `terminal.close`.
### Provider shutdown fails
The tab remains closed; reappearing would violate user intent. Report the
provider class through structured diagnostics and leave the session visible in
provider inventory so existing Resource Manager orphan cleanup can retry. A
durable pending-retirement inventory is a later main-process API and is not
claimed by Phase 1. Do not restore resume authority after a failed terminal
kill.
### Remote host is unavailable
Keep the local close intent until either a host snapshot confirms removal or the
existing intent TTL expires. If it expires, the host remains authoritative and
may republish the tab; log the failed close. Never pretend a local mirror prune
terminated the host PTY.
### App exits during close
Submit shutdown IPC before discarding the only local PTY identifiers. The main
process/provider owns completion after accepting the request. A future batched
shutdown IPC may return per-ID acceptance, but UI close does not wait for it.
## Implementation plan
### Phase 1: correctness patch (implemented)
1. Add a small store-independent terminal-tab retirement planner that collects
all six ID sources, classifies execution owners, and excludes IDs shared by
another live surface.
2. Add an explicit close reason, update natural-exit callers, and make explicit
`closeTab` retirement dispose the whole parked-tab registry, submit local,
SSH, or runtime shutdown, atomically clear all reconnect maps, and remove
tab-owned resume records.
3. Add post-await owner checks to both direct background launchers so a late
local, SSH, or runtime spawn is terminated before any renderer binding.
4. Defer decision-time expiry until unowned defensive `live` or `quit` records
can be distinguished without changing intentional `worktree-sleep` or legacy
recovery behavior.
5. Add focused planner, store, launcher-race, and resume-policy tests. Keep one
local Electron parked-close proof as an RC release gate.
### Phase 2: remaining API cleanup
1. Rename the state-only reducer primitive so `closeTab` cannot ambiguously mean
either UI cleanup or lifecycle teardown.
2. Route all user and runtime close call sites through one domain command.
3. Keep explicit `detach` and `sleep` APIs; do not encode them as close options.
4. Add a development assertion when tab state is removed with provider PTY IDs
but no retirement plan.
Exact pane retirement, persisted detach authority transfer, and unified-only
terminal routing landed with Phase 1 because exact-head review found they were
required to avoid introducing adjacent close regressions.
### Phase 3: daemon ownership leases
1. Define versioned attach/detach/retire lease messages.
2. Preserve warm reattach across renderer/app restarts within a bounded grace.
3. Reap ownerless sessions and expose pressure telemetry.
4. Add compatibility tests across the supported daemon protocol versions and
native/WSL/SSH host isolation.
## Test plan
### Unit/store tests
- Closing an active single-pane tab submits exactly its PTY for shutdown and
removes all tab state.
- Closing a parked tab with no mounted transport still submits its PTY.
- Closing a split tab submits every unique PTY found across tab row, index, and
layout plus relay/deferred/reconnect maps, including IDs present in only one
source.
- Duplicate IDs are submitted once by the authoritative retirement plan.
- An ID referenced by another live tab is not killed when the source tab closes.
- `remote:` IDs are excluded from local PTY shutdown.
- App-scoped SSH IDs are included and are not mistaken for remote runtime IDs.
- All sleeping records for the tab are removed by key prefix or `record.tabId`;
sibling records retain identity.
- Periodic capture after close does not recreate the record.
- Late agent status after close cannot recreate live status or resume authority.
- Closing twice and close-after-exit are no-ops after the first retirement.
- A spawn resolving after close kills the newly returned PTY.
- A defensive live/quit record at the expiry boundary is retained; one
millisecond past it is cleared and never launched, while an older intentional
worktree-sleep record still resumes.
### Renderer integration tests
- Mounted tab close still destroys its xterm and terminates the PTY.
- Parked watcher disposal occurs before or with PTY shutdown and emits no final
completion/bell notification.
- Closing one split pane preserves the sibling; closing the last pane retires
the tab.
- Pane-to-tab detach preserves the PTY and resume record.
- Pinned-tab cancellation performs no shutdown; confirmed close does.
- Bulk close (`close others`, `close right`, kill-all) uses the same semantics.
### Provider/main tests
- Local, daemon, WSL, ConPTY, and SSH ownership route explicit close to the
correct provider.
- Disconnected SSH close tombstones the app-scoped ID and never falls through to
a local provider.
- Repeated `pty.kill` for the same ID is benign.
- Remote-runtime IDs cannot reach the local kill handler from renderer close.
### Electron end-to-end regression
1. Create a worktree terminal and start a deterministic long-lived child.
2. Switch worktrees and wait until the source worktree is cold-parked.
3. Close the parked tab.
4. Assert its exact PTY disappears from `pty:listSessions` and the child exits.
5. Restart/reload Orca and activate the worktree.
6. Assert no terminal or agent resume command is recreated for the closed pane.
7. Repeat with two split panes and with an SSH fixture where CI supports it.
### Soak and pressure gate
Run repeated create, park, close, reload, and reopen cycles. App-owned PTY count
must return to baseline after each cycle and remain bounded over the run. Record
the peak and final provider session count in CI artifacts.
## Observability
Emit a synchronous scheduled summary and an asynchronous completion summary:
- tab/worktree identifiers in hashed or existing diagnostic form;
- number of PTY IDs discovered by each source;
- provider classification counts;
- submitted count, then fulfilled/rejected counts after `Promise.allSettled`;
- number of resume records removed;
- whether the tab was parked or mounted when retired.
After the daemon-lease phase, Resource Manager should distinguish:
- attached sessions;
- warm-detached sessions eligible for reattach;
- ownerless/orphan sessions;
- pending/failed explicit retirement.
Alert locally before app-owned PTYs approach platform pressure. The warning is a
defense, not a substitute for lifecycle correctness.
## Rollout
1. Land the correctness patch without a feature flag. User close semantics are
restorative behavior, not an experiment.
2. Keep hidden-view parking enabled; disabling it masks the ownership bug and
forfeits its memory benefit.
3. Run focused unit/integration tests plus the live parked-close Electron gate.
4. Cut an RC and soak create/close/restart cycles while monitoring provider and
OS PTY counts.
5. Ship daemon leases separately behind protocol compatibility and telemetry.
## Alternatives rejected
### Disable hidden-view parking
This restores the old accidental unmount behavior at significant memory cost
and leaves every other state-only close path fragile. It does not repair resume
records or daemon ownership.
### Kill only from `TerminalPane` cleanup
There is intentionally no pane during parking and some headless/runtime flows.
View cleanup cannot be process authority.
### Clear only resume records
This prevents resurrection but still leaks the live PTY until app/daemon exit.
### Kill only the tab row's `ptyId`
Split panes and partially reconciled layouts can own multiple IDs. The full
union is required.
### Rely only on a daemon idle timeout
Interactive agents can be legitimately idle for hours. Reaping by output
idleness loses user work; ownership, not activity, is the safe signal.
### Await every provider exit before hiding the tab
Remote and disconnected providers can take seconds or fail. UI retirement must
be immediate once the user confirms; provider completion is asynchronous and
observable.
## Phase 1 acceptance criteria
- A confirmed terminal-tab close submits retirement for every exclusively owned
session Orca can currently reach and reports rejected requests without an
unhandled promise.
- A closed agent session is never resumed by restart, activation, or mobile wake.
- Parking, move/detach, sleep/wake, and warm app reattach retain their documented
behavior.
- Split, local, SSH, and remote-runtime ownership are covered by deterministic
tests; Windows/WSL process-tree behavior remains a live-platform follow-up.
- PTY counts remain bounded across the soak scenario.
- No process-lifetime decision depends on React component mount state.
## Full-contract follow-ups
- Durable main-owned retirement intent and bounded retry inventory.
- Disconnected SSH relay kill-on-reconnect.
- Serializable daemon attachment leases with protocol-version fallback.
- Positive ownership validation for late hook events beyond the bounded recent-
close registry.
-1
View File
@@ -182,7 +182,6 @@ export default defineConfig({
'computer-sidecar': resolve('src/main/computer/sidecar-entry.ts'),
'stt-worker': resolve('src/main/speech/stt-worker.ts'),
'warp-theme-parser-worker': resolve('src/main/warp-themes/warp-theme-parser-worker.ts'),
'file-watcher-worker': resolve('src/main/runtime/file-watcher-worker.ts'),
// Why: forked with ELECTRON_RUN_AS_NODE so @parcel/watcher faults
// can't take down the main process (issue #7547).
'parcel-watcher-process-entry': resolve('src/main/ipc/parcel-watcher-process-entry.ts'),
+13
View File
@@ -23,6 +23,7 @@ import {
getActiveProviderRateLimits,
getInactiveProviderUsage,
getUsageBarState,
getWindowResetLabel,
hasActiveProviderUsage,
UsageBar
} from '../../../src/components/AccountUsage'
@@ -40,6 +41,14 @@ export default function AccountsScreen() {
const [refreshing, setRefreshing] = useState(false)
const [busyAccountId, setBusyAccountId] = useState<string | null>(null)
// Why: the reset countdown must stay fresh while the screen sits open —
// snapshot pushes only arrive when the desktop's rate-limit poll completes.
const [now, setNow] = useState(() => Date.now())
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 60_000)
return () => clearInterval(id)
}, [])
useEffect(() => {
if (!hostId) {
return
@@ -163,12 +172,14 @@ export default function AccountsScreen() {
usedPercent={activeSessionBar.usedPercent}
unavailable={activeSessionBar.unavailable}
loading={activeSessionBar.loading}
resetText={getWindowResetLabel(activeUsage, 'session', now)}
/>
<UsageBar
label="7d"
usedPercent={activeWeeklyBar.usedPercent}
unavailable={activeWeeklyBar.unavailable}
loading={activeWeeklyBar.loading}
resetText={getWindowResetLabel(activeUsage, 'weekly', now)}
/>
</View>
) : null}
@@ -211,12 +222,14 @@ export default function AccountsScreen() {
usedPercent={sessionBar.usedPercent}
unavailable={sessionBar.unavailable}
loading={sessionBar.loading}
resetText={getWindowResetLabel(usage, 'session', now)}
/>
<UsageBar
label="7d"
usedPercent={weeklyBar.usedPercent}
unavailable={weeklyBar.unavailable}
loading={weeklyBar.loading}
resetText={getWindowResetLabel(usage, 'weekly', now)}
/>
</View>
{usage?.error ? (
+53 -10
View File
@@ -1,5 +1,14 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { View, Text, StyleSheet, SectionList, Pressable, ActivityIndicator } from 'react-native'
import {
View,
Text,
StyleSheet,
SectionList,
Pressable,
ActivityIndicator,
Alert,
RefreshControl
} from 'react-native'
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
import { useFocusEffect, useLocalSearchParams, usePathname, useRouter } from 'expo-router'
import {
@@ -20,7 +29,8 @@ import {
PanelLeftClose
} from 'lucide-react-native'
import type { RpcClient } from '../../../src/transport/rpc-client'
import { loadHosts, updateLastConnected, removeHost } from '../../../src/transport/host-store'
import { loadHosts, updateLastConnected } from '../../../src/transport/host-store'
import { removeHostAndCloseClient } from '../../../src/transport/host-removal-lifecycle'
import {
useHostClient,
useCloseHost,
@@ -28,6 +38,7 @@ import {
useReconnectAttempt,
useLastConnectedAt
} from '../../../src/transport/client-context'
import { useWorktreeResync } from '../../../src/transport/use-worktree-resync'
import {
classifyConnection,
type ConnectionVerdict
@@ -50,7 +61,7 @@ import { ProtocolBlockScreen } from '../../../src/components/ProtocolBlockScreen
import { AuthFailedBanner } from '../../../src/components/AuthFailedBanner'
import { MobileSearchField } from '../../../src/components/MobileSearchField'
import { WorkspaceDetailPlaceholder } from '../../../src/components/WorkspaceDetailPlaceholder'
import { getCachedWorktrees } from '../../../src/cache/worktree-cache'
import { getCachedWorktrees, setCachedWorktrees } from '../../../src/cache/worktree-cache'
import { setCachedRepos } from '../../../src/cache/repo-cache'
import { colors, radii, spacing, typography } from '../../../src/theme/mobile-theme'
import { useResponsiveLayout } from '../../../src/layout/responsive-layout'
@@ -450,6 +461,14 @@ export function HostScreen({
areWorktreeListsEqual(current, result.worktrees) ? current : result.worktrees
)
setWorktreesLoaded(true)
// Why (#8498): the host detail screen seeds its list from the
// home-written cache, so a partial home fetch could poison it until a
// focus poll corrected it. Write the confirmed snapshot back through
// the same cache so a reconnect refetch (or a remount) can't serve a
// stale worktree list.
if (hostId) {
setCachedWorktrees(hostId, result.worktrees)
}
// Drop the optimistic active override once the host confirms it (the
// activate RPC has landed and worktree.ps now reports it active), so we
// stop overriding and respect any later desktop-driven change.
@@ -604,6 +623,17 @@ export function HostScreen({
return () => clearInterval(interval)
}, [embedded, connState, fetchWorktrees, fetchRepoMetadata, syncViewSettingsFromDesktop])
// Why (#8498): reconnect refetch + manual pull-to-refresh, extracted to
// useWorktreeResync so this screen stays under its max-lines budget. The
// steady-state focus/embedded polls don't cover the transition INTO
// 'connected' after a background/sleep, which is when the cache is stalest.
const { refreshing, onRefresh } = useWorktreeResync({
client,
connState,
fetchWorktrees,
fetchRepoMetadata
})
const updateLocalPins = useCallback(
(worktreeId: string, pinned: boolean) => {
setPinnedIds((prev) => {
@@ -684,13 +714,15 @@ export function HostScreen({
if (!hostId) {
return
}
// Why: close the shared client first so its WebSocket is gone before
// the host record disappears; otherwise the next loadHosts() the
// provider does (e.g. on remount) wouldn't find this host but the
// socket would still be open, leaking state.
closeHostClient(hostId)
await removeHost(hostId)
leaveHost()
try {
await removeHostAndCloseClient(hostId, closeHostClient)
leaveHost()
} catch {
// Why: metadata commit can fail while the host is still paired; keep the
// screen mounted and re-open confirm (ConfirmModal closes on confirm).
setConfirmRemoveHost(true)
Alert.alert('Could not remove host', 'Please try again.')
}
}, [hostId, leaveHost, closeHostClient])
const navigateFromHostList = useCallback(
@@ -1218,6 +1250,16 @@ export function HostScreen({
)
}}
ItemSeparatorComponent={ListSeparator}
// Why (#8498): manual pull-to-refresh forces a fresh worktree
// snapshot after a reconnect or whenever the cache looks stale.
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColor={colors.textSecondary}
colors={[colors.textSecondary]}
/>
}
renderItem={({ item }) => (
<WorktreeListRow
item={item}
@@ -1417,6 +1459,7 @@ export function HostScreen({
client={client}
hostId={hostId}
existingWorktreePaths={existingWorktreePaths}
existingWorktrees={worktrees}
onVisibleChange={(visible) => {
newWorktreeModalVisibleRef.current = visible
}}
+56 -5
View File
@@ -1080,6 +1080,13 @@ export default function SessionScreen() {
// the row) without any window-dim change. Tracking the measured width lets the
// refit hook re-fit the PTY on those resizes — see terminal-viewport-refit.ts.
const [terminalFrameWidth, setTerminalFrameWidth] = useState(0)
// Why: a new agent terminal can fit before the accessory/live-input dock lays
// out, over-fitting the PTY so its bottom-pinned input box hides behind the
// dock; tracking the settled height lets the refit hook correct it.
const [terminalFrameHeight, setTerminalFrameHeight] = useState(0)
// Why: lets the height refit skip keyboard-driven resizes (never reflow the
// PTY per keystroke); mirrors keyboardHeight but readable synchronously.
const keyboardVisibleRef = useRef(false)
const activeSessionTab = sessionTabs.find((tab) => tab.id === activeSessionTabId) ?? null
const {
@@ -2528,6 +2535,7 @@ export default function SessionScreen() {
}
}, [])
const pendingForegroundRecoveryRef = useRef(false)
useEffect(() => {
let previousAppState: AppStateStatus | null = AppState.currentState
const sub = AppState.addEventListener('change', (nextAppState: AppStateStatus) => {
@@ -2546,7 +2554,7 @@ export default function SessionScreen() {
// Why: iOS can resume a live WKWebView with a blank xterm backing store
// without firing web-ready/reconnect; invalidate the native readiness
// latch before replay so init waits for the document's pong.
recoverActiveTerminalAfterForeground({
const outcome = recoverActiveTerminalAfterForeground({
activeHandleRef,
terminalRefs,
initializedHandlesRef,
@@ -2555,12 +2563,36 @@ export default function SessionScreen() {
subscribeToTerminal,
schedule: scheduleDelayedAction
})
pendingForegroundRecoveryRef.current = outcome === 'deferred'
})
return () => {
sub.remove()
}
}, [scheduleDelayedAction, subscribeToTerminal, unsubscribeTerminal])
// Why: resume usually lands mid-reconnect (the socket dies after ~60-80s of
// background), so the recovery above defers. Re-run it once the connection
// is back; otherwise a blanked WKWebView whose socket was merely probed (no
// stream replay) stays stale until a manual tab switch.
useEffect(() => {
if (connState !== 'connected' || !pendingForegroundRecoveryRef.current) {
return
}
pendingForegroundRecoveryRef.current = false
if (AppState.currentState !== 'active') {
return
}
recoverActiveTerminalAfterForeground({
activeHandleRef,
terminalRefs,
initializedHandlesRef,
connStateRef,
unsubscribeTerminal,
subscribeToTerminal,
schedule: scheduleDelayedAction
})
}, [connState, scheduleDelayedAction, subscribeToTerminal, unsubscribeTerminal])
// Why: viewport refits for layout changes outside the subscribe path
// (tab strip toggling, fold/unfold, rotation) live in a dedicated hook —
// see terminal-viewport-refit.ts for the full rationale.
@@ -2577,15 +2609,19 @@ export default function SessionScreen() {
tabStripVisible: terminals.length > 1,
textScale: terminalTextScale,
terminalFrameWidth,
terminalFrameHeight,
keyboardVisibleRef,
unsubscribeTerminal,
subscribeToTerminal
})
useEffect(() => {
const onShow = (e: KeyboardEvent) => {
keyboardVisibleRef.current = true
setKeyboardHeight(e.endCoordinates?.height ?? 0)
}
const onHide = () => {
keyboardVisibleRef.current = false
setKeyboardHeight(0)
}
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'
@@ -3046,10 +3082,15 @@ export default function SessionScreen() {
// this, subscribe(viewport=null) lands on the server first and the
// post-scrollback measure path's resubscribe sees alreadyMeasured=true
// (because measureViewportOnce won the race) and silently skips.
if (handle === activeHandleRef.current && !terminalUnsubsRef.current.has(handle)) {
// Why: a just-created tab can briefly lose activeHandleRef to a lagging
// session-tab snapshot; honor the pending marker so its one web-ready
// subscribe still fires (see handleCreateTerminal).
const isIntendedActive = () =>
handle === activeHandleRef.current || handle === pendingActiveTerminalHandleRef.current
if (isIntendedActive() && !terminalUnsubsRef.current.has(handle)) {
void (async () => {
await measureViewportOnce(handle)
if (handle === activeHandleRef.current && !terminalUnsubsRef.current.has(handle)) {
if (isIntendedActive() && !terminalUnsubsRef.current.has(handle)) {
subscribeToTerminal(handle)
}
})()
@@ -3937,6 +3978,11 @@ export default function SessionScreen() {
if (typeof created.terminal === 'string') {
const createdHandle = created.terminal
defaultTerminalHandlesToLiveInput([createdHandle])
// Why: session-tab snapshots can lag the create RPC. Without the
// handle marker, applySessionTabs snaps activeHandleRef back to the
// previous terminal, and the new pane's web-ready subscribe (gated
// on the active handle) is skipped — blank tab until a manual switch.
pendingActiveTerminalHandleRef.current = createdHandle
activeHandleRef.current = createdHandle
setActiveHandle(createdHandle)
setTerminals((prev) => {
@@ -3991,6 +4037,9 @@ export default function SessionScreen() {
})
}
} else {
// Why: a prior pending handle must not outlive a create that returned
// no terminal; web-ready subscribe gates on this ref as active.
pendingActiveTerminalHandleRef.current = null
activeHandleRef.current = null
setActiveHandle(null)
}
@@ -4793,10 +4842,12 @@ export default function SessionScreen() {
style={styles.terminalFrame}
onLayout={(e) => {
terminalFrameHeightRef.current = e.nativeEvent.layout.height
// Trigger a refit only when the width actually changes (sidebar
// resize, fold, rotation) — avoids churn on height-only changes.
// Track width AND height so the refit hook re-fits on sidebar/
// fold/rotation (width) and on the dock settling (height).
const nextWidth = Math.round(e.nativeEvent.layout.width)
const nextHeight = Math.round(e.nativeEvent.layout.height)
setTerminalFrameWidth((prev) => (prev === nextWidth ? prev : nextWidth))
setTerminalFrameHeight((prev) => (prev === nextHeight ? prev : nextHeight))
}}
>
{terminals.map((terminal) => (
+1 -1
View File
@@ -61,6 +61,7 @@ import {
} from '../../../src/session/mobile-file-syntax'
import { buildGitHubCheckSummary } from '../../../src/tasks/github-check-summary'
import { buildTaskWorkspaceCreateParams } from '../../../src/tasks/workspace-create-params'
import { MOBILE_TASKS_CAPABILITY } from '../../../src/tasks/mobile-tasks-capability'
import {
filterWorkspaceAgents,
isWorkspaceAgentEnabled,
@@ -858,7 +859,6 @@ const GITHUB_REPO_CONCURRENCY = 3
const MAX_RENDERED_PR_DIFF_LINES = 400
const GITLAB_PER_PAGE = 50
const LINEAR_LIMIT = 50
const MOBILE_TASKS_CAPABILITY = 'mobile.tasks.v1'
// Why: task detail drawers can launch child sheets; children must layer above
// the still-mounted parent while its dismissal animation/state remains alive.
const TASK_SECONDARY_DRAWER_Z_INDEX = 1100
+9 -7
View File
@@ -1,5 +1,5 @@
import { useState, useCallback, useEffect, useMemo, useRef } from 'react'
import { View, Text, StyleSheet, Pressable, FlatList } from 'react-native'
import { View, Text, StyleSheet, Pressable, FlatList, Alert } from 'react-native'
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
import { useRouter, useFocusEffect } from 'expo-router'
import {
@@ -25,7 +25,8 @@ import {
UsageBar
} from '../src/components/AccountUsage'
import AsyncStorage from '@react-native-async-storage/async-storage'
import { loadHosts, removeHost, renameHost } from '../src/transport/host-store'
import { loadHosts, renameHost } from '../src/transport/host-store'
import { removeHostAndCloseClient } from '../src/transport/host-removal-lifecycle'
import { pickResumeWorktree } from '../src/worktree/resume-worktree'
import type { RpcClient } from '../src/transport/rpc-client'
import {
@@ -710,15 +711,16 @@ export default function HomeScreen() {
if (!confirmRemove) {
return
}
const hostToRemove = confirmRemove
try {
// Why: close the shared client first so the WebSocket is gone
// before the host record disappears from loadHosts().
closeHostClient(confirmRemove.id)
await removeHost(confirmRemove.id)
await removeHostAndCloseClient(hostToRemove.id, closeHostClient)
setConfirmRemove(null)
setHosts(await loadHosts())
} catch {
setConfirmRemove(null)
// Why: ConfirmModal closes on confirm; re-open for retry and surface the
// failure instead of silently leaving the host listed.
setConfirmRemove(hostToRemove)
Alert.alert('Could not remove host', 'Please try again.')
}
}
+227 -77
View File
@@ -1,6 +1,15 @@
import { View, Text, StyleSheet, Pressable, Linking } from 'react-native'
import { useCallback, useRef, useState } from 'react'
import {
View,
Text,
StyleSheet,
Pressable,
Linking,
ActivityIndicator,
ScrollView
} from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useRouter } from 'expo-router'
import { useFocusEffect, useRouter } from 'expo-router'
import {
ChevronLeft,
ChevronRight,
@@ -11,13 +20,76 @@ import {
LifeBuoy,
Mic,
Globe,
Terminal as TerminalIcon
Terminal as TerminalIcon,
KeyRound
} from 'lucide-react-native'
import { colors, spacing, typography } from '../src/theme/mobile-theme'
import { colors, radii, spacing, typography } from '../src/theme/mobile-theme'
import {
loadPendingHostCredentialCleanup,
subscribePendingHostCredentialCleanup
} from '../src/transport/host-credential-cleanup'
import { retryPendingHostCredentialCleanup } from '../src/transport/host-store'
export default function SettingsScreen() {
const router = useRouter()
const insets = useSafeAreaInsets()
const [pendingCredentialIds, setPendingCredentialIds] = useState<string[]>([])
const [credentialStorageUnreadable, setCredentialStorageUnreadable] = useState(false)
const [retryingCredentialCleanup, setRetryingCredentialCleanup] = useState(false)
const [credentialRetryFailed, setCredentialRetryFailed] = useState(false)
const credentialRefreshGenerationRef = useRef(0)
useFocusEffect(
useCallback(() => {
let active = true
setCredentialRetryFailed(false)
const refresh = () => {
const generation = ++credentialRefreshGenerationRef.current
void loadPendingHostCredentialCleanup().then((state) => {
if (active && generation === credentialRefreshGenerationRef.current) {
setPendingCredentialIds(state.ids)
setCredentialStorageUnreadable(state.storageUnreadable)
// Why: neutral copy once the queue is confirmed empty so a later
// pending set does not inherit a previous Retry failure message.
if (state.ids.length === 0 && !state.storageUnreadable) {
setCredentialRetryFailed(false)
}
}
})
}
const unsubscribe = subscribePendingHostCredentialCleanup(refresh)
refresh()
return () => {
active = false
credentialRefreshGenerationRef.current += 1
unsubscribe()
}
}, [])
)
const retryCredentialCleanup = useCallback(async () => {
if (retryingCredentialCleanup) {
return
}
setCredentialRetryFailed(false)
setRetryingCredentialCleanup(true)
try {
const result = await retryPendingHostCredentialCleanup()
setPendingCredentialIds(result.remainingIds)
setCredentialStorageUnreadable(result.storageUnreadable)
setCredentialRetryFailed(result.remainingIds.length > 0 || result.storageUnreadable)
} catch {
setCredentialRetryFailed(true)
} finally {
setRetryingCredentialCleanup(false)
}
}, [retryingCredentialCleanup])
const pendingCredentialCount = pendingCredentialIds.length
// Why: show the cleanup card whenever cleanup is pending OR the durable queue
// is unreadable — an unreadable queue can hide an orphaned token, so keep a
// retry affordance rather than a silently-empty (hidden) section.
const showCredentialCleanup = pendingCredentialCount > 0 || credentialStorageUnreadable
return (
<View style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
@@ -28,79 +100,123 @@ export default function SettingsScreen() {
<Text style={styles.heading}>Settings</Text>
</View>
<View style={styles.section}>
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => router.push('/terminal-settings')}
>
<TerminalIcon size={16} color={colors.textSecondary} />
<Text style={styles.rowLabel}>Terminal</Text>
<ChevronRight size={16} color={colors.textMuted} />
</Pressable>
<View style={styles.separator} />
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => router.push('/browser-settings')}
>
<Globe size={16} color={colors.textSecondary} />
<Text style={styles.rowLabel}>Browser</Text>
<ChevronRight size={16} color={colors.textMuted} />
</Pressable>
<View style={styles.separator} />
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => router.push('/voice-settings')}
>
<Mic size={16} color={colors.textSecondary} />
<Text style={styles.rowLabel}>Voice</Text>
<ChevronRight size={16} color={colors.textMuted} />
</Pressable>
<View style={styles.separator} />
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => router.push('/notifications')}
>
<Bell size={16} color={colors.textSecondary} />
<Text style={styles.rowLabel}>Notifications</Text>
<ChevronRight size={16} color={colors.textMuted} />
</Pressable>
<View style={styles.separator} />
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => router.push('/troubleshoot')}
>
<Wrench size={16} color={colors.textSecondary} />
<Text style={styles.rowLabel}>Troubleshooting</Text>
<ChevronRight size={16} color={colors.textMuted} />
</Pressable>
<View style={styles.separator} />
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => router.push('/about')}
>
<Info size={16} color={colors.textSecondary} />
<Text style={styles.rowLabel}>About</Text>
<ChevronRight size={16} color={colors.textMuted} />
</Pressable>
</View>
<ScrollView
contentContainerStyle={{ paddingBottom: insets.bottom + spacing.lg }}
showsVerticalScrollIndicator={false}
>
<View style={styles.section}>
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => router.push('/terminal-settings')}
>
<TerminalIcon size={16} color={colors.textSecondary} />
<Text style={styles.rowLabel}>Terminal</Text>
<ChevronRight size={16} color={colors.textMuted} />
</Pressable>
<View style={styles.separator} />
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => router.push('/browser-settings')}
>
<Globe size={16} color={colors.textSecondary} />
<Text style={styles.rowLabel}>Browser</Text>
<ChevronRight size={16} color={colors.textMuted} />
</Pressable>
<View style={styles.separator} />
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => router.push('/voice-settings')}
>
<Mic size={16} color={colors.textSecondary} />
<Text style={styles.rowLabel}>Voice</Text>
<ChevronRight size={16} color={colors.textMuted} />
</Pressable>
<View style={styles.separator} />
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => router.push('/notifications')}
>
<Bell size={16} color={colors.textSecondary} />
<Text style={styles.rowLabel}>Notifications</Text>
<ChevronRight size={16} color={colors.textMuted} />
</Pressable>
<View style={styles.separator} />
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => router.push('/troubleshoot')}
>
<Wrench size={16} color={colors.textSecondary} />
<Text style={styles.rowLabel}>Troubleshooting</Text>
<ChevronRight size={16} color={colors.textMuted} />
</Pressable>
<View style={styles.separator} />
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => router.push('/about')}
>
<Info size={16} color={colors.textSecondary} />
<Text style={styles.rowLabel}>About</Text>
<ChevronRight size={16} color={colors.textMuted} />
</Pressable>
</View>
<View style={[styles.section, styles.sectionSpacer]}>
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => void Linking.openURL('https://www.onorca.dev/privacy')}
>
<Shield size={16} color={colors.textSecondary} />
<Text style={styles.rowLabel}>Privacy Policy</Text>
</Pressable>
<View style={styles.separator} />
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => void Linking.openURL('https://github.com/stablyai/orca/issues')}
>
<LifeBuoy size={16} color={colors.textSecondary} />
<Text style={styles.rowLabel}>Support</Text>
</Pressable>
</View>
{showCredentialCleanup ? (
<View style={[styles.section, styles.sectionSpacer]}>
<View style={styles.credentialCleanupRow}>
<KeyRound size={16} color={colors.statusAmber} />
<View style={styles.credentialCleanupCopy}>
<Text style={styles.credentialCleanupTitle}>Pairing credential cleanup</Text>
<Text accessibilityLiveRegion="polite" style={styles.rowHint}>
{credentialRetryFailed
? "Cleanup still couldn't be confirmed. Try again later."
: pendingCredentialCount > 0
? `Couldn't confirm cleanup for ${pendingCredentialCount} credential${pendingCredentialCount === 1 ? '' : 's'} on this device.`
: "Couldn't check cleanup status on this device. Retry to be safe."}
</Text>
</View>
<Pressable
accessibilityRole="button"
accessibilityLabel="Retry clearing pairing credentials"
accessibilityState={{
busy: retryingCredentialCleanup,
disabled: retryingCredentialCleanup
}}
disabled={retryingCredentialCleanup}
hitSlop={8}
style={({ pressed }) => [
styles.retryButton,
pressed && !retryingCredentialCleanup && styles.rowPressed
]}
onPress={() => void retryCredentialCleanup()}
>
{retryingCredentialCleanup ? (
<ActivityIndicator size="small" color={colors.textSecondary} />
) : (
<Text style={styles.retryButtonText}>Retry</Text>
)}
</Pressable>
</View>
</View>
) : null}
<View style={[styles.section, styles.sectionSpacer]}>
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => void Linking.openURL('https://www.onorca.dev/privacy')}
>
<Shield size={16} color={colors.textSecondary} />
<Text style={styles.rowLabel}>Privacy Policy</Text>
</Pressable>
<View style={styles.separator} />
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => void Linking.openURL('https://github.com/stablyai/orca/issues')}
>
<LifeBuoy size={16} color={colors.textSecondary} />
<Text style={styles.rowLabel}>Support</Text>
</Pressable>
</View>
</ScrollView>
</View>
)
}
@@ -109,7 +225,7 @@ const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.bgBase,
padding: spacing.lg
paddingHorizontal: spacing.lg
},
topRow: {
flexDirection: 'row',
@@ -153,6 +269,40 @@ const styles = StyleSheet.create({
fontWeight: '500',
color: colors.textPrimary
},
credentialCleanupRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm + 2,
paddingVertical: spacing.md,
paddingHorizontal: spacing.md + 2
},
credentialCleanupCopy: {
flex: 1,
gap: spacing.xs
},
credentialCleanupTitle: {
fontSize: typography.bodySize,
fontWeight: '500',
color: colors.textPrimary
},
rowHint: {
fontSize: typography.metaSize,
color: colors.textSecondary,
lineHeight: 17
},
retryButton: {
width: 72,
height: 32,
borderRadius: radii.button,
backgroundColor: colors.bgRaised,
alignItems: 'center',
justifyContent: 'center'
},
retryButtonText: {
fontSize: typography.metaSize,
fontWeight: '600',
color: colors.textPrimary
},
separator: {
height: StyleSheet.hairlineWidth,
backgroundColor: colors.borderSubtle,
+97 -39
View File
@@ -1935,48 +1935,56 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-arm64-musl@0.52.0':
resolution: {integrity: sha512-wZg6bLjDvh2KibyI3QFUYo8GTXneIFsd0JvehtvJiUmQ8WRPERgxd/VM4ctWb86U5FT1FkqgS8/wZKVB+AZScg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxfmt/binding-linux-ppc64-gnu@0.52.0':
resolution: {integrity: sha512-IngE8uxhNvxcMrLjZNDo9xNLY7rEK33AKnaMd2B46he1e/mz2CfcW6If/U1wUjdRZddm1QzQaciqZkuMkdh1FA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-riscv64-gnu@0.52.0':
resolution: {integrity: sha512-H3+DdFMv/efN3Efmhsv18jDrpiWWqKG7wsfAlQBqAt6z/E2Bx+TwEj2Nowe51CPOWB8/mFBC2dAMSgVFLvvowA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-riscv64-musl@0.52.0':
resolution: {integrity: sha512-zji+1kb7lJKohSDjzC1IsS+K/cKRs1hdVf0ZH0VbdbiakmtLvN9twBoXo/k8VdjFax7kfo+DyPxS7vv52br1aw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxfmt/binding-linux-s390x-gnu@0.52.0':
resolution: {integrity: sha512-hcLBYedpCy7ToUvvBidWk7+11Yhg1oAZ4+6hKPic/mQI6NaqXJSXMps5nFlwUuX2ewhtLZZDPg63TI042qGKBg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-x64-gnu@0.52.0':
resolution: {integrity: sha512-IDO2loXK2OtTOhSPchU9MW25mWL2QCDGdJbjN8MXKZVS80qXe5gMTwQWu/gMJ3juoBHbkuUZNB2N1LHzNT7DoA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-x64-musl@0.52.0':
resolution: {integrity: sha512-mAV2Hjn0SatJ+KoAzKUC3eJhdJ8wv+3m1KyuS0dTsbF0c5weq+QrCt/DRZZM+uj/XiKzCDEUKYsBF30e2qkcyw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxfmt/binding-openharmony-arm64@0.52.0':
resolution: {integrity: sha512-vd4npaUIwChxp7XzkqmepBWTT9YMcSe/NBApVGPC30/lLyOVaV3dvma1SKo03t8O73BPRAG7EyJzGlN5cJM5hQ==}
@@ -2049,48 +2057,56 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-arm64-musl@1.71.0':
resolution: {integrity: sha512-fJZrs5sDZtTaPIOiemRQQmo82Ezy+vOGXemPc4Ok7iVVsYsFa7SlW6Z5XN819VfsqBHRm3NJ3rTdnR8+bJYJdQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxlint/binding-linux-ppc64-gnu@1.71.0':
resolution: {integrity: sha512-cwl7VKGERIy9p+G+AvZdfy/06q0aHXaTt/mMRReC751iuNYJgqKjB7NydXSS30nBT9vtr2tunciOtrR4fD6FUA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-riscv64-gnu@1.71.0':
resolution: {integrity: sha512-eZ8ieVXvzGi8jr7+ybQGPK2STw3mldfxZlgA2738iflfB/rzA69sE6m5rDRpQaxC7dpm745Enlh1Tod0QAk9Gg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-riscv64-musl@1.71.0':
resolution: {integrity: sha512-puMDbQYe6+NXwfMusojoA7CXGn2b3utukmd23PQqc1E3XhVCwyZ+FueSMzDYeNgDV2dUfIVXAAKZBcFDeCL6sA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxlint/binding-linux-s390x-gnu@1.71.0':
resolution: {integrity: sha512-4NJLxBs1ujISCt3L/1FcywLs73PWtJuw+piD6feK2V6h6OS6P7xu9/sWt1DTRLibe6QCzmfZzmM/2HPORoV/Lg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-x64-gnu@1.71.0':
resolution: {integrity: sha512-cFDaiR8L3430qp88tfZnvFlt3KotFhR/DlbIL0nHOMMYiG/9Wy4l+6f7t8G8pTa9bd8Lt8+M0y/qjRQ/xcB74g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-x64-musl@1.71.0':
resolution: {integrity: sha512-orfixdt76KlpNly9z0PkWBBNfwjKz+JFVLP/7wnVchlKNU9Dpt9InU/ZggeSej6fC7qwHmHNOGlhLnQXcYoGuA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxlint/binding-openharmony-arm64@1.71.0':
resolution: {integrity: sha512-9emQu2lAp6yhPB3XuI+++vR+l/o6JR1X+EpxwcumPdQXBWXEPAsquPGL7l158EqU8SebQMXTUa/S5zN98juyHw==}
@@ -2552,36 +2568,42 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-arm64-musl@1.1.3':
resolution: {integrity: sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rolldown/binding-linux-ppc64-gnu@1.1.3':
resolution: {integrity: sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-s390x-gnu@1.1.3':
resolution: {integrity: sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-gnu@1.1.3':
resolution: {integrity: sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-musl@1.1.3':
resolution: {integrity: sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@rolldown/binding-openharmony-arm64@1.1.3':
resolution: {integrity: sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==}
@@ -4881,24 +4903,28 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.32.0:
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.32.0:
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.32.0:
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.32.0:
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
@@ -7025,22 +7051,22 @@ snapshots:
'@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.29.7)':
dependencies:
'@babel/core': 7.29.7
'@babel/helper-plugin-utils': 7.28.6
'@babel/helper-plugin-utils': 7.29.7
'@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.29.7)':
dependencies:
'@babel/core': 7.29.7
'@babel/helper-plugin-utils': 7.28.6
'@babel/helper-plugin-utils': 7.29.7
'@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.29.7)':
dependencies:
'@babel/core': 7.29.7
'@babel/helper-plugin-utils': 7.28.6
'@babel/helper-plugin-utils': 7.29.7
'@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.29.7)':
dependencies:
'@babel/core': 7.29.7
'@babel/helper-plugin-utils': 7.28.6
'@babel/helper-plugin-utils': 7.29.7
'@babel/plugin-syntax-decorators@7.28.6(@babel/core@7.29.7)':
dependencies:
@@ -7050,7 +7076,7 @@ snapshots:
'@babel/plugin-syntax-dynamic-import@7.8.3(@babel/core@7.29.7)':
dependencies:
'@babel/core': 7.29.7
'@babel/helper-plugin-utils': 7.28.6
'@babel/helper-plugin-utils': 7.29.7
'@babel/plugin-syntax-export-default-from@7.28.6(@babel/core@7.29.7)':
dependencies:
@@ -7085,12 +7111,12 @@ snapshots:
'@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.29.7)':
dependencies:
'@babel/core': 7.29.7
'@babel/helper-plugin-utils': 7.28.6
'@babel/helper-plugin-utils': 7.29.7
'@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.29.7)':
dependencies:
'@babel/core': 7.29.7
'@babel/helper-plugin-utils': 7.28.6
'@babel/helper-plugin-utils': 7.29.7
'@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.7)':
dependencies:
@@ -7105,42 +7131,42 @@ snapshots:
'@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.29.7)':
dependencies:
'@babel/core': 7.29.7
'@babel/helper-plugin-utils': 7.28.6
'@babel/helper-plugin-utils': 7.29.7
'@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.29.7)':
dependencies:
'@babel/core': 7.29.7
'@babel/helper-plugin-utils': 7.28.6
'@babel/helper-plugin-utils': 7.29.7
'@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.29.7)':
dependencies:
'@babel/core': 7.29.7
'@babel/helper-plugin-utils': 7.28.6
'@babel/helper-plugin-utils': 7.29.7
'@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.29.7)':
dependencies:
'@babel/core': 7.29.7
'@babel/helper-plugin-utils': 7.28.6
'@babel/helper-plugin-utils': 7.29.7
'@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.29.7)':
dependencies:
'@babel/core': 7.29.7
'@babel/helper-plugin-utils': 7.28.6
'@babel/helper-plugin-utils': 7.29.7
'@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.29.7)':
dependencies:
'@babel/core': 7.29.7
'@babel/helper-plugin-utils': 7.28.6
'@babel/helper-plugin-utils': 7.29.7
'@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.29.7)':
dependencies:
'@babel/core': 7.29.7
'@babel/helper-plugin-utils': 7.28.6
'@babel/helper-plugin-utils': 7.29.7
'@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.29.7)':
dependencies:
'@babel/core': 7.29.7
'@babel/helper-plugin-utils': 7.28.6
'@babel/helper-plugin-utils': 7.29.7
'@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.7)':
dependencies:
@@ -8604,7 +8630,7 @@ snapshots:
'@jest/console@29.7.0':
dependencies:
'@jest/types': 29.6.3
'@types/node': 25.6.0
'@types/node': 26.1.1
chalk: 4.1.2
jest-message-util: 29.7.0
jest-util: 29.7.0
@@ -8771,7 +8797,7 @@ snapshots:
'@jest/schemas': 29.6.3
'@types/istanbul-lib-coverage': 2.0.6
'@types/istanbul-reports': 3.0.4
'@types/node': 25.6.0
'@types/node': 26.1.1
'@types/yargs': 17.0.35
chalk: 4.1.2
@@ -9529,24 +9555,24 @@ snapshots:
'@types/babel__core@7.20.5':
dependencies:
'@babel/parser': 7.29.3
'@babel/types': 7.29.0
'@babel/parser': 7.29.7
'@babel/types': 7.29.7
'@types/babel__generator': 7.27.0
'@types/babel__template': 7.4.4
'@types/babel__traverse': 7.28.0
'@types/babel__generator@7.27.0':
dependencies:
'@babel/types': 7.29.0
'@babel/types': 7.29.7
'@types/babel__template@7.4.4':
dependencies:
'@babel/parser': 7.29.3
'@babel/types': 7.29.0
'@babel/parser': 7.29.7
'@babel/types': 7.29.7
'@types/babel__traverse@7.28.0':
dependencies:
'@babel/types': 7.29.0
'@babel/types': 7.29.7
'@types/chai@5.2.3':
dependencies:
@@ -9561,7 +9587,7 @@ snapshots:
'@types/graceful-fs@4.1.9':
dependencies:
'@types/node': 25.6.0
'@types/node': 26.1.1
'@types/hammerjs@2.0.46': {}
@@ -9639,7 +9665,7 @@ snapshots:
dependencies:
'@types/yargs-parser': 21.0.3
'@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)':
'@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4)(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
'@typescript-eslint/parser': 8.59.2(eslint@9.39.4)(typescript@6.0.3)
@@ -9659,7 +9685,7 @@ snapshots:
dependencies:
'@typescript-eslint/scope-manager': 8.59.2
'@typescript-eslint/types': 8.59.2
'@typescript-eslint/typescript-estree': 8.59.2(typescript@5.9.3)
'@typescript-eslint/typescript-estree': 8.59.2(typescript@6.0.3)
'@typescript-eslint/visitor-keys': 8.59.2
debug: 4.4.3
eslint: 9.39.4
@@ -9676,6 +9702,15 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/project-service@8.59.2(typescript@6.0.3)':
dependencies:
'@typescript-eslint/tsconfig-utils': 8.59.2(typescript@6.0.3)
'@typescript-eslint/types': 8.59.2
debug: 4.4.3
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/scope-manager@8.59.2':
dependencies:
'@typescript-eslint/types': 8.59.2
@@ -9685,6 +9720,10 @@ snapshots:
dependencies:
typescript: 5.9.3
'@typescript-eslint/tsconfig-utils@8.59.2(typescript@6.0.3)':
dependencies:
typescript: 6.0.3
'@typescript-eslint/type-utils@8.59.2(eslint@9.39.4)(typescript@5.9.3)':
dependencies:
'@typescript-eslint/types': 8.59.2
@@ -9714,6 +9753,21 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/typescript-estree@8.59.2(typescript@6.0.3)':
dependencies:
'@typescript-eslint/project-service': 8.59.2(typescript@6.0.3)
'@typescript-eslint/tsconfig-utils': 8.59.2(typescript@6.0.3)
'@typescript-eslint/types': 8.59.2
'@typescript-eslint/visitor-keys': 8.59.2
debug: 4.4.3
minimatch: 10.2.5
semver: 7.7.4
tinyglobby: 0.2.17
ts-api-utils: 2.5.0(typescript@6.0.3)
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/utils@8.59.2(eslint@9.39.4)(typescript@5.9.3)':
dependencies:
'@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4)
@@ -9972,7 +10026,7 @@ snapshots:
babel-plugin-istanbul@6.1.1:
dependencies:
'@babel/helper-plugin-utils': 7.28.6
'@babel/helper-plugin-utils': 7.29.7
'@istanbuljs/load-nyc-config': 1.1.0
'@istanbuljs/schema': 0.1.6
istanbul-lib-instrument: 5.2.1
@@ -10273,7 +10327,7 @@ snapshots:
chrome-launcher@0.15.2:
dependencies:
'@types/node': 25.6.0
'@types/node': 26.1.1
escape-string-regexp: 4.0.0
is-wsl: 2.2.0
lighthouse-logger: 1.4.2
@@ -10282,7 +10336,7 @@ snapshots:
chromium-edge-launcher@0.2.0:
dependencies:
'@types/node': 25.6.0
'@types/node': 26.1.1
escape-string-regexp: 4.0.0
is-wsl: 2.2.0
lighthouse-logger: 1.4.2
@@ -10678,7 +10732,7 @@ snapshots:
es-errors: 1.3.0
get-intrinsic: 1.3.0
has-tostringtag: 1.0.2
hasown: 2.0.3
hasown: 2.0.4
es-shim-unscopables@1.1.0:
dependencies:
@@ -10776,11 +10830,11 @@ snapshots:
eslint-config-universe@15.0.4(eslint@9.39.4)(prettier@2.8.8)(typescript@5.9.3):
dependencies:
'@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)
'@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4)(typescript@5.9.3)
'@typescript-eslint/parser': 8.59.2(eslint@9.39.4)(typescript@6.0.3)
eslint: 9.39.4
eslint-config-prettier: 9.1.2(eslint@9.39.4)
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4)
eslint-plugin-n: 17.24.0(eslint@9.39.4)(typescript@5.9.3)
eslint-plugin-node: 11.1.0(eslint@9.39.4)
eslint-plugin-prettier: 5.5.5(eslint-config-prettier@9.1.2(eslint@9.39.4))(eslint@9.39.4)(prettier@2.8.8)
@@ -10804,7 +10858,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4):
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4):
dependencies:
debug: 3.2.7
optionalDependencies:
@@ -10827,7 +10881,7 @@ snapshots:
eslint-utils: 2.1.0
regexpp: 3.2.0
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4):
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4):
dependencies:
'@rtsao/scc': 1.1.0
array-includes: 3.1.9
@@ -10838,7 +10892,7 @@ snapshots:
doctrine: 2.1.0
eslint: 9.39.4
eslint-import-resolver-node: 0.3.10
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4)
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4)
hasown: 2.0.3
is-core-module: 2.16.2
is-glob: 4.0.3
@@ -11515,7 +11569,7 @@ snapshots:
get-proto: 1.0.1
gopd: 1.2.0
has-symbols: 1.1.0
hasown: 2.0.3
hasown: 2.0.4
math-intrinsics: 1.1.0
get-nonce@1.0.1: {}
@@ -12087,7 +12141,7 @@ snapshots:
dependencies:
'@jest/types': 29.6.3
'@types/graceful-fs': 4.1.9
'@types/node': 25.6.0
'@types/node': 26.1.1
anymatch: 3.1.3
fb-watchman: 2.0.2
graceful-fs: 4.2.11
@@ -12242,7 +12296,7 @@ snapshots:
jest-util@29.7.0:
dependencies:
'@jest/types': 29.6.3
'@types/node': 25.6.0
'@types/node': 26.1.1
chalk: 4.1.2
ci-info: 3.9.0
graceful-fs: 4.2.11
@@ -12287,7 +12341,7 @@ snapshots:
jest-worker@29.7.0:
dependencies:
'@types/node': 25.6.0
'@types/node': 26.1.1
jest-util: 29.7.0
merge-stream: 2.0.0
supports-color: 8.1.1
@@ -14008,6 +14062,10 @@ snapshots:
dependencies:
typescript: 5.9.3
ts-api-utils@2.5.0(typescript@6.0.3):
dependencies:
typescript: 6.0.3
ts-declaration-location@1.0.7(typescript@5.9.3):
dependencies:
picomatch: 4.0.4
+5
View File
@@ -0,0 +1,5 @@
allowBuilds:
esbuild: true
overrides:
xcode>uuid: 11.1.1
+5
View File
@@ -506,6 +506,11 @@ async function openPairingUrlInSimulator(pairingUrl, deviceUdid, runtime, worktr
await execFileAsync('xcrun', ['simctl', 'openurl', deviceUdid, pairingUrl])
await new Promise((resolve) => setTimeout(resolve, 2000))
// Why: the first deep link can arrive while the freshly opened Expo app is
// still mounting, so resend it once the JS router is ready to receive URLs.
await execFileAsync('xcrun', ['simctl', 'openurl', deviceUdid, pairingUrl])
await new Promise((resolve) => setTimeout(resolve, 2000))
// Why: the mobile app intentionally asks for a trust confirmation before
// saving a host. This lands on the Pair button on current iPhone simulators.
await orca(['emulator', 'tap', '0.5', '0.56', '--worktree', worktree, '--json'], {
+43
View File
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest'
import { setCachedWorktrees, getCachedWorktrees } from './worktree-cache'
// Why: AC #8498 guarantees a reconnect refetch writes through the
// same cache path the host detail screen seeds from, so a reconnect can't
// serve a stale snapshot. This unit pins the write-through contract.
describe('worktree-cache write-through', () => {
it('returns the most-recently written snapshot, not a stale one', () => {
const hostId = 'host-write-through'
const stale = [{ worktreeId: 'a', name: 'stale' }]
const fresh = [
{ worktreeId: 'a', name: 'fresh' },
{ worktreeId: 'b', name: 'added' }
]
setCachedWorktrees(hostId, stale)
expect(getCachedWorktrees(hostId)).toEqual(stale)
// Why: simulates the reconnect refetch write-through — the fresh
// worktree.ps snapshot must fully replace the poisoned cache entry.
setCachedWorktrees(hostId, fresh)
expect(getCachedWorktrees(hostId)).toEqual(fresh)
expect(getCachedWorktrees(hostId)).not.toEqual(stale)
})
it('exposes a fresh snapshot to a remounting screen after reconnect', () => {
// Why: the host detail screen reads getCachedWorktrees(hostId)
// on (re)mount as its initialCache. A reconnect that writes
// through must therefore surface here instead of the pre-reconnect data.
const hostId = 'host-remount'
setCachedWorktrees(hostId, [{ worktreeId: 'old', name: 'pre-reconnect' }])
// Reconnect refetch lands a fresh snapshot and writes it through.
const reconnected = [
{ worktreeId: 'old', name: 'post-reconnect' },
{ worktreeId: 'new', name: 'now-visible' }
]
setCachedWorktrees(hostId, reconnected)
// A fresh screen mount reads the cache — must see the connected set.
expect(getCachedWorktrees(hostId)).toEqual(reconnected)
})
})
+44 -20
View File
@@ -17,6 +17,7 @@ export {
getActiveProviderRateLimits,
getInactiveProviderUsage,
getUsageBarState,
getWindowResetLabel,
hasActiveProviderUsage,
hasRenderableUsage
} from './account-usage-state'
@@ -28,12 +29,14 @@ export function UsageBar({
label,
usedPercent,
unavailable,
loading
loading,
resetText
}: {
label: string
usedPercent: number | null
unavailable: boolean
loading?: boolean
resetText?: string | null
}) {
// Why: round then clamp so bar width, color, and label share one value (desktop parity).
const used = usedPercent == null ? null : Math.max(0, Math.min(100, Math.round(usedPercent)))
@@ -47,34 +50,48 @@ export function UsageBar({
? colors.statusAmber
: colors.statusGreen
return (
<View style={styles.usageBar}>
<Text style={styles.usageLabel}>{label}</Text>
<View style={styles.usageTrack}>
<View
style={[
styles.usageFill,
{
width: `${used ?? 0}%`,
backgroundColor: unavailable ? colors.textMuted : barColor
}
]}
/>
<View style={styles.usageBarColumn}>
<View style={styles.usageBar}>
<Text style={styles.usageLabel}>{label}</Text>
<View style={styles.usageTrack}>
<View
style={[
styles.usageFill,
{
width: `${used ?? 0}%`,
backgroundColor: unavailable ? colors.textMuted : barColor
}
]}
/>
</View>
{loading ? (
<ActivityIndicator
size="small"
color={colors.textSecondary}
style={styles.usageSpinner}
/>
) : (
<Text style={styles.usageValue}>{unavailable || used == null ? '—' : `${used}%`}</Text>
)}
</View>
{loading ? (
<ActivityIndicator size="small" color={colors.textSecondary} style={styles.usageSpinner} />
) : (
<Text style={styles.usageValue}>{unavailable || used == null ? '—' : `${used}%`}</Text>
)}
{resetText ? (
<Text style={styles.usageResetText} numberOfLines={1}>
{resetText}
</Text>
) : null}
</View>
)
}
const styles = StyleSheet.create({
usageBarColumn: {
flex: 1,
gap: 2
},
usageBar: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
flex: 1
gap: spacing.xs
},
usageLabel: {
fontSize: typography.metaSize,
@@ -100,5 +117,12 @@ const styles = StyleSheet.create({
},
usageSpinner: {
width: 36
},
// Why: indented past the window label so the countdown aligns with the
// start of the track above it.
usageResetText: {
fontSize: typography.metaSize,
color: colors.textMuted,
marginLeft: 22 + spacing.xs
}
})
+108 -96
View File
@@ -24,6 +24,7 @@ import Animated, {
} from 'react-native-reanimated'
import { colors, spacing } from '../theme/mobile-theme'
import { resolveBottomDrawerMounted } from './bottom-drawer-mount-state'
import { useInsideBottomDrawerModalHost } from './bottom-drawer-modal-host'
import { useResponsiveLayout } from '../layout/responsive-layout'
const DISMISS_THRESHOLD = 80
@@ -107,6 +108,7 @@ function MountedBottomDrawer({
// center it horizontally. Vertical bottom-anchoring (and all the drag/keyboard
// transforms below) is unchanged, so phone behavior stays identical.
const { isWideLayout, modalMaxWidth } = useResponsiveLayout()
const insideModalHost = useInsideBottomDrawerModalHost()
useEffect(() => {
if (visible) {
@@ -265,104 +267,114 @@ function MountedBottomDrawer({
return { opacity: progress.value * dragFade }
})
// Why: rendering through a full-screen Modal lifts the sheet into its own
// native window so it always covers the viewport — even when the drawer is
// mounted deep inside a ScrollView, where a plain absolute overlay anchors to
// the scrolled content and clips the sheet. The Modal stays mounted (visible)
// for the whole life of MountedBottomDrawer so the reanimated exit animation
// runs before the parent unmounts us; show/hide is driven by `progress`, so
// animationType stays "none". onRequestClose handles the Android back button.
// Why: the sheet renders through a full-screen native window (its own Modal
// below, or the shared BottomDrawerModalHost) so it always covers the viewport
// — even when mounted deep inside a ScrollView, where a plain absolute overlay
// anchors to the scrolled content and clips the sheet. Show/hide is driven by
// `progress` (animationType "none") so the reanimated exit animation runs before
// the parent unmounts us.
const overlay = (
<Animated.View
pointerEvents={visible ? 'auto' : 'none'}
style={[styles.overlay, { zIndex, elevation: zIndex }]}
accessibilityViewIsModal
aria-modal
>
<GestureHandlerRootView style={styles.root}>
<Animated.View style={[styles.backdrop, backdropStyle]}>
<Pressable style={StyleSheet.absoluteFill} onPress={dismiss} />
</Animated.View>
<View style={[styles.anchor, isWideLayout && styles.anchorWide]} pointerEvents="box-none">
<Animated.View
style={[
styles.drawer,
{
width: '100%',
maxWidth: isWideLayout ? modalMaxWidth : undefined,
maxHeight: screenHeight - insets.top - spacing.lg,
paddingBottom: insets.bottom + spacing.lg
},
drawerStyle
]}
>
{!contentScrollable ? (
<>
<GestureDetector gesture={handlePanGesture}>
<Animated.View
style={styles.handleHitArea}
accessibilityRole="button"
accessibilityLabel="Dismiss drawer"
>
<View style={styles.handle} />
</Animated.View>
</GestureDetector>
<View style={styles.staticContent}>{children}</View>
</>
) : dragContentToDismiss ? (
<>
<GestureDetector gesture={handlePanGesture}>
<Animated.View
style={styles.handleHitArea}
accessibilityRole="button"
accessibilityLabel="Dismiss drawer"
>
<View style={styles.handle} />
</Animated.View>
</GestureDetector>
<GestureDetector gesture={contentPanGesture}>
<Animated.View collapsable={false}>
<GestureDetector gesture={scrollGesture}>
<Animated.ScrollView
bounces={false}
keyboardShouldPersistTaps="handled"
onScroll={scrollHandler}
scrollEventThrottle={16}
showsVerticalScrollIndicator={false}
>
{children}
</Animated.ScrollView>
</GestureDetector>
</Animated.View>
</GestureDetector>
</>
) : (
<>
<GestureDetector gesture={handlePanGesture}>
<Animated.View
style={styles.handleHitArea}
accessibilityRole="button"
accessibilityLabel="Dismiss drawer"
>
<View style={styles.handle} />
</Animated.View>
</GestureDetector>
<ScrollView
bounces={false}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{children}
</ScrollView>
</>
)}
<View style={styles.bottomExtension} />
</Animated.View>
</View>
</GestureHandlerRootView>
</Animated.View>
)
// Why: inside a BottomDrawerModalHost the host owns the single native Modal;
// rendering our own would stack modals and reintroduce the iOS present/dismiss
// race the host exists to avoid. The host handles the Android back button.
if (insideModalHost) {
return overlay
}
return (
<Modal visible transparent animationType="none" statusBarTranslucent onRequestClose={dismiss}>
<Animated.View
pointerEvents={visible ? 'auto' : 'none'}
style={[styles.overlay, { zIndex, elevation: zIndex }]}
accessibilityViewIsModal
aria-modal
>
<GestureHandlerRootView style={styles.root}>
<Animated.View style={[styles.backdrop, backdropStyle]}>
<Pressable style={StyleSheet.absoluteFill} onPress={dismiss} />
</Animated.View>
<View style={[styles.anchor, isWideLayout && styles.anchorWide]} pointerEvents="box-none">
<Animated.View
style={[
styles.drawer,
{
width: '100%',
maxWidth: isWideLayout ? modalMaxWidth : undefined,
maxHeight: screenHeight - insets.top - spacing.lg,
paddingBottom: insets.bottom + spacing.lg
},
drawerStyle
]}
>
{!contentScrollable ? (
<>
<GestureDetector gesture={handlePanGesture}>
<Animated.View
style={styles.handleHitArea}
accessibilityRole="button"
accessibilityLabel="Dismiss drawer"
>
<View style={styles.handle} />
</Animated.View>
</GestureDetector>
<View style={styles.staticContent}>{children}</View>
</>
) : dragContentToDismiss ? (
<>
<GestureDetector gesture={handlePanGesture}>
<Animated.View
style={styles.handleHitArea}
accessibilityRole="button"
accessibilityLabel="Dismiss drawer"
>
<View style={styles.handle} />
</Animated.View>
</GestureDetector>
<GestureDetector gesture={contentPanGesture}>
<Animated.View collapsable={false}>
<GestureDetector gesture={scrollGesture}>
<Animated.ScrollView
bounces={false}
keyboardShouldPersistTaps="handled"
onScroll={scrollHandler}
scrollEventThrottle={16}
showsVerticalScrollIndicator={false}
>
{children}
</Animated.ScrollView>
</GestureDetector>
</Animated.View>
</GestureDetector>
</>
) : (
<>
<GestureDetector gesture={handlePanGesture}>
<Animated.View
style={styles.handleHitArea}
accessibilityRole="button"
accessibilityLabel="Dismiss drawer"
>
<View style={styles.handle} />
</Animated.View>
</GestureDetector>
<ScrollView
bounces={false}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{children}
</ScrollView>
</>
)}
<View style={styles.bottomExtension} />
</Animated.View>
</View>
</GestureHandlerRootView>
</Animated.View>
{overlay}
</Modal>
)
}
+13
View File
@@ -1,8 +1,11 @@
import { Image, StyleSheet, Text, View } from 'react-native'
import type { ImageSourcePropType } from 'react-native'
import { Terminal } from 'lucide-react-native'
import type { TuiAgent } from '../../../src/shared/types'
import Svg, { Defs, G, LinearGradient, Path, Stop } from 'react-native-svg'
import { colors } from '../theme/mobile-theme'
import { MOBILE_AGENT_CATALOG } from '../tasks/mobile-agent-catalog'
import { MOBILE_AGENT_ICON_ASSETS } from './mobile-agent-icon-assets'
import { ClaudeIcon, OpenAIIcon } from './AgentIcons'
// Why: agent branding should match the desktop/new-worktree picker everywhere
@@ -49,6 +52,10 @@ function AiderIcon({ size = 16 }: { size?: number }) {
)
}
function BundledIcon({ source, size = 16 }: { source: ImageSourcePropType; size?: number }) {
return <Image source={source} style={{ width: size, height: size, borderRadius: 2 }} />
}
function FaviconIcon({ domain, size = 16 }: { domain: string; size?: number }) {
return (
<Image
@@ -98,6 +105,12 @@ export function MobileAgentIcon({ agentId, size = 16 }: { agentId: string; size?
return <Terminal size={size} color={colors.textMuted} />
}
// Why: prefer the favicon bundled into the app over Google's favicon service,
// which is unreachable in some regions and offline (#8451).
const bundledIcon = MOBILE_AGENT_ICON_ASSETS[agentId as TuiAgent]
if (bundledIcon) {
return <BundledIcon source={bundledIcon} size={size} />
}
const agent = MOBILE_AGENT_CATALOG.find((entry) => entry.id === agentId)
if (agent?.faviconDomain) {
return <FaviconIcon domain={agent.faviconDomain} size={size} />
+17 -2
View File
@@ -14,6 +14,7 @@ import {
type MobilePrTitleAction
} from '../session/use-mobile-pr-title-action'
import { useMobilePrAiTriage, type MobilePrAiTriage } from '../session/use-mobile-pr-ai-triage'
import { usePRBotAuthorOverrides } from '../session/use-pr-bot-author-overrides'
import { buildFixChecksPrompt, buildResolveConflictsPrompt } from '../session/pr-ai-triage-prompt'
import { prSidebarRenderBranch } from './mobile-pr-sidebar-presentation'
import { mobilePrSidebarStyles as styles } from './pr-sidebar/mobile-pr-sidebar-styles'
@@ -89,6 +90,13 @@ export function MobilePRSidebar({
refetch
})
const triage = useMobilePrAiTriage({ client, connState, worktreeId })
// Keyed on the PR payload identity so overrides re-fetch with each PR refetch
// instead of staying a stale one-shot snapshot for the whole session.
const botAuthorOverrides = usePRBotAuthorOverrides(
client,
connState,
state.kind === 'ready' ? state.data.details : null
)
return (
<ScrollView
@@ -114,6 +122,7 @@ export function MobilePRSidebar({
commentActions={commentActions}
titleAction={titleAction}
triage={triage}
botAuthorOverrides={botAuthorOverrides}
showOpenOnWeb={showOpenOnWeb}
/>
</ScrollView>
@@ -134,7 +143,8 @@ function PrSidebarContent({
commentActions,
titleAction,
triage,
showOpenOnWeb
showOpenOnWeb,
botAuthorOverrides
}: {
branch: ReturnType<typeof prSidebarRenderBranch>
state: PrSidebarState
@@ -150,6 +160,7 @@ function PrSidebarContent({
titleAction: MobilePrTitleAction
triage: MobilePrAiTriage
showOpenOnWeb: boolean
botAuthorOverrides: ReadonlySet<string>
}) {
if (branch === 'loading') {
return (
@@ -215,6 +226,7 @@ function PrSidebarContent({
titleAction={titleAction}
triage={triage}
refetch={refetch}
botAuthorOverrides={botAuthorOverrides}
showOpenOnWeb={showOpenOnWeb}
/>
)
@@ -231,7 +243,8 @@ function PrSidebarSections({
titleAction,
triage,
refetch,
showOpenOnWeb
showOpenOnWeb,
botAuthorOverrides
}: {
data: Extract<PrSidebarState, { kind: 'ready' }>['data']
client: RpcClient | null
@@ -242,6 +255,7 @@ function PrSidebarSections({
triage: MobilePrAiTriage
refetch: () => void
showOpenOnWeb: boolean
botAuthorOverrides: ReadonlySet<string>
}) {
const pr = data.pr
// Bind the triage launchers to this PR's data; the prompt builders are pure so
@@ -314,6 +328,7 @@ function PrSidebarSections({
prState={data.pr.state}
prRepo={data.pr.prRepo ?? null}
actions={commentActions}
botAuthorOverrides={botAuthorOverrides}
/>
</>
)
+329 -288
View File
@@ -10,21 +10,20 @@ import {
ActivityIndicator,
Keyboard
} from 'react-native'
import { ChevronDown, ChevronUp, Check } from 'lucide-react-native'
import { ChevronDown, ChevronUp } from 'lucide-react-native'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcSuccess } from '../transport/types'
import type { RpcResponse, RpcSuccess } from '../transport/types'
import { colors, spacing, radii, typography } from '../theme/mobile-theme'
import { BottomDrawer } from './BottomDrawer'
import { BottomDrawer, BOTTOM_DRAWER_HIDE_DURATION_MS } from './BottomDrawer'
import { BottomDrawerModalHost } from './bottom-drawer-modal-host'
import { PickerListDrawer } from './PickerListDrawer'
import { MobileAgentIcon } from './MobileAgentIcon'
import { MobileWorkspaceNameInput } from './MobileWorkspaceNameInput'
import { getSuggestedCreatureName } from './worktree-name-suggestion'
import { deriveWorkspaceSshGate, workspaceSshStatusLabel } from '../tasks/workspace-ssh-gate'
import { WORKTREE_CREATE_TIMEOUT_MS } from '../tasks/workspace-create-timeout'
import {
isSetupHookTrusted,
normalizeSetupHookTrust,
trustedOrcaHooksWithSetupApproval,
persistSetupHookTrustApproval,
wasSetupHookPreviouslyApproved,
type SetupHookTrust
} from '../tasks/setup-hook-trust'
@@ -49,6 +48,24 @@ import {
refreshMobileNewWorkspaceDialogSelectedRepo,
resolveMobileNewWorkspaceDialogRepoId
} from '../worktree/new-workspace-dialog-repo-selection'
import { createBlankWorkspace } from '../tasks/blank-workspace-create'
import { createWorkspaceFromComposerSource } from '../tasks/source-workspace-create'
import { MOBILE_TASKS_CAPABILITY } from '../tasks/mobile-tasks-capability'
import { normalizeWorkspaceAgent } from '../tasks/workspace-agent-selection'
import {
filterAvailableTaskProviders,
normalizeVisibleTaskProviders,
type TaskProvider
} from '../tasks/mobile-task-providers'
import { useMobileComposerSource } from '../tasks/use-mobile-composer-source'
import type { SmartModeAvailabilityInput } from '../tasks/mobile-smart-source-modes'
import { deriveRepoSlug, type PasteRepoCandidate } from '../tasks/smart-source-paste-intent'
import { shouldPreserveWorkspaceSourceOnRepoChange } from '../../../src/shared/new-workspace/workspace-source'
import { getComposerRepoWorktreeBranches } from '../../../src/shared/composer-branch-selection'
import { SmartWorkspaceSourceField } from './SmartWorkspaceSourceField'
import { SmartWorkspaceSourceDrawer } from './SmartWorkspaceSourceDrawer'
import { SmartWorkspaceAdvancedFields } from './SmartWorkspaceAdvancedFields'
import { SetupHookTrustDrawer, type SetupTrustPrompt } from './SetupHookTrustDrawer'
type Repo = {
id: string
@@ -56,6 +73,9 @@ type Repo = {
path: string
badgeColor?: string
connectionId?: string | null
kind?: 'git' | 'folder'
upstream?: { owner: string; repo: string } | null
gitRemoteIdentity?: { remoteUrl?: string; canonicalKey?: string } | null
}
type SetupDecision = 'inherit' | 'run' | 'skip'
@@ -91,13 +111,11 @@ type CreateOptions = {
approvedSetupContentHash?: string
}
type SetupTrustPrompt = {
repoId: string
repoName: string
scriptContent: string
contentHash: string
previouslyApproved: boolean
}
type NewWorktreeDrawerView = 'form' | 'transition' | 'source' | 'repo' | 'agent' | 'trust'
// Why: iOS cannot reliably present a second native modal until the first drawer's
// exit commits; one extra frame keeps transitions sequential on slower devices.
const NEW_WORKTREE_DRAWER_TRANSITION_MS = BOTTOM_DRAWER_HIDE_DURATION_MS + 16
function repoColor(name: string): string {
const palette = ['#f97316', '#8b5cf6', '#06b6d4', '#ec4899', '#84cc16', '#f59e0b', '#6366f1']
@@ -124,6 +142,7 @@ type Props = {
// on the on-disk directory basename, so paths (not displayNames) are
// what the suggestion logic must dedupe against.
existingWorktreePaths?: readonly string[]
existingWorktrees?: readonly { repoId: string; branch: string }[]
onCreated: (worktreeId: string, name: string) => void
onClose: () => void
}
@@ -133,6 +152,7 @@ export function NewWorktreeModal({
client,
hostId,
existingWorktreePaths,
existingWorktrees,
onCreated,
onClose
}: Props) {
@@ -157,6 +177,7 @@ export function NewWorktreeModal({
client={client}
hostId={hostId}
existingWorktreePaths={existingWorktreePaths}
existingWorktrees={existingWorktrees}
onCreated={onCreated}
onClose={onClose}
/>
@@ -168,25 +189,28 @@ function NewWorktreeModalContent({
client,
hostId,
existingWorktreePaths,
existingWorktrees,
onCreated,
onClose
}: Props) {
const [initialRepos] = useState(() => (hostId ? (getCachedRepos(hostId) as Repo[] | null) : null))
const [repos, setRepos] = useState<Repo[]>(initialRepos ?? [])
const [selectedRepo, setSelectedRepo] = useState<Repo | null>(null)
const [showRepoPicker, setShowRepoPicker] = useState(false)
const [nameAutoFocusEnabled, setNameAutoFocusEnabled] = useState(true)
const [drawerView, setDrawerView] = useState<NewWorktreeDrawerView>('form')
const drawerTransitionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const createInFlightRef = useRef(false)
const setupTrustActionInFlightRef = useRef(false)
const [selectedAgentState, setSelectedAgent] = useState<AgentOption>(AGENT_OPTIONS[0]!)
const [runtimeSettings, setRuntimeSettings] = useState<RuntimeSettings | null>(null)
const [detectedAgentIdsState, setDetectedAgentIdsState] = useState<DetectedAgentIdsState | null>(
null
)
const [agentOverriddenState, setAgentOverridden] = useState(false)
const [showAgentPicker, setShowAgentPicker] = useState(false)
const [sshState, setSshState] = useState<SshConnectionState | null>(null)
const [sshConnectingTargetId, setSshConnectingTargetId] = useState<string | null>(null)
const [name, setName] = useState('')
const [note, setNote] = useState('')
const [availableProviders, setAvailableProviders] = useState<TaskProvider[]>([])
const [tasksSupported, setTasksSupported] = useState(false)
const [showAdvanced, setShowAdvanced] = useState(false)
const [setupHookDetails, setSetupHookDetails] = useState<SetupHookDetails | null>(null)
const [trustedOrcaHooks, setTrustedOrcaHooks] = useState<PersistedTrustedOrcaHooks>({})
@@ -200,12 +224,40 @@ function NewWorktreeModalContent({
const [error, setError] = useState('')
const [loading, setLoading] = useState(initialRepos == null)
const lastVisitedRepo = useLastVisitedWorktreeRepoId(hostId, visible)
const selectedRepoWorktreeBranches = useMemo(
() => getComposerRepoWorktreeBranches(existingWorktrees ?? [], selectedRepo?.id ?? null),
[existingWorktrees, selectedRepo]
)
// Why: matches the desktop UI — the input shows a generic "Workspace name"
// placeholder, not the suggested creature. The creature name is only used
// as a server-bound fallback when the user submits with a blank field, so
// it's recomputed lazily inside handleCreate() to stay fresh against
// existingWorktreePaths at submission time.
useEffect(() => {
return () => {
if (drawerTransitionTimerRef.current) {
clearTimeout(drawerTransitionTimerRef.current)
}
}
}, [])
function transitionDrawer(nextView: Exclude<NewWorktreeDrawerView, 'transition'>): void {
if (drawerTransitionTimerRef.current) {
clearTimeout(drawerTransitionTimerRef.current)
}
setDrawerView('transition')
drawerTransitionTimerRef.current = setTimeout(() => {
drawerTransitionTimerRef.current = null
setDrawerView(nextView)
}, NEW_WORKTREE_DRAWER_TRANSITION_MS)
}
// The Smart source picker owns the workspace name AND the linked-source
// selection: typing names the workspace and drives source search, and picking
// a source resolves the base/branch/push metadata (matching desktop). The
// creature-name fallback is only computed lazily at submit for a blank name.
const composer = useMobileComposerSource({
client,
selectedRepoId: selectedRepo?.id ?? null,
worktreeBranches: selectedRepoWorktreeBranches,
onError: setError
})
const selectedRepoConnectionId = selectedRepo?.connectionId ?? null
const sshGate = deriveWorkspaceSshGate({
@@ -242,6 +294,25 @@ function NewWorktreeModalContent({
}
const selectedAgent = selectedAgentResolution.selectedAgent
const selectedRepoIsGit = selectedRepo ? selectedRepo.kind !== 'folder' : true
const sourceAvailability: SmartModeAvailabilityInput = {
textOnly: selectedRepo != null && !selectedRepoIsGit,
tasksSupported,
hasRepo: selectedRepo != null,
githubAvailable: availableProviders.includes('github'),
gitlabAvailable: availableProviders.includes('gitlab'),
linearAvailable: availableProviders.includes('linear')
}
const pasteRepos = useMemo<PasteRepoCandidate[]>(
() =>
repos.map((repo) => ({
id: repo.id,
displayName: repo.displayName,
slug: deriveRepoSlug(repo)
})),
[repos]
)
useEffect(() => {
if (!visible || !lastVisitedRepo.loaded || selectedRepo || repos.length === 0) {
return
@@ -298,27 +369,68 @@ function NewWorktreeModalContent({
})
void (async () => {
try {
const [settingsResponse, uiResponse] = await Promise.all([
client.sendRequest('settings.get'),
client.sendRequest('ui.get')
])
if (stale) {
return
}
if (settingsResponse.ok) {
const result = (settingsResponse as RpcSuccess).result as { settings: RuntimeSettings }
setRuntimeSettings(result.settings)
}
if (uiResponse.ok) {
const result = (uiResponse as RpcSuccess).result as {
ui?: { trustedOrcaHooks?: PersistedTrustedOrcaHooks }
}
setTrustedOrcaHooks(result.ui?.trustedOrcaHooks ?? {})
}
} catch {
// Non-critical; repo.list owns the visible loading state.
// Why: settle each RPC independently so a flaky availability probe (e.g. a
// linear.status timeout, which rejects rather than resolving {ok:false})
// can't discard the already-resolved critical settings/ui results.
const probes = Promise.allSettled([
client.sendRequest('status.get'),
client.sendRequest('preflight.check'),
client.sendRequest('linear.status')
])
const okResult = (entry: PromiseSettledResult<RpcResponse>): RpcSuccess | null =>
entry.status === 'fulfilled' && entry.value.ok ? (entry.value as RpcSuccess) : null
// Why: hydrate settings/trust the moment their own RPCs settle — gating them
// on the probes (a first-open preflight.check can take seconds) widens the
// window where an already-trusted setup hook spuriously re-prompts on create.
const [settingsRes, uiRes] = await Promise.allSettled([
client.sendRequest('settings.get'),
client.sendRequest('ui.get')
])
if (stale) {
return
}
const settingsResult = okResult(settingsRes)
const settingsValue = settingsResult
? (
settingsResult.result as {
settings: RuntimeSettings & { visibleTaskProviders?: unknown }
}
).settings
: null
if (settingsValue) {
setRuntimeSettings(settingsValue)
}
const uiResult = okResult(uiRes)
if (uiResult) {
const ui = (uiResult.result as { ui?: { trustedOrcaHooks?: PersistedTrustedOrcaHooks } }).ui
setTrustedOrcaHooks(ui?.trustedOrcaHooks ?? {})
}
const [statusRes, preflightRes, linearRes] = await probes
if (stale) {
return
}
// Tasks is an additive RPC surface, so older paired desktops without the
// capability fall back to branch + blank sources only.
const statusResult = okResult(statusRes)
const capabilities =
(statusResult?.result as { capabilities?: string[] } | undefined)?.capabilities ?? []
setTasksSupported(capabilities.includes(MOBILE_TASKS_CAPABILITY))
const glabInstalled =
(okResult(preflightRes)?.result as { glab?: { installed?: boolean } } | undefined)?.glab
?.installed === true
const linearConnected =
(okResult(linearRes)?.result as { connected?: boolean } | undefined)?.connected === true
const visibleProviders = normalizeVisibleTaskProviders(settingsValue?.visibleTaskProviders)
setAvailableProviders(
// Drop filterAvailableTaskProviders' forced 'github' fallback when the user
// hid GitHub; the Branch tab always guarantees at least one tab remains.
filterAvailableTaskProviders(visibleProviders, {
gitlabInstalled: glabInstalled,
linearConnected
}).filter((provider) => visibleProviders.includes(provider))
)
})()
return () => {
stale = true
@@ -486,31 +598,11 @@ function NewWorktreeModalContent({
}
}
async function persistSetupHookTrust(
repoId: string,
contentHash: string,
alwaysTrust: boolean
): Promise<void> {
if (!client) {
return
}
const next = trustedOrcaHooksWithSetupApproval({
trust: trustedOrcaHooks,
repoId,
contentHash,
alwaysTrust
})
const response = await client.sendRequest('ui.set', { trustedOrcaHooks: next })
if (!response.ok) {
throw new Error(response.error.message)
}
setTrustedOrcaHooks(next)
}
async function handleCreate(options: CreateOptions = {}) {
if (!client || !selectedRepo) {
if (!client || !selectedRepo || createInFlightRef.current) {
return
}
createInFlightRef.current = true
setCreating(true)
setError('')
@@ -555,24 +647,9 @@ function NewWorktreeModalContent({
// server invent one. The pre-flight basename dedupe is only a hint;
// the authoritative collision is checked server-side against git
// branches/remotes/PRs, so we also retry-with-suffix on conflict.
const trimmedName = name.trim()
const trimmedName = composer.name.trim()
const baseName = trimmedName || getSuggestedCreatureName(existingWorktreePaths ?? [])
// Why: mirrors src/renderer/src/store/slices/worktrees.ts
// (createWorktree retry loop). Server-side checks (Branch X already
// exists locally / on a remote / already has PR #N) can fire even
// after the pre-flight basename dedupe — branches outlive worktrees
// in git, and remote branches/PRs aren't visible from worktree.ps.
// Retry up to 25 times by appending -2, -3, ... before surfacing
// the error. The desktop applies this to user-typed names too, so
// mobile follows suit for parity.
const retryablePatterns = [
/already exists locally/i,
/already exists on a remote/i,
/already has pr #\d+/i
]
const candidateFor = (attempt: number): string =>
attempt === 0 ? baseName : `${baseName}-${attempt + 1}`
let setupDecision: SetupDecision = 'inherit'
if (setupCommand) {
if (options.setupOverride) {
@@ -602,44 +679,46 @@ function NewWorktreeModalContent({
contentHash: setupTrust.contentHash,
previouslyApproved: wasSetupHookPreviouslyApproved(trustedOrcaHooks, selectedRepo.id)
})
transitionDrawer('trust')
return
}
let lastError: string | null = null
for (let attempt = 0; attempt < 25; attempt += 1) {
const candidateName = candidateFor(attempt)
const params: Record<string, unknown> = {
repo: `id:${selectedRepo.id}`,
startupCommand: command,
setupDecision,
name: candidateName
}
if (selectedAgent.id !== '__blank__') {
params.createdWithAgent = selectedAgent.id
}
if (note.trim()) {
params.comment = note.trim()
}
const response = await client.sendRequest('worktree.create', params, {
timeoutMs: WORKTREE_CREATE_TIMEOUT_MS
})
if (response.ok) {
const result = (response as RpcSuccess).result as { worktree: { id: string } }
onClose()
onCreated(result.worktree.id, candidateName)
return
}
lastError = response.error.message
if (!retryablePatterns.some((p) => p.test(lastError ?? ''))) {
break
}
const createdWithAgentId = selectedAgent.id !== '__blank__' ? selectedAgent.id : undefined
const trimmedNote = note.trim() || undefined
const createSelection = composer.createSelection
const result = createSelection
? await createWorkspaceFromComposerSource({
client,
selection: createSelection,
targetRepoId: selectedRepo.id,
setupDecision,
agent: {
choice: normalizeWorkspaceAgent(selectedAgent.id) ?? 'blank',
startupCommand: command
},
workspaceName: trimmedName || undefined,
note: trimmedNote,
nameIsAutoManaged: composer.isNameAutoManaged
})
: await createBlankWorkspace({
client,
repoId: selectedRepo.id,
baseName,
startupCommand: command,
createdWithAgentId,
comment: trimmedNote,
setupDecision
})
if ('error' in result) {
setError(result.error)
return
}
setError(lastError ?? 'Failed to create workspace')
onClose()
onCreated(result.worktreeId, result.name)
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to create workspace')
} finally {
createInFlightRef.current = false
setCreating(false)
}
}
@@ -670,15 +749,90 @@ function NewWorktreeModalContent({
)
function prepareSelectionPickerOpen(): void {
// Why: picker taps can beat the delayed name-field focus; suppressing it
// prevents the keyboard from reopening under the picker drawer.
setNameAutoFocusEnabled(false)
// Why: picker taps can beat an open soft keyboard; dismissing it prevents the
// keyboard from reopening under the picker drawer.
Keyboard.dismiss()
}
function handleRepoSelected(repo: Repo): void {
const repoChanged = repo.id !== selectedRepo?.id
setSelectedRepo(repo)
// Branch and provider-backed sources are repo-scoped; Linear/Jira are global
// work context and survive choosing a different implementation repo.
if (repoChanged && !shouldPreserveWorkspaceSourceOnRepoChange(composer.linkedWorkItem)) {
composer.handleClearSmartNameSelection()
}
}
async function approveSetupTrust(alwaysTrust: boolean): Promise<void> {
if (
!client ||
!setupTrustPrompt ||
setupTrustActionInFlightRef.current ||
createInFlightRef.current
) {
return
}
setupTrustActionInFlightRef.current = true
setCreating(true)
try {
const nextTrust = await persistSetupHookTrustApproval({
client,
trust: trustedOrcaHooks,
repoId: setupTrustPrompt.repoId,
contentHash: setupTrustPrompt.contentHash,
alwaysTrust
})
setTrustedOrcaHooks(nextTrust)
const approvedHash = setupTrustPrompt.contentHash
setSetupTrustPrompt(null)
transitionDrawer('form')
await handleCreate({ setupOverride: 'run', approvedSetupContentHash: approvedHash })
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to trust setup script.')
} finally {
setupTrustActionInFlightRef.current = false
if (!createInFlightRef.current) {
setCreating(false)
}
}
}
function closeSetupTrust(): void {
if (setupTrustActionInFlightRef.current || createInFlightRef.current) {
return
}
setSetupTrustPrompt(null)
transitionDrawer('form')
}
function skipSetupTrust(): void {
if (setupTrustActionInFlightRef.current || createInFlightRef.current) {
return
}
closeSetupTrust()
void handleCreate({ setupOverride: 'skip' })
}
return (
<>
<BottomDrawer visible={visible} onClose={onClose}>
// Why: hosting the form and every picker in one persistent native Modal makes
// form → repo/agent transitions in-window view swaps, avoiding the iOS
// dismiss-then-present race that left the dropdowns unresponsive. Native back
// closes the flow from the form, routes the trust prompt through its in-flight
// guard, and otherwise returns to the form from a picker.
<BottomDrawerModalHost
visible={visible}
onRequestClose={() => {
if (drawerView === 'form') {
onClose()
} else if (drawerView === 'trust') {
closeSetupTrust()
} else {
transitionDrawer('form')
}
}}
>
<BottomDrawer visible={visible && drawerView === 'form'} onClose={onClose}>
<View style={styles.header}>
<Text style={styles.title}>Create Workspace</Text>
<Text style={styles.subtitle}>
@@ -702,7 +856,7 @@ function NewWorktreeModalContent({
style={styles.fieldButton}
onPress={() => {
prepareSelectionPickerOpen()
setShowRepoPicker(true)
transitionDrawer('repo')
}}
>
{selectedRepo ? (
@@ -720,6 +874,18 @@ function NewWorktreeModalContent({
</Pressable>
</View>
<SmartWorkspaceSourceField
composer={composer}
label={selectedRepoIsGit ? "Name or 'Create From'" : 'Workspace name'}
disabled={sshGate.requiresConnection}
onBeforeOpen={() => setError('')}
onOpenDrawer={() => transitionDrawer('source')}
/>
{composer.forkPushWarning ? (
<Text style={styles.sourceWarning}>{composer.forkPushWarning}</Text>
) : null}
{selectedRepoConnectionId ? (
<View style={styles.field}>
<Text style={styles.label}>SSH Connection</Text>
@@ -763,28 +929,6 @@ function NewWorktreeModalContent({
</View>
) : null}
<View style={styles.field}>
<Text style={styles.label}>
Workspace Name <Text style={styles.labelHint}>[Optional]</Text>
</Text>
<MobileWorkspaceNameInput
style={styles.input}
value={name}
onChangeText={(t) => {
setName(t)
setError('')
}}
placeholderTextColor={colors.textMuted}
shouldAutoFocus={nameAutoFocusEnabled && visible && !loading && repos.length > 0}
returnKeyType="done"
onSubmitEditing={() => {
if (canCreate) {
void handleCreate()
}
}}
/>
</View>
<View style={styles.field}>
<Text style={styles.label}>Agent</Text>
<Pressable
@@ -792,7 +936,7 @@ function NewWorktreeModalContent({
disabled={sshGate.requiresConnection}
onPress={() => {
prepareSelectionPickerOpen()
setShowAgentPicker(true)
transitionDrawer('agent')
}}
>
<MobileAgentIcon agentId={selectedAgent.id} size={16} />
@@ -814,6 +958,11 @@ function NewWorktreeModalContent({
{showAdvanced && (
<>
<SmartWorkspaceAdvancedFields
composer={composer}
selectedRepoIsGit={selectedRepoIsGit}
/>
<View style={styles.field}>
<Text style={styles.label}>Note</Text>
<TextInput
@@ -903,22 +1052,39 @@ function NewWorktreeModalContent({
)}
</BottomDrawer>
{/* Sub-modals for pickers — rendered outside the main modal so they
layer on top and scroll without touch conflicts. */}
{/* Why: list drawers stay outside the form's ScrollView, and the transition
state lets each hosted overlay finish hiding before the next appears. */}
<SmartWorkspaceSourceDrawer
visible={visible && drawerView === 'source'}
client={client}
composer={composer}
availability={sourceAvailability}
repoId={selectedRepo?.id ?? null}
repos={pasteRepos}
sshReady={!sshGate.requiresConnection}
onRepoChange={(repoId) => {
const nextRepo = repos.find((repo) => repo.id === repoId)
if (nextRepo) {
setSelectedRepo(nextRepo)
}
}}
onClose={() => transitionDrawer('form')}
/>
<PickerListDrawer
visible={visible && showRepoPicker}
visible={visible && drawerView === 'repo'}
title="Repository"
items={repoPickerItems}
selectedId={selectedRepo?.id ?? ''}
onSelect={(item) => setSelectedRepo(item.repo)}
onClose={() => setShowRepoPicker(false)}
onSelect={(item) => handleRepoSelected(item.repo)}
onClose={() => transitionDrawer('form')}
renderIcon={(item) => {
return <View style={[styles.repoDot, { backgroundColor: repoBadgeColor(item.repo) }]} />
}}
/>
<PickerListDrawer
visible={visible && showAgentPicker}
visible={visible && drawerView === 'agent'}
title="Agent"
items={pickerAgentOptions}
selectedId={selectedAgent.id}
@@ -926,105 +1092,20 @@ function NewWorktreeModalContent({
setAgentOverridden(true)
setSelectedAgent(agent)
}}
onClose={() => setShowAgentPicker(false)}
onClose={() => transitionDrawer('form')}
renderIcon={(agent) => <MobileAgentIcon agentId={agent.id} size={18} />}
/>
<BottomDrawer
visible={visible && setupTrustPrompt != null}
onClose={() => setSetupTrustPrompt(null)}
>
{setupTrustPrompt ? (
<View>
<View style={styles.trustHeader}>
<Text style={styles.title}>
{setupTrustPrompt.previouslyApproved
? `${setupTrustPrompt.repoName}'s setup script changed`
: `Run setup from ${setupTrustPrompt.repoName}?`}
</Text>
<Text style={styles.subtitle}>
This repository's orca.yaml runs before the workspace starts. Only run it if you
trust this repository.
</Text>
</View>
<View style={styles.trustScriptBox}>
<Text style={styles.trustScriptLabel}>
{setupTrustPrompt.previouslyApproved ? 'New setup script' : 'Setup script'}
</Text>
<Text style={styles.trustScriptText}>{setupTrustPrompt.scriptContent}</Text>
</View>
<View style={styles.trustActionGroup}>
<Pressable
style={styles.trustActionRow}
disabled={creating}
onPress={() =>
void (async () => {
try {
await persistSetupHookTrust(
setupTrustPrompt.repoId,
setupTrustPrompt.contentHash,
false
)
const approvedHash = setupTrustPrompt.contentHash
setSetupTrustPrompt(null)
await handleCreate({
setupOverride: 'run',
approvedSetupContentHash: approvedHash
})
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to trust setup script.')
}
})()
}
>
<Check size={16} color={colors.textPrimary} />
<Text style={styles.trustActionText}>Run hooks</Text>
</Pressable>
<View style={styles.trustActionSeparator} />
<Pressable
style={styles.trustActionRow}
disabled={creating}
onPress={() =>
void (async () => {
try {
await persistSetupHookTrust(
setupTrustPrompt.repoId,
setupTrustPrompt.contentHash,
true
)
const approvedHash = setupTrustPrompt.contentHash
setSetupTrustPrompt(null)
await handleCreate({
setupOverride: 'run',
approvedSetupContentHash: approvedHash
})
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to trust setup script.')
}
})()
}
>
<Check size={16} color={colors.textPrimary} />
<Text style={styles.trustActionText}>Always trust and run</Text>
</Pressable>
<View style={styles.trustActionSeparator} />
<Pressable
style={styles.trustActionRow}
disabled={creating}
onPress={() => {
setSetupTrustPrompt(null)
void handleCreate({ setupOverride: 'skip' })
}}
>
<Text style={styles.trustActionText}>Don't run</Text>
</Pressable>
</View>
</View>
) : null}
</BottomDrawer>
</>
<SetupHookTrustDrawer
visible={visible && drawerView === 'trust' && setupTrustPrompt != null}
prompt={setupTrustPrompt}
busy={creating}
onRunOnce={() => void approveSetupTrust(false)}
onAlwaysTrust={() => void approveSetupTrust(true)}
onDontRun={skipSetupTrust}
onClose={closeSetupTrust}
/>
</BottomDrawerModalHost>
)
}
@@ -1164,6 +1245,12 @@ const styles = StyleSheet.create({
fontSize: 13,
marginBottom: spacing.md
},
sourceWarning: {
marginTop: -spacing.sm,
marginBottom: spacing.md,
fontSize: 12,
color: colors.statusAmber
},
advancedToggle: {
flexDirection: 'row',
alignItems: 'center',
@@ -1247,52 +1334,6 @@ const styles = StyleSheet.create({
fontFamily: typography.monoFamily,
color: colors.textPrimary
},
trustHeader: {
paddingHorizontal: spacing.xs,
marginBottom: spacing.md
},
trustScriptBox: {
backgroundColor: colors.bgRaised,
borderRadius: radii.input,
borderWidth: 1,
borderColor: colors.borderSubtle,
padding: spacing.md,
marginBottom: spacing.md
},
trustScriptLabel: {
fontSize: 12,
fontWeight: '600',
color: colors.textSecondary,
marginBottom: spacing.sm
},
trustScriptText: {
fontSize: 13,
fontFamily: typography.monoFamily,
color: colors.textPrimary
},
trustActionGroup: {
backgroundColor: colors.bgPanel,
borderRadius: radii.input,
overflow: 'hidden'
},
trustActionRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingVertical: spacing.md,
paddingHorizontal: spacing.md
},
trustActionText: {
flex: 1,
fontSize: typography.bodySize,
color: colors.textPrimary,
fontWeight: '500'
},
trustActionSeparator: {
height: StyleSheet.hairlineWidth,
backgroundColor: colors.borderSubtle,
marginHorizontal: spacing.md
},
actions: {
flexDirection: 'row',
justifyContent: 'flex-end',
@@ -12,6 +12,7 @@ type Props = {
client: RpcClient | null
hostId?: string
existingWorktreePaths?: readonly string[]
existingWorktrees?: readonly { repoId: string; branch: string }[]
onVisibleChange?: (visible: boolean) => void
onRouteVisibleChange: (visible: boolean) => void
onCreated: (worktreeId: string, name: string) => void
@@ -24,6 +25,7 @@ export const NewWorktreeModalController = forwardRef<NewWorktreeModalControllerH
client,
hostId,
existingWorktreePaths,
existingWorktrees,
onVisibleChange,
onRouteVisibleChange,
onCreated
@@ -58,6 +60,7 @@ export const NewWorktreeModalController = forwardRef<NewWorktreeModalControllerH
client={client}
hostId={hostId}
existingWorktreePaths={existingWorktreePaths}
existingWorktrees={existingWorktrees}
onCreated={onCreated}
onClose={close}
/>
@@ -0,0 +1,137 @@
import { Pressable, StyleSheet, Text, View } from 'react-native'
import { Check } from 'lucide-react-native'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import { BottomDrawer } from './BottomDrawer'
export type SetupTrustPrompt = {
repoId: string
repoName: string
scriptContent: string
contentHash: string
previouslyApproved: boolean
}
type Props = {
visible: boolean
prompt: SetupTrustPrompt | null
busy: boolean
onRunOnce: () => void
onAlwaysTrust: () => void
onDontRun: () => void
onClose: () => void
}
// The repo-owned orca.yaml setup-hook trust prompt, shown before a workspace
// create that would run an untrusted setup script. Extracted from NewWorktreeModal
// to keep that file focused; the async persist/create logic stays with the caller.
export function SetupHookTrustDrawer({
visible,
prompt,
busy,
onRunOnce,
onAlwaysTrust,
onDontRun,
onClose
}: Props) {
return (
<BottomDrawer visible={visible && prompt != null} onClose={onClose}>
{prompt ? (
<View>
<View style={styles.trustHeader}>
<Text style={styles.title}>
{prompt.previouslyApproved
? `${prompt.repoName}'s setup script changed`
: `Run setup from ${prompt.repoName}?`}
</Text>
<Text style={styles.subtitle}>
This repository's orca.yaml runs before the workspace starts. Only run it if you trust
this repository.
</Text>
</View>
<View style={styles.trustScriptBox}>
<Text style={styles.trustScriptLabel}>
{prompt.previouslyApproved ? 'New setup script' : 'Setup script'}
</Text>
<Text style={styles.trustScriptText}>{prompt.scriptContent}</Text>
</View>
<View style={styles.trustActionGroup}>
<Pressable style={styles.trustActionRow} disabled={busy} onPress={onRunOnce}>
<Check size={16} color={colors.textPrimary} />
<Text style={styles.trustActionText}>Run hooks</Text>
</Pressable>
<View style={styles.trustActionSeparator} />
<Pressable style={styles.trustActionRow} disabled={busy} onPress={onAlwaysTrust}>
<Check size={16} color={colors.textPrimary} />
<Text style={styles.trustActionText}>Always trust and run</Text>
</Pressable>
<View style={styles.trustActionSeparator} />
<Pressable style={styles.trustActionRow} disabled={busy} onPress={onDontRun}>
<Text style={styles.trustActionText}>Don't run</Text>
</Pressable>
</View>
</View>
) : null}
</BottomDrawer>
)
}
const styles = StyleSheet.create({
title: {
fontSize: 15,
fontWeight: '600',
color: colors.textPrimary
},
subtitle: {
fontSize: 13,
color: colors.textMuted,
marginTop: 2
},
trustHeader: {
paddingHorizontal: spacing.xs,
marginBottom: spacing.md
},
trustScriptBox: {
backgroundColor: colors.bgRaised,
borderRadius: radii.input,
borderWidth: 1,
borderColor: colors.borderSubtle,
padding: spacing.md,
marginBottom: spacing.md
},
trustScriptLabel: {
fontSize: 12,
fontWeight: '600',
color: colors.textSecondary,
marginBottom: spacing.sm
},
trustScriptText: {
fontSize: 13,
fontFamily: typography.monoFamily,
color: colors.textPrimary
},
trustActionGroup: {
backgroundColor: colors.bgPanel,
borderRadius: radii.input,
overflow: 'hidden'
},
trustActionRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingVertical: spacing.md,
paddingHorizontal: spacing.md
},
trustActionText: {
flex: 1,
fontSize: typography.bodySize,
color: colors.textPrimary,
fontWeight: '500'
},
trustActionSeparator: {
height: StyleSheet.hairlineWidth,
backgroundColor: colors.borderSubtle,
marginHorizontal: spacing.md
}
})
@@ -0,0 +1,18 @@
import { CaseSensitive, GitBranch, Sparkles } from 'lucide-react-native'
import type { SmartModeIcon } from '../tasks/mobile-smart-source-modes'
import { TaskProviderLogo } from './TaskProviderLogo'
// Renders a Smart-mode tab icon: the inline brand SVGs for provider modes,
// lucide glyphs for the neutral modes.
export function SmartSourceModeIcon({ icon, color }: { icon: SmartModeIcon; color: string }) {
if (icon.type === 'provider') {
return <TaskProviderLogo provider={icon.provider} size={14} color={color} />
}
if (icon.name === 'sparkles') {
return <Sparkles size={14} color={color} />
}
if (icon.name === 'git-branch') {
return <GitBranch size={14} color={color} />
}
return <CaseSensitive size={14} color={color} />
}
@@ -0,0 +1,102 @@
import { Platform, StyleSheet, Switch, Text, TextInput, View } from 'react-native'
import type { MobileComposerSource } from '../tasks/use-mobile-composer-source'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
type Props = {
composer: MobileComposerSource
selectedRepoIsGit: boolean
}
// The Advanced-section source controls: the editable Name appears once a source
// pill is shown (the field itself is no longer the name input); the branch-name
// override and reuse toggle mirror the desktop composer's advanced branch fields.
export function SmartWorkspaceAdvancedFields({ composer, selectedRepoIsGit }: Props) {
const selection = composer.smartNameSelection
const showBranchOverride = selectedRepoIsGit && (!selection || selection.kind === 'branch')
return (
<>
{selection ? (
<View style={styles.field}>
<Text style={styles.label}>Name</Text>
<TextInput
style={styles.input}
value={composer.name}
onChangeText={composer.setName}
placeholder="Workspace name"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
/>
</View>
) : null}
{showBranchOverride ? (
<View style={styles.field}>
<Text style={styles.label}>Branch name</Text>
<TextInput
style={styles.input}
value={composer.branchNameOverride ?? ''}
onChangeText={composer.handleBranchNameOverrideChange}
placeholder="Derived from name"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
/>
</View>
) : null}
{composer.reuseEligibleBranch ? (
<View style={styles.field}>
<View style={styles.reuseRow}>
<Text style={styles.reuseLabel} numberOfLines={1}>
Reuse branch “{composer.reuseEligibleBranch}”
</Text>
<Switch
value={composer.reuseSelectedBranch}
onValueChange={composer.setReuseSelectedBranch}
trackColor={{ false: colors.borderSubtle, true: colors.textSecondary }}
thumbColor={colors.textPrimary}
style={styles.reuseSwitch}
/>
</View>
</View>
) : null}
</>
)
}
const styles = StyleSheet.create({
field: {
marginBottom: spacing.md
},
label: {
fontSize: 13,
fontWeight: '500',
color: colors.textSecondary,
marginBottom: spacing.xs
},
input: {
backgroundColor: colors.bgRaised,
color: colors.textPrimary,
borderRadius: radii.input,
paddingHorizontal: spacing.md,
paddingVertical: Platform.OS === 'ios' ? spacing.sm + 2 : spacing.sm,
fontSize: typography.bodySize,
borderWidth: 1,
borderColor: colors.borderSubtle
},
reuseRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
gap: spacing.sm
},
reuseLabel: {
flex: 1,
fontSize: 13,
color: colors.textSecondary
},
reuseSwitch: {
transform: [{ scaleX: 0.7 }, { scaleY: 0.7 }]
}
})
@@ -0,0 +1,425 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import {
ActivityIndicator,
FlatList,
Pressable,
StyleSheet,
Text,
TextInput,
View
} from 'react-native'
import type { RpcClient } from '../transport/rpc-client'
import type { SmartWorkspaceSourceRow as SourceRow } from '../../../src/shared/new-workspace/smart-workspace-source-results'
import {
MR_STATE_FILTER_OPTIONS,
resolveAvailableSmartModes,
resolveDefaultSmartMode,
SMART_MODE_OPTIONS,
type SmartModeAvailabilityInput,
type SmartModeOption
} from '../tasks/mobile-smart-source-modes'
import type { MrStateFilter, SmartNameMode } from '../tasks/mobile-composer-source-types'
import {
lookupGitHubItemByOwnerRepo,
type PasteRepoCandidate
} from '../tasks/smart-source-paste-intent'
import { useSmartWorkspaceSource } from '../tasks/use-smart-workspace-source'
import type { MobileComposerSource } from '../tasks/use-mobile-composer-source'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import { BottomDrawer, BOTTOM_DRAWER_HIDE_DURATION_MS } from './BottomDrawer'
import { SmartSourceModeIcon } from './SmartSourceModeIcon'
import { SmartWorkspaceSourceRow } from './SmartWorkspaceSourceRow'
type Props = {
visible: boolean
client: RpcClient | null
composer: MobileComposerSource
availability: SmartModeAvailabilityInput
repoId: string | null
repos: readonly PasteRepoCandidate[]
linearWorkspaceId?: string | null
sshReady: boolean
onRepoChange: (repoId: string) => void
onClose: () => void
}
export function SmartWorkspaceSourceDrawer({
visible,
client,
composer,
availability,
repoId,
repos,
linearWorkspaceId,
sshReady,
onRepoChange,
onClose
}: Props) {
const availableModes = useMemo(() => resolveAvailableSmartModes(availability), [availability])
const [mode, setMode] = useState<SmartNameMode>(() => resolveDefaultSmartMode(availability))
const [mrStateFilter, setMrStateFilter] = useState<MrStateFilter>('opened')
// Why: read latest availability inside the open effect without making it a
// reactive dep (the object is recreated each render), so re-seeding happens
// only on open, not on every availability recompute.
const availabilityRef = useRef(availability)
availabilityRef.current = availability
// Reset to the default mode each time the drawer opens.
useEffect(() => {
if (visible) {
setMode(resolveDefaultSmartMode(availabilityRef.current))
}
}, [visible])
// Snap the chosen mode back into the available set if availability changes.
const effectiveMode = availableModes.includes(mode) ? mode : (availableModes[0] ?? 'text')
// Linear searches without a repo; every other provider/branch search needs a
// connected repo-backed target.
const searchEnabled = visible && (effectiveMode === 'linear' || sshReady)
const {
rows,
loading,
error,
needsGitHubRemote,
emptyHint,
crossRepoPrompt,
dismissCrossRepoPrompt
} = useSmartWorkspaceSource({
client,
enabled: searchEnabled,
mode: effectiveMode,
query: composer.name,
repoId,
githubAvailable: availability.githubAvailable,
gitlabAvailable: availability.gitlabAvailable,
linearAvailable: availability.linearAvailable,
mrStateFilter,
linearWorkspaceId,
repos
})
function closeSoon(): void {
setTimeout(onClose, BOTTOM_DRAWER_HIDE_DURATION_MS)
}
function handleSelectRow(row: SourceRow): void {
switch (row.kind) {
case 'use-name':
composer.setName(row.name)
break
case 'create-branch':
composer.handleSmartCreateBranch(row.name)
break
case 'github':
composer.handleSmartGitHubItemSelect(row.item)
break
case 'gitlab':
composer.handleSmartGitLabItemSelect(row.item)
break
case 'branch':
composer.handleSmartBranchSelect(row.refName, row.localBranchName)
break
case 'linear':
composer.handleSmartLinearIssueSelect(row.issue)
break
}
onClose()
}
async function handleAcceptCrossRepo(): Promise<void> {
if (!client || !crossRepoPrompt) {
return
}
const { link, matchingRepo } = crossRepoPrompt
try {
const item = await lookupGitHubItemByOwnerRepo(
client,
matchingRepo.id,
link.slug,
link.number,
link.type
)
if (item) {
onRepoChange(matchingRepo.id)
composer.handleSmartGitHubItemSelect(item)
onClose()
}
} catch {
dismissCrossRepoPrompt()
}
}
const showEmpty =
!loading && !error && !needsGitHubRemote && effectiveMode !== 'text' && rows.length === 0
return (
<BottomDrawer
visible={visible}
onClose={onClose}
dragContentToDismiss={false}
contentScrollable={false}
>
<View style={styles.header}>
<Text style={styles.title}>Name or 'Create From'</Text>
<Pressable onPress={closeSoon} hitSlop={8}>
<Text style={styles.done}>Done</Text>
</Pressable>
</View>
<TextInput
style={styles.search}
value={composer.name}
onChangeText={composer.setName}
placeholder="Type a name or search a source"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
autoFocus
/>
<View style={styles.tabRow}>
{SMART_MODE_OPTIONS.filter((option: SmartModeOption) =>
availableModes.includes(option.id)
).map((option) => {
const selected = option.id === effectiveMode
const tint = selected ? colors.textPrimary : colors.textSecondary
return (
<Pressable
key={option.id}
style={[styles.tab, selected && styles.tabSelected]}
onPress={() => setMode(option.id)}
>
<SmartSourceModeIcon icon={option.icon} color={tint} />
<Text style={[styles.tabText, selected && styles.tabTextSelected]}>
{option.label}
</Text>
</Pressable>
)
})}
</View>
{effectiveMode === 'gitlab' ? (
<View style={styles.chipRow}>
{MR_STATE_FILTER_OPTIONS.map((option) => {
const selected = option.id === mrStateFilter
return (
<Pressable
key={option.id}
style={[styles.chip, selected && styles.chipSelected]}
onPress={() => setMrStateFilter(option.id)}
>
<Text style={[styles.chipText, selected && styles.chipTextSelected]}>
{option.label}
</Text>
</Pressable>
)
})}
</View>
) : null}
{crossRepoPrompt ? (
<View style={styles.crossRepo}>
<Text style={styles.crossRepoText}>
This item lives in {crossRepoPrompt.link.slug.owner}/{crossRepoPrompt.link.slug.repo}.
</Text>
<View style={styles.crossRepoActions}>
<Pressable style={styles.crossRepoDismiss} onPress={dismissCrossRepoPrompt}>
<Text style={styles.crossRepoDismissText}>Cancel</Text>
</Pressable>
<Pressable style={styles.crossRepoSwitch} onPress={() => void handleAcceptCrossRepo()}>
<Text style={styles.crossRepoSwitchText}>
Switch to {crossRepoPrompt.matchingRepo.displayName}
</Text>
</Pressable>
</View>
</View>
) : null}
{!sshReady && effectiveMode !== 'text' && effectiveMode !== 'linear' ? (
<Text style={styles.notice}>Connect the repository to search sources.</Text>
) : needsGitHubRemote ? (
<Text style={styles.notice}>
This SSH repo needs a GitHub remote to list issues and PRs.
</Text>
) : error ? (
<Text style={styles.errorNotice}>{error}</Text>
) : null}
<FlatList
data={rows}
keyExtractor={(row) => row.value}
style={styles.list}
keyboardShouldPersistTaps="handled"
nestedScrollEnabled
ListFooterComponent={
loading ? (
<View style={styles.loading}>
<ActivityIndicator size="small" color={colors.textSecondary} />
</View>
) : showEmpty ? (
<Text style={styles.empty}>{emptyHint || 'No results found.'}</Text>
) : null
}
renderItem={({ item }) => (
<SmartWorkspaceSourceRow row={item} onPress={() => handleSelectRow(item)} />
)}
/>
</BottomDrawer>
)
}
const styles = StyleSheet.create({
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.xs,
paddingBottom: spacing.sm
},
title: {
fontSize: 15,
fontWeight: '600',
color: colors.textPrimary
},
done: {
fontSize: typography.bodySize,
fontWeight: '600',
color: colors.accentBlue
},
search: {
backgroundColor: colors.bgRaised,
color: colors.textPrimary,
borderRadius: radii.input,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
fontSize: typography.bodySize,
borderWidth: 1,
borderColor: colors.borderSubtle,
marginBottom: spacing.sm
},
tabRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: spacing.xs,
marginBottom: spacing.sm
},
tab: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
paddingHorizontal: spacing.sm + 2,
paddingVertical: spacing.xs + 2,
borderRadius: radii.button,
borderWidth: 1,
borderColor: colors.borderSubtle
},
tabSelected: {
backgroundColor: colors.bgPanel,
borderColor: colors.textSecondary
},
tabText: {
fontSize: 13,
color: colors.textSecondary
},
tabTextSelected: {
color: colors.textPrimary,
fontWeight: '600'
},
chipRow: {
flexDirection: 'row',
gap: spacing.xs,
marginBottom: spacing.sm
},
chip: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs,
borderRadius: radii.button,
borderWidth: 1,
borderColor: colors.borderSubtle
},
chipSelected: {
backgroundColor: colors.bgPanel,
borderColor: colors.textSecondary
},
chipText: {
fontSize: 12,
color: colors.textSecondary
},
chipTextSelected: {
color: colors.textPrimary,
fontWeight: '600'
},
crossRepo: {
backgroundColor: colors.bgRaised,
borderRadius: radii.input,
borderWidth: 1,
borderColor: colors.borderSubtle,
padding: spacing.md,
marginBottom: spacing.sm,
gap: spacing.sm
},
crossRepoText: {
fontSize: 13,
color: colors.textSecondary
},
crossRepoActions: {
flexDirection: 'row',
justifyContent: 'flex-end',
gap: spacing.sm
},
crossRepoDismiss: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs + 2,
borderRadius: radii.button,
borderWidth: 1,
borderColor: colors.borderSubtle
},
crossRepoDismissText: {
fontSize: 13,
color: colors.textSecondary
},
crossRepoSwitch: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs + 2,
borderRadius: radii.button,
backgroundColor: colors.bgPanel,
borderWidth: 1,
borderColor: colors.textSecondary
},
crossRepoSwitchText: {
fontSize: 13,
fontWeight: '600',
color: colors.textPrimary
},
notice: {
fontSize: 12,
color: colors.textMuted,
paddingHorizontal: spacing.xs,
paddingBottom: spacing.sm
},
errorNotice: {
fontSize: 12,
color: colors.statusRed,
paddingHorizontal: spacing.xs,
paddingBottom: spacing.sm
},
list: {
backgroundColor: colors.bgPanel,
borderRadius: radii.card,
overflow: 'hidden',
maxHeight: 420,
flexGrow: 0
},
loading: {
paddingVertical: spacing.lg,
alignItems: 'center'
},
empty: {
paddingVertical: spacing.lg,
textAlign: 'center',
color: colors.textMuted,
fontSize: 13
}
})
@@ -0,0 +1,145 @@
import { Linking, Pressable, StyleSheet, Text, View } from 'react-native'
import {
CircleDot,
ExternalLink,
GitBranch,
GitMerge,
GitPullRequest,
X
} from 'lucide-react-native'
import type { SmartNameSelection } from '../tasks/mobile-composer-source-types'
import type { MobileComposerSource } from '../tasks/use-mobile-composer-source'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import { TaskProviderLogo } from './TaskProviderLogo'
type Props = {
composer: MobileComposerSource
label: string
disabled?: boolean
onBeforeOpen?: () => void
onOpenDrawer: () => void
}
function SelectionIcon({ kind }: { kind: SmartNameSelection['kind'] }) {
if (kind === 'github-pr') {
return <GitPullRequest size={15} color={colors.textSecondary} />
}
if (kind === 'gitlab-mr') {
return <GitMerge size={15} color={colors.textSecondary} />
}
if (kind === 'github-issue' || kind === 'gitlab-issue') {
return <CircleDot size={15} color={colors.textSecondary} />
}
if (kind === 'branch') {
return <GitBranch size={15} color={colors.textSecondary} />
}
return <TaskProviderLogo provider="linear" size={15} color={colors.textSecondary} />
}
export function SmartWorkspaceSourceField({
composer,
label,
disabled,
onBeforeOpen,
onOpenDrawer
}: Props) {
const selection = composer.smartNameSelection
function openDrawer(): void {
if (disabled) {
return
}
onBeforeOpen?.()
onOpenDrawer()
}
return (
<View style={styles.field}>
<Text style={styles.label}>
{label} <Text style={styles.labelHint}>[Optional]</Text>
</Text>
{selection ? (
<View style={styles.pill}>
<SelectionIcon kind={selection.kind} />
<Text style={styles.pillLabel} numberOfLines={1}>
{selection.label}
</Text>
{selection.url ? (
<Pressable
hitSlop={6}
onPress={() => selection.url && void Linking.openURL(selection.url).catch(() => {})}
>
<ExternalLink size={15} color={colors.textMuted} />
</Pressable>
) : null}
<Pressable hitSlop={6} onPress={composer.handleClearSmartNameSelection}>
<X size={15} color={colors.textMuted} />
</Pressable>
</View>
) : (
<Pressable
style={[styles.input, disabled && styles.disabled]}
disabled={disabled}
onPress={openDrawer}
>
<Text
style={[styles.inputText, !composer.name && styles.inputPlaceholder]}
numberOfLines={1}
>
{composer.name || 'Type a name or search a source'}
</Text>
</Pressable>
)}
</View>
)
}
const styles = StyleSheet.create({
field: {
marginBottom: spacing.md
},
label: {
fontSize: 13,
fontWeight: '500',
color: colors.textSecondary,
marginBottom: spacing.xs
},
labelHint: {
fontWeight: '400',
color: colors.textMuted
},
input: {
backgroundColor: colors.bgRaised,
borderRadius: radii.input,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm + 2,
borderWidth: 1,
borderColor: colors.borderSubtle
},
disabled: {
opacity: 0.55
},
inputText: {
fontSize: typography.bodySize,
color: colors.textPrimary
},
inputPlaceholder: {
color: colors.textMuted
},
pill: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
backgroundColor: colors.bgRaised,
borderRadius: radii.input,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderWidth: 1,
borderColor: colors.borderSubtle
},
pillLabel: {
flex: 1,
fontSize: typography.bodySize,
color: colors.textPrimary
}
})
@@ -0,0 +1,134 @@
import { Pressable, StyleSheet, Text, View } from 'react-native'
import { CaseSensitive, GitBranch, Sparkles } from 'lucide-react-native'
import type { SmartWorkspaceSourceRow as SourceRow } from '../../../src/shared/new-workspace/smart-workspace-source-results'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import { TaskProviderLogo } from './TaskProviderLogo'
type Props = {
row: SourceRow
onPress: () => void
}
type RowContent = {
icon: React.ReactNode
title: string
subtitle?: string
status?: string
}
function resolveRowContent(row: SourceRow): RowContent {
switch (row.kind) {
case 'use-name':
return {
icon: <Sparkles size={16} color={colors.textSecondary} />,
title: `Use "${row.name}"`,
subtitle: 'Name this workspace'
}
case 'create-branch':
return {
icon: <GitBranch size={16} color={colors.accentBlue} />,
title: `Create branch "${row.name}"`,
subtitle: 'New branch'
}
case 'github':
return {
icon: <TaskProviderLogo provider="github" size={16} color={colors.textSecondary} />,
title: row.item.title,
subtitle: `${row.item.type === 'pr' ? 'PR #' : 'Issue #'}${row.item.number}`,
status: row.item.state
}
case 'gitlab':
return {
icon: <TaskProviderLogo provider="gitlab" size={16} color={colors.textSecondary} />,
title: row.item.title,
subtitle: `${row.item.type === 'mr' ? 'MR !' : 'Issue #'}${row.item.number}`,
status: row.item.state
}
case 'branch':
return {
icon: <GitBranch size={16} color={colors.textSecondary} />,
title: row.localBranchName || row.refName,
subtitle: row.refName
}
case 'linear':
return {
icon: <TaskProviderLogo provider="linear" size={16} color={colors.textSecondary} />,
title: row.issue.title,
subtitle: `${row.issue.identifier} · ${row.issue.team?.key ?? 'Linear'}`,
status: row.issue.state?.name
}
default:
return { icon: <CaseSensitive size={16} color={colors.textSecondary} />, title: '' }
}
}
export function SmartWorkspaceSourceRow({ row, onPress }: Props) {
const content = resolveRowContent(row)
return (
<Pressable
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={onPress}
>
<View style={styles.icon}>{content.icon}</View>
<View style={styles.copy}>
<Text style={styles.title} numberOfLines={1}>
{content.title}
</Text>
{content.subtitle ? (
<Text style={styles.subtitle} numberOfLines={1}>
{content.subtitle}
</Text>
) : null}
</View>
{content.status ? (
<View style={styles.pill}>
<Text style={styles.pillText} numberOfLines={1}>
{content.status}
</Text>
</View>
) : null}
</Pressable>
)
}
const styles = StyleSheet.create({
row: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingVertical: spacing.md,
paddingHorizontal: spacing.md + 2
},
rowPressed: {
backgroundColor: colors.bgRaised
},
icon: {
width: 18,
alignItems: 'center'
},
copy: {
flex: 1,
minWidth: 0
},
title: {
fontSize: typography.bodySize,
color: colors.textPrimary
},
subtitle: {
fontSize: 12,
color: colors.textMuted,
marginTop: 1
},
pill: {
backgroundColor: colors.bgRaised,
borderRadius: radii.button,
paddingHorizontal: spacing.sm,
paddingVertical: 2
},
pillText: {
fontSize: 11,
fontWeight: '600',
color: colors.textSecondary,
textTransform: 'capitalize'
}
})
@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'
import {
getInactiveProviderUsage,
getUsageBarState,
getWindowResetLabel,
hasActiveProviderUsage,
hasRenderableUsage,
type AccountsSnapshot,
@@ -117,6 +118,65 @@ describe('getInactiveProviderUsage', () => {
})
})
describe('getWindowResetLabel', () => {
const now = 1_700_000_000_000
const min = 60_000
const hour = 60 * min
const day = 24 * hour
function makeWindow(resetsAt: number | null): ProviderRateLimits['session'] {
return { usedPercent: 13, windowMinutes: 300, resetsAt, resetDescription: null }
}
it('is null when there are no limits or the window has no reset timestamp', () => {
expect(getWindowResetLabel(null, 'session', now)).toBe(null)
expect(getWindowResetLabel(makeLimits({ status: 'ok' }), 'session', now)).toBe(null)
expect(
getWindowResetLabel(makeLimits({ status: 'ok', session: makeWindow(null) }), 'session', now)
).toBe(null)
})
it('formats minutes, hours+minutes, and days+hours like the desktop tooltip', () => {
expect(
getWindowResetLabel(makeLimits({ session: makeWindow(now + 47 * min) }), 'session', now)
).toBe('Resets in 47m')
expect(
getWindowResetLabel(
makeLimits({ session: makeWindow(now + 3 * hour + 54 * min) }),
'session',
now
)
).toBe('Resets in 3h 54m')
expect(
getWindowResetLabel(
makeLimits({ weekly: makeWindow(now + 6 * day + 7 * hour) }),
'weekly',
now
)
).toBe('Resets in 6d 7h')
})
it('formats exact hours and exact days without a zero remainder', () => {
expect(
getWindowResetLabel(makeLimits({ session: makeWindow(now + 2 * hour) }), 'session', now)
).toBe('Resets in 2h')
expect(
getWindowResetLabel(makeLimits({ weekly: makeWindow(now + 7 * day) }), 'weekly', now)
).toBe('Resets in 7d')
})
it('reports "Resets now" for a reset timestamp in the past', () => {
expect(
getWindowResetLabel(makeLimits({ session: makeWindow(now - min) }), 'session', now)
).toBe('Resets now')
})
it('reads the requested window only', () => {
const limits = makeLimits({ session: makeWindow(now + hour) })
expect(getWindowResetLabel(limits, 'weekly', now)).toBe(null)
})
})
describe('getUsageBarState', () => {
it('keeps stale window data visible during a transient error', () => {
const bar = getUsageBarState(
@@ -5,6 +5,8 @@
// Pure state/selectors live here (no React Native imports) so they can be
// unit-tested directly; AccountUsage.tsx re-exports them alongside the
// UsageBar component.
import { formatResetCountdown } from '../../../src/shared/rate-limit-reset-format'
export type RateLimitWindow = {
usedPercent: number
windowMinutes: number
@@ -117,6 +119,27 @@ export function getUsageBarState(
}
}
/**
* Reset countdown for one window, e.g. "Resets in 3h 54m" / "Resets now",
* or null when the window has no reset timestamp (so the UI degrades to
* today's bars-only layout).
*
* Why: shares formatResetCountdown with the desktop status-bar tooltip so the
* copy stays identical across surfaces. `now` is a parameter so the function
* stays pure and unit-testable.
*/
export function getWindowResetLabel(
limits: ProviderRateLimits | null,
windowKey: 'session' | 'weekly',
now: number
): string | null {
const resetsAt = limits?.[windowKey]?.resetsAt
if (resetsAt == null) {
return null
}
return formatResetCountdown(resetsAt - now)
}
// Why: the usage UI must render for the system-default login, not only for
// Orca-managed accounts. Show a provider when it has at least one managed
// account OR active rate-limit data for the system-default target.
@@ -0,0 +1,40 @@
import { createContext, useContext, type ReactNode } from 'react'
import { Modal } from 'react-native'
const BottomDrawerModalHostContext = createContext(false)
/** True when a BottomDrawer is rendered inside a shared BottomDrawerModalHost and
* must therefore skip its own native Modal (the host owns the single Modal). */
export function useInsideBottomDrawerModalHost(): boolean {
return useContext(BottomDrawerModalHostContext)
}
type Props = {
visible: boolean
onRequestClose: () => void
children: ReactNode
}
// Why: iOS cannot reliably dismiss one native modal and present another in the same
// beat. Flows that swap between sibling drawer modals (e.g. the Create Workspace form
// → its repository/agent pickers) dropped the incoming modal, leaving the sheet dead
// to taps. Hosting every drawer in ONE persistent native Modal makes those swaps
// in-window view changes instead, so no present/dismiss race can eat the transition.
export function BottomDrawerModalHost({ visible, onRequestClose, children }: Props) {
if (!visible) {
return null
}
return (
<Modal
visible
transparent
animationType="none"
statusBarTranslucent
onRequestClose={onRequestClose}
>
<BottomDrawerModalHostContext.Provider value={true}>
{children}
</BottomDrawerModalHostContext.Provider>
</Modal>
)
}
@@ -0,0 +1,45 @@
import { existsSync, readFileSync } from 'node:fs'
import path from 'node:path'
import { describe, expect, it } from 'vitest'
import type { TuiAgent } from '../../../src/shared/types'
import { MOBILE_TUI_AGENT_FAVICON_DOMAINS } from '../tasks/mobile-tui-agents'
// Agents that render a hand-authored glyph in MobileAgentIcon and therefore
// never reach the favicon/bundled-asset path.
const GLYPH_AGENT_IDS = new Set<TuiAgent>([
'claude',
'claude-agent-teams',
'codex',
'pi',
'omp',
'aider'
])
const dirname = import.meta.dirname
const assetsDir = path.resolve(dirname, '../../../src/shared/agent-icons')
const assetsModuleSource = readFileSync(path.join(dirname, 'mobile-agent-icon-assets.ts'), 'utf8')
// Why: these agents previously loaded from Google's favicon service, which is
// unreachable in some regions/offline (#8451). Every one that lacks a glyph
// must ship a bundled PNG so the icon renders without a network request.
const agentsNeedingBundledIcon = Object.keys(MOBILE_TUI_AGENT_FAVICON_DOMAINS).filter(
(id): id is TuiAgent => !GLYPH_AGENT_IDS.has(id as TuiAgent)
)
describe('mobile bundled agent icons', () => {
it('ships a bundled PNG on disk for every favicon-path agent', () => {
for (const id of agentsNeedingBundledIcon) {
expect(existsSync(path.join(assetsDir, `${id}.png`)), `missing bundled icon for ${id}`).toBe(
true
)
}
})
it('wires every favicon-path agent into the bundled asset map', () => {
for (const id of agentsNeedingBundledIcon) {
expect(assetsModuleSource, `${id} not mapped in mobile-agent-icon-assets.ts`).toContain(
`agent-icons/${id}.png`
)
}
})
})
@@ -0,0 +1,41 @@
import type { ImageSourcePropType } from 'react-native'
import type { TuiAgent } from '../../../src/shared/types'
// Why: mobile previously rendered these agent icons from Google's favicon
// service, which is unreachable in some regions (e.g. mainland China) and
// offline, leaving broken images in the agent picker (#8451). Bundle each
// favicon so Metro packages it into the app and the icon renders without any
// network request. The PNGs are shared with desktop in src/shared/agent-icons
// (Metro watches src/shared via metro.config.js sharedRoot). Metro requires
// static literal require() paths, so keep this as an explicit map rather than
// building the path from the agent id.
export const MOBILE_AGENT_ICON_ASSETS: Partial<Record<TuiAgent, ImageSourcePropType>> = {
openclaude: require('../../../src/shared/agent-icons/openclaude.png'),
grok: require('../../../src/shared/agent-icons/grok.png'),
copilot: require('../../../src/shared/agent-icons/copilot.png'),
opencode: require('../../../src/shared/agent-icons/opencode.png'),
'mimo-code': require('../../../src/shared/agent-icons/mimo-code.png'),
ante: require('../../../src/shared/agent-icons/ante.png'),
gemini: require('../../../src/shared/agent-icons/gemini.png'),
antigravity: require('../../../src/shared/agent-icons/antigravity.png'),
goose: require('../../../src/shared/agent-icons/goose.png'),
amp: require('../../../src/shared/agent-icons/amp.png'),
kilo: require('../../../src/shared/agent-icons/kilo.png'),
kiro: require('../../../src/shared/agent-icons/kiro.png'),
crush: require('../../../src/shared/agent-icons/crush.png'),
aug: require('../../../src/shared/agent-icons/aug.png'),
autohand: require('../../../src/shared/agent-icons/autohand.png'),
cline: require('../../../src/shared/agent-icons/cline.png'),
codebuff: require('../../../src/shared/agent-icons/codebuff.png'),
'command-code': require('../../../src/shared/agent-icons/command-code.png'),
continue: require('../../../src/shared/agent-icons/continue.png'),
cursor: require('../../../src/shared/agent-icons/cursor.png'),
droid: require('../../../src/shared/agent-icons/droid.png'),
kimi: require('../../../src/shared/agent-icons/kimi.png'),
'mistral-vibe': require('../../../src/shared/agent-icons/mistral-vibe.png'),
'qwen-code': require('../../../src/shared/agent-icons/qwen-code.png'),
rovo: require('../../../src/shared/agent-icons/rovo.png'),
hermes: require('../../../src/shared/agent-icons/hermes.png'),
devin: require('../../../src/shared/agent-icons/devin.png'),
openclaw: require('../../../src/shared/agent-icons/openclaw.png')
}
@@ -0,0 +1,131 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { GitHubWorkItemDetails, PRComment } from '../../../../src/shared/types'
import { PRCommentsSection } from './PRCommentsSection'
vi.mock('react-native', () => ({
ActivityIndicator: 'ActivityIndicator',
Pressable: 'Pressable',
Text: 'Text',
View: 'View'
}))
vi.mock('lucide-react-native', () => ({
ChevronDown: 'ChevronDown',
ChevronRight: 'ChevronRight'
}))
vi.mock('../../session/pr-comment-actions', () => ({
canAddRootComment: () => false
}))
vi.mock('../../session/mobile-pr-sidebar-state', () => ({
isPrSidebarDetailsPlaceholder: () => false
}))
vi.mock('./PRSection', () => ({ PRSection: 'PRSection' }))
vi.mock('./CommentMarkdown', () => ({ CommentMarkdown: 'CommentMarkdown' }))
vi.mock('./PRCommentCard', () => ({ PRCommentCard: 'PRCommentCard' }))
vi.mock('./PRCommentComposer', () => ({ PRCommentComposer: 'PRCommentComposer' }))
vi.mock('./pr-comments-styles', () => ({ prCommentsStyles: {} }))
vi.mock('./mobile-pr-sidebar-styles', () => ({ mobilePrSidebarStyles: {} }))
vi.mock('../../theme/mobile-theme', () => ({ colors: { textSecondary: '#999' } }))
function suppressReactTestRendererDeprecationWarning(): () => void {
const originalConsoleError = console.error
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
const firstArg = args[0]
if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) {
return
}
originalConsoleError(...args)
})
return () => consoleErrorSpy.mockRestore()
}
function comment(id: number): PRComment {
return {
id,
author: 'octocat',
authorAvatarUrl: '',
body: '',
createdAt: '',
url: ''
}
}
function detailsWithComments(count: number): GitHubWorkItemDetails {
return {
item: { id: 'pr:1', type: 'pr' },
body: '',
comments: Array.from({ length: count }, (_, index) => comment(index + 1))
} as GitHubWorkItemDetails
}
async function renderComments(details: GitHubWorkItemDetails): Promise<ReactTestRenderer> {
let renderer: ReactTestRenderer | null = null
const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
try {
await act(async () => {
renderer = create(createElement(PRCommentsSection, { details, prState: 'open' }))
})
} finally {
restoreConsoleError()
}
if (!renderer) {
throw new Error('PRCommentsSection did not render')
}
return renderer
}
function audienceTabs(renderer: ReactTestRenderer) {
return renderer.root
.findAllByType('Pressable')
.filter((node) => node.props.accessibilityState !== undefined)
}
function showMoreButton(renderer: ReactTestRenderer) {
const button = renderer.root
.findAllByType('Pressable')
.find((node) => node.props.accessibilityState === undefined)
if (!button) {
throw new Error('Show more button not found')
}
return button
}
async function press(node: { props: { onPress: () => void } }): Promise<void> {
await act(async () => {
node.props.onPress()
})
}
describe('PRCommentsSection', () => {
let renderer: ReactTestRenderer | null = null
afterEach(() => {
renderer?.unmount()
renderer = null
vi.restoreAllMocks()
})
it('resets pagination only when the user chooses a different audience filter', async () => {
renderer = await renderComments(detailsWithComments(25))
expect(renderer.root.findAllByType('PRCommentCard')).toHaveLength(12)
await press(showMoreButton(renderer))
expect(renderer.root.findAllByType('PRCommentCard')).toHaveLength(24)
// The second tab is Humans; moving from All to Humans resets the page limit.
await press(audienceTabs(renderer)[1])
expect(renderer.root.findAllByType('PRCommentCard')).toHaveLength(12)
await press(showMoreButton(renderer))
expect(renderer.root.findAllByType('PRCommentCard')).toHaveLength(24)
// Retapping the active tab used to leave the page size alone; retain that behavior.
await press(audienceTabs(renderer)[1])
expect(renderer.root.findAllByType('PRCommentCard')).toHaveLength(24)
})
})
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'
import { useMemo, useState } from 'react'
import { ActivityIndicator, Pressable, Text, View } from 'react-native'
import { ChevronDown, ChevronRight } from 'lucide-react-native'
import type { GitHubWorkItemDetails, PRState } from '../../../../src/shared/types'
@@ -39,6 +39,9 @@ type Props = {
// Interactive comment actions (reply/resolve/add/edit/delete). Absent (e.g.
// non-PR) leaves the timeline read-only.
actions?: MobilePrCommentActions
// Author logins manually marked as bots on desktop; keeps the Humans/Bots
// tabs classifying the same comments as the desktop panel.
botAuthorOverrides?: ReadonlySet<string>
}
// Render comments in bounded pages — the whole sidebar is one ScrollView (can't
@@ -49,7 +52,13 @@ const COMMENT_PAGE = 12
// PR body + full comment timeline, mirroring the desktop PR page: a Description
// card, then a Comments section with an audience filter (PRs only), threaded
// review comments, reactions, and collapsible resolved threads.
export function PRCommentsSection({ details, prState, prRepo, actions }: Props) {
export function PRCommentsSection({
details,
prState,
prRepo,
actions,
botAuthorOverrides
}: Props) {
// details is null while phase 2 (the heavy comments/body payload) is still loading.
// A synthetic placeholder means phase 2 failed — do not paint that as empty success.
const loadingDetails = details === null
@@ -83,15 +92,27 @@ export function PRCommentsSection({ details, prState, prRepo, actions }: Props)
const canComment = isPr && actions !== undefined && canAddRootComment(prState)
const [filter, setFilter] = useState<PRCommentAudienceFilter>('all')
const counts = useMemo(() => getPRCommentAudienceCounts(comments), [comments])
const visible = useMemo(() => filterPRCommentsByAudience(comments, filter), [comments, filter])
const counts = useMemo(
() => getPRCommentAudienceCounts(comments, botAuthorOverrides),
[botAuthorOverrides, comments]
)
const visible = useMemo(
() => filterPRCommentsByAudience(comments, filter, botAuthorOverrides),
[botAuthorOverrides, comments, filter]
)
const groups = useMemo(() => groupPRComments(visible), [visible])
// Bounded render window; reset to the first page whenever the filtered set changes.
// Bounded render window; reset to the first page when the user selects another filter.
const [limit, setLimit] = useState(COMMENT_PAGE)
useEffect(() => {
const selectFilter = (nextFilter: PRCommentAudienceFilter): void => {
if (nextFilter === filter) {
return
}
// Why: paging belongs to the filter-tab event, so reset it in the same batch
// instead of briefly rendering the new filter with the previous page size.
setLimit(COMMENT_PAGE)
}, [filter])
setFilter(nextFilter)
}
const shownGroups = groups.slice(0, limit)
const remaining = groups.length - shownGroups.length
@@ -139,7 +160,7 @@ export function PRCommentsSection({ details, prState, prRepo, actions }: Props)
<Pressable
key={tab.value}
style={[styles.audienceTab, active && styles.audienceTabActive]}
onPress={() => setFilter(tab.value)}
onPress={() => selectFilter(tab.value)}
accessibilityRole="button"
accessibilityState={{ selected: active }}
>
@@ -1,4 +1,10 @@
import type { PRComment } from '../../../../src/shared/types'
import {
createBotAuthorOverrideSet,
normalizePRCommentAuthorLogin
} from '../../../../src/shared/pr-bot-author-overrides'
export { createBotAuthorOverrideSet }
// Audience filtering for the PR comment timeline, ported from the desktop helper
// (src/renderer/src/lib/pr-comment-audience.ts) minus its i18n wrapper so it stays
@@ -43,12 +49,18 @@ const KNOWN_AUTOMATION_LOGIN_SUBSTRINGS = [
'-reviewer'
]
export function isBotPRComment(comment: PRComment): boolean {
export function isBotPRComment(
comment: PRComment,
botAuthorOverrides?: ReadonlySet<string>
): boolean {
const author = comment.author.trim()
const normalized = normalizePRCommentAuthorLogin(author)
if (botAuthorOverrides?.has(normalized)) {
return true
}
if (comment.isBot === true) {
return true
}
const author = comment.author.trim()
const normalized = author.toLowerCase()
if (normalized.endsWith(BOT_LOGIN_SUFFIX)) {
return true
}
@@ -59,9 +71,10 @@ export function isBotPRComment(comment: PRComment): boolean {
}
export function getPRCommentAudienceCounts(
comments: PRComment[]
comments: PRComment[],
botAuthorOverrides?: ReadonlySet<string>
): Record<PRCommentAudienceFilter, number> {
const bot = comments.filter(isBotPRComment).length
const bot = comments.filter((comment) => isBotPRComment(comment, botAuthorOverrides)).length
return {
all: comments.length,
human: comments.length - bot,
@@ -71,13 +84,14 @@ export function getPRCommentAudienceCounts(
export function filterPRCommentsByAudience(
comments: PRComment[],
filter: PRCommentAudienceFilter
filter: PRCommentAudienceFilter,
botAuthorOverrides?: ReadonlySet<string>
): PRComment[] {
if (filter === 'bot') {
return comments.filter(isBotPRComment)
return comments.filter((comment) => isBotPRComment(comment, botAuthorOverrides))
}
if (filter === 'human') {
return comments.filter((comment) => !isBotPRComment(comment))
return comments.filter((comment) => !isBotPRComment(comment, botAuthorOverrides))
}
return comments
}
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import type { PRComment } from '../../../../src/shared/types'
import {
createBotAuthorOverrideSet,
filterPRCommentsByAudience,
getPRCommentAudienceCounts,
isBotPRComment
@@ -38,6 +39,15 @@ describe('pr comment audience', () => {
expect(filterPRCommentsByAudience(comments, 'human').map((c) => c.id)).toEqual([1, 3])
expect(filterPRCommentsByAudience(comments, 'all')).toHaveLength(3)
})
it('classifies desktop-marked bot authors via overrides (desktop parity)', () => {
const overrides = createBotAuthorOverrideSet([' GretelFlux '])
const comments = [comment({ id: 1, author: 'alice' }), comment({ id: 2, author: 'gretelflux' })]
expect(isBotPRComment(comments[1])).toBe(false)
expect(isBotPRComment(comments[1], overrides)).toBe(true)
expect(getPRCommentAudienceCounts(comments, overrides)).toEqual({ all: 2, human: 1, bot: 1 })
expect(filterPRCommentsByAudience(comments, 'bot', overrides).map((c) => c.id)).toEqual([2])
})
})
describe('pr comment groups', () => {
@@ -0,0 +1,122 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { MobileFileMarkdownPreview } from './MobileFileMarkdownPreview'
vi.mock('react-native', () => ({
Pressable: 'Pressable',
ScrollView: 'ScrollView',
View: 'View'
}))
vi.mock('lucide-react-native', () => ({
Code: 'Code',
Pencil: 'Pencil'
}))
vi.mock('../components/MobileMarkdown', () => ({
MobileMarkdown: 'MobileMarkdown'
}))
vi.mock('./MobileFilePreviewSourceText', () => ({
MobileFilePreviewSourceText: 'MobileFilePreviewSourceText',
MobileFilePreviewTruncatedNote: 'MobileFilePreviewTruncatedNote'
}))
vi.mock('../theme/mobile-theme', () => ({
colors: { textPrimary: '#fff', textSecondary: '#999' }
}))
vi.mock('./mobile-file-preview-styles', () => ({
filePreviewStyles: {}
}))
type PreviewProps = Parameters<typeof MobileFileMarkdownPreview>[0]
function suppressReactTestRendererDeprecationWarning(): () => void {
const originalConsoleError = console.error
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
const firstArg = args[0]
if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) {
return
}
originalConsoleError(...args)
})
return () => consoleErrorSpy.mockRestore()
}
async function renderPreview(props: PreviewProps): Promise<ReactTestRenderer> {
let renderer: ReactTestRenderer | null = null
const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
try {
await act(async () => {
renderer = create(createElement(MobileFileMarkdownPreview, props))
})
} finally {
restoreConsoleError()
}
if (!renderer) {
throw new Error('MobileFileMarkdownPreview did not render')
}
return renderer
}
async function updatePreview(renderer: ReactTestRenderer, props: PreviewProps): Promise<void> {
await act(async () => {
renderer.update(createElement(MobileFileMarkdownPreview, props))
})
}
function modeToggle(renderer: ReactTestRenderer, label: string) {
const toggle = renderer.root
.findAllByType('Pressable')
.find((node) => node.props.accessibilityLabel === label)
if (!toggle) {
throw new Error(`Missing ${label} toggle`)
}
return toggle
}
async function selectMode(renderer: ReactTestRenderer, label: string): Promise<void> {
await act(async () => {
modeToggle(renderer, label).props.onPress()
})
}
function isSelected(renderer: ReactTestRenderer, label: string): boolean {
return modeToggle(renderer, label).props.accessibilityState.selected === true
}
describe('MobileFileMarkdownPreview', () => {
let renderer: ReactTestRenderer | null = null
afterEach(() => {
renderer?.unmount()
renderer = null
vi.restoreAllMocks()
})
it('resets the selected mode for a new file or line target without remounting the preview', async () => {
const baseProps: PreviewProps = {
relativePath: 'notes/first.md',
content: '# First',
truncated: false,
byteLength: 7
}
renderer = await renderPreview(baseProps)
expect(isSelected(renderer, 'View rendered Markdown preview')).toBe(true)
await selectMode(renderer, 'View Markdown source')
expect(isSelected(renderer, 'View Markdown source')).toBe(true)
// Content updates alone preserve the user's explicitly selected mode.
await updatePreview(renderer, { ...baseProps, content: '# First updated' })
expect(isSelected(renderer, 'View Markdown source')).toBe(true)
await updatePreview(renderer, { ...baseProps, relativePath: 'notes/second.md' })
expect(isSelected(renderer, 'View rendered Markdown preview')).toBe(true)
await updatePreview(renderer, { ...baseProps, relativePath: 'notes/second.md', initialLine: 8 })
expect(isSelected(renderer, 'View Markdown source')).toBe(true)
})
})
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useState } from 'react'
import { Pressable, ScrollView, View } from 'react-native'
import { Code, Pencil } from 'lucide-react-native'
import { MobileMarkdown } from '../components/MobileMarkdown'
@@ -25,9 +25,15 @@ export function MobileFileMarkdownPreview({
initialLine
}: Props) {
const [mode, setMode] = useState<'preview' | 'source'>(() => (initialLine ? 'source' : 'preview'))
useEffect(() => {
const [previousRelativePath, setPreviousRelativePath] = useState(relativePath)
const [previousInitialLine, setPreviousInitialLine] = useState(initialLine)
// Why: opening a different file or line target must switch modes before paint,
// never briefly retain the prior file's manually selected mode.
if (relativePath !== previousRelativePath || initialLine !== previousInitialLine) {
setPreviousRelativePath(relativePath)
setPreviousInitialLine(initialLine)
setMode(initialLine ? 'source' : 'preview')
}, [initialLine, relativePath])
}
const previewSelected = mode === 'preview'
const sourceSelected = mode === 'source'
@@ -4,6 +4,7 @@ import {
setScheduledNotificationsMaxForTests,
subscribeToDesktopNotifications
} from './mobile-notifications'
import AsyncStorage from '@react-native-async-storage/async-storage'
import type { RpcClient } from '../transport/rpc-client'
import { loadPushNotificationsEnabled } from '../storage/preferences'
@@ -20,6 +21,16 @@ vi.mock('react-native', () => ({
Platform: { OS: 'ios' }
}))
// Why: mobile-notifications now persists the catch-up watermark to
// AsyncStorage. The package isn't resolvable in the node test env (other
// mobile tests mock it the same way), so we provide a no-op mock.
vi.mock('@react-native-async-storage/async-storage', () => ({
default: {
getItem: vi.fn(async () => null),
setItem: vi.fn(async () => undefined)
}
}))
vi.mock('../storage/preferences', () => ({
loadPushNotificationsEnabled: vi.fn()
}))
@@ -316,3 +327,288 @@ describe('subscribeToDesktopNotifications', () => {
}
})
})
// Why: #8129 catch-up. On a reconnect the live stream re-emits `ready`; the
// client must fetch missed notifications from its watermark and push exactly
// the ones it had not yet delivered — never re-pushing an already-delivered id.
describe('subscribeToDesktopNotifications — reconnect catch-up', () => {
const AsyncStorageMock = vi.mocked(AsyncStorage)
beforeEach(() => {
vi.clearAllMocks()
AsyncStorageMock.getItem.mockResolvedValue(null)
vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined)
})
function flushAsync(): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, 10)
})
}
function makeClient() {
let onData: ((data: unknown) => void) | null = null
const sentRequests: { method: string; params: unknown }[] = []
const client = {
subscribe: vi.fn((_method: string, _params: unknown, cb: (data: unknown) => void) => {
onData = cb
return vi.fn()
}),
getState: vi.fn(() => 'connected'),
sendRequest: vi.fn(
async (method: string, _params: unknown = {}) =>
({
ok: true,
result: method === 'notifications.getMissedSince' ? { notifications: [] } : undefined
}) as never
)
}
// Why: onData is captured live via a getter (not destructured) because the
// subscribe mock assigns it asynchronously as a side effect of
// subscribeToDesktopNotifications calling client.subscribe.
return {
client: client as unknown as RpcClient,
get onData() {
return onData
},
sentRequests
}
}
it('does not fetch missed notifications on the first (cold-open) ready', async () => {
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
status: 'granted',
canAskAgain: true
} as never)
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('scheduled-1')
const sub = makeClient()
subscribeToDesktopNotifications(sub.client, 'host-1')
// First ready = cold open.
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' })
await flushAsync()
expect(sub.client.sendRequest).not.toHaveBeenCalledWith(
'notifications.getMissedSince',
expect.anything()
)
})
it('fetches only notifications after the delivered watermark (idempotent catch-up)', async () => {
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
status: 'granted',
canAskAgain: true
} as never)
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('scheduled-1')
const sub = makeClient()
// The desktop honours the watermark: only seq 10 (agent:missed) is returned
// because seq 11 (agent:dup) was already delivered on the live stream and
// advanced lastDeliveredSeq to 11. So the replay never re-includes it.
sub.client.sendRequest = vi.fn(async (method: string) => {
if (method === 'notifications.getMissedSince') {
return {
ok: true,
result: {
notifications: [
{
type: 'notification',
title: 'missed',
body: 'b',
notificationId: 'agent:missed',
notificationSeq: 10
}
]
}
} as never
}
return { ok: true, result: undefined } as never
})
subscribeToDesktopNotifications(sub.client, 'host-1')
// First ready = cold open (no fetch).
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' })
await flushAsync()
// Live stream already delivered agent:dup (seq 11) before reap.
sub.onData?.({
type: 'notification',
title: 'dup',
body: 'b',
notificationId: 'agent:dup',
notificationSeq: 11
})
await flushAsync()
// Reconnect ready → fetchMissed sends the watermark (11).
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' })
await flushAsync()
await flushAsync()
// The watermark passed to getMissedSince is the delivered seq.
const missedCall = vi
.mocked(sub.client.sendRequest)
.mock.calls.find((c: unknown[]) => c[0] === 'notifications.getMissedSince')
expect(missedCall?.[1]).toEqual({ lastSeenSeq: 11 })
// Only agent:missed was pushed; agent:dup appears exactly once (live only).
const scheduledIds = vi
.mocked(Notifications.scheduleNotificationAsync)
.mock.calls.map(
(call) =>
(call[0] as { content: { data: { notificationId: string } } }).content.data.notificationId
)
expect(scheduledIds).toEqual(['agent:dup', 'agent:missed'])
expect(scheduledIds.filter((id) => id === 'agent:dup')).toHaveLength(1)
})
it('drops an already-seen id if a replay re-includes it (defense-in-depth)', async () => {
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
status: 'granted',
canAskAgain: true
} as never)
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('s')
const sub = makeClient()
// Simulate the bounded-buffer edge: the desktop returns seq 11 again
// (already delivered live) alongside a new seq 12.
sub.client.sendRequest = vi.fn(async (method: string) => {
if (method === 'notifications.getMissedSince') {
return {
ok: true,
result: {
notifications: [
{
type: 'notification',
title: 'dup',
body: 'b',
notificationId: 'agent:dup',
notificationSeq: 11
},
{
type: 'notification',
title: 'new',
body: 'b',
notificationId: 'agent:new',
notificationSeq: 12
}
]
}
} as never
}
return { ok: true, result: undefined } as never
})
subscribeToDesktopNotifications(sub.client, 'host-1')
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' })
await flushAsync()
// Live stream delivered agent:dup (seq 11) before reap.
sub.onData?.({
type: 'notification',
title: 'dup',
body: 'b',
notificationId: 'agent:dup',
notificationSeq: 11
})
await flushAsync()
// Reconnect replay re-includes seq 11 (must be dropped) + new seq 12.
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' })
await flushAsync()
await flushAsync()
const scheduledIds = vi
.mocked(Notifications.scheduleNotificationAsync)
.mock.calls.map(
(call) =>
(call[0] as { content: { data: { notificationId: string } } }).content.data.notificationId
)
expect(scheduledIds).toEqual(['agent:dup', 'agent:new'])
expect(scheduledIds.filter((id) => id === 'agent:dup')).toHaveLength(1)
})
it('persists the highest delivered seq so a later reconnect resumes from it', async () => {
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
status: 'granted',
canAskAgain: true
} as never)
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('s')
const sub = makeClient()
subscribeToDesktopNotifications(sub.client, 'host-1')
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' })
await flushAsync()
// Live stream delivers seq 5.
sub.onData?.({
type: 'notification',
title: 't',
body: 'b',
notificationId: 'agent:live',
notificationSeq: 5
})
await flushAsync()
expect(AsyncStorageMock.setItem).toHaveBeenCalledWith(
'orca:mobileNotificationsLastSeq:host-1',
'5'
)
})
// Why: a replay-ONLY delivery (nothing arrived live first) must still advance
// and persist the watermark. This is the exact case the seq/notificationSeq
// field mismatch broke — the desktop replay path returns `notificationSeq`
// (matching the live fan-out), so the client watermark moves and the next
// reconnect resumes from it instead of re-fetching from 0.
it('advances + persists the watermark from a replay-only delivery (#8129 field-mismatch regression)', async () => {
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
status: 'granted',
canAskAgain: true
} as never)
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('s')
const sub = makeClient()
// Desktop replay returns events keyed by notificationSeq (the fixed shape).
sub.client.sendRequest = vi.fn(async (method: string) => {
if (method === 'notifications.getMissedSince') {
return {
ok: true,
result: {
notifications: [
{
type: 'notification',
title: 'missed',
body: 'b',
notificationId: 'agent:missed',
notificationSeq: 8
}
]
}
} as never
}
return { ok: true, result: undefined } as never
})
subscribeToDesktopNotifications(sub.client, 'host-1')
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' })
await flushAsync()
// First reconnect → replay delivers seq 8 (no prior live delivery).
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' })
await flushAsync()
await flushAsync()
// Watermark advanced to the replayed seq and was persisted.
expect(AsyncStorageMock.setItem).toHaveBeenCalledWith(
'orca:mobileNotificationsLastSeq:host-1',
'8'
)
// Second reconnect resumes from the advanced watermark, not 0.
sub.onData?.({ type: 'ready', subscriptionId: 'sub-1' })
await flushAsync()
const missedCalls = vi
.mocked(sub.client.sendRequest)
.mock.calls.filter((c: unknown[]) => c[0] === 'notifications.getMissedSince')
expect(missedCalls.at(-1)?.[1]).toEqual({ lastSeenSeq: 8 })
})
})
@@ -3,6 +3,12 @@ import { Platform } from 'react-native'
import type { RpcClient } from '../transport/rpc-client'
import { loadPushNotificationsEnabled } from '../storage/preferences'
import { buildLocalNotificationData, type DesktopNotificationSource } from './notification-routing'
import {
createSeenNotificationGuard,
loadLastSeenSeq,
saveLastSeenSeq,
seenKeyForEvent
} from './notification-reconnect-catchup'
type NotificationEvent = {
type: 'notification'
@@ -11,11 +17,16 @@ type NotificationEvent = {
body: string
worktreeId?: string
notificationId?: string
// Mirrors the desktop-assigned MobileNotificationEvent.notificationSeq used
// for reconnect catch-up (#8129). Optional because older runtimes / non-
// replay events may omit it.
notificationSeq?: number
}
type DismissNotificationEvent = {
type: 'dismiss'
notificationId: string
notificationSeq?: number
}
type SubscribeResult = {
@@ -223,18 +234,105 @@ async function dismissLocalNotification(
// Why: each host connection gets its own notification subscription. When the
// connection drops, the unsubscribe function cleans up the streaming RPC.
// On reconnect the same subscribe stream is re-established by the RPC client;
// we use its `ready` event to trigger catch-up (#8129): fetch notifications
// dispatched while the socket was reaped, watermarked by the last seq we
// already delivered so the desktop never re-sends an already-pushed one.
// Returns an unsubscribe function.
export function subscribeToDesktopNotifications(client: RpcClient, hostId: string): () => void {
configureNotificationChannel()
let subscriptionId: string | null = null
let disposed = false
// Highest seq delivered on the live stream or replay for this connection.
// Persisted per-host so a cold app start still resumes from the right cut.
let lastDeliveredSeq = 0
// Why: per-connection dedup guard applied ONLY to the replay path
// (fetchMissed), never the live stream. The desktop already guarantees the
// replay cannot contain an event with seq <= lastDeliveredSeq (both live and
// replay advance the same watermark), so live + replay never overlap there.
// This set is defense-in-depth: if the desktop's bounded buffer evicted an
// old entry and a reconnect re-fetches across a boundary, an id delivered in
// the same connection isn't pushed twice. Bounded (RECENTLY_SEEN_CAP) so a
// long-lived session can't grow without limit.
const seenReplay = createSeenNotificationGuard()
function deliverLive(
type: 'notification' | 'dismiss',
event: NotificationEvent | DismissNotificationEvent
): Promise<void> {
if (event.notificationSeq != null && event.notificationSeq > lastDeliveredSeq) {
lastDeliveredSeq = event.notificationSeq
void saveLastSeenSeq(hostId, lastDeliveredSeq)
}
// Why (#8129 dedup): mark the event seen on EVERY delivery path (live AND
// replay) so a replay that re-includes an id already pushed live in this
// connection is dropped instead of double-pushed. fetchMissed also
// pre-checks seenReplay, but without this the live path never populated it.
const key = seenKeyForEvent(event)
if (key) {
seenReplay.add(key)
}
if (type === 'notification') {
return showLocalNotification(event as NotificationEvent, hostId)
}
return dismissLocalNotification(event as DismissNotificationEvent, hostId)
}
// Why: on a reconnect `ready` the desktop has already dispatched whatever we
// missed; ask for it from our persisted watermark. Because the desktop cuts
// by seq > lastSeenSeq this is idempotent — we only ever get events we have
// not delivered before. The seenReplay guard is a second layer so a replay
// that somehow re-includes an id already delivered this connection is
// dropped instead of double-pushed.
async function fetchMissed(): Promise<void> {
if (disposed) {
return
}
const missed = await client
.sendRequest('notifications.getMissedSince', { lastSeenSeq: lastDeliveredSeq })
.then((response) => {
if (!response.ok) {
return []
}
const result = response.result as { notifications?: unknown[] } | undefined
return Array.isArray(result?.notifications) ? result.notifications : []
})
.catch(() => [])
for (const raw of missed) {
const event = raw as NotificationEvent | DismissNotificationEvent
const key = seenKeyForEvent(event)
if (key && seenReplay.has(key)) {
continue
}
if (key) {
seenReplay.add(key)
}
if (event.type === 'notification') {
await deliverLive('notification', event)
} else if (event.type === 'dismiss') {
await deliverLive('dismiss', event)
}
}
}
// Why: lazily seed the watermark from durable storage on first use so we
// don't block subscribe() on an AsyncStorage read. The first `ready` (cold
// open) does NOT need catch-up — the live stream starts fresh; only
// subsequent reconnect `ready` events fetch missed notifications.
let watermarkLoaded = false
void loadLastSeenSeq(hostId).then((seq) => {
lastDeliveredSeq = Math.max(lastDeliveredSeq, seq)
watermarkLoaded = true
})
function unsubscribeServer(id: string) {
if (client.getState() === 'connected') {
client.sendRequest('notifications.unsubscribe', { subscriptionId: id }).catch(() => {})
}
}
let reconnectReadyCount = 0
const unsubscribeStream = client.subscribe('notifications.subscribe', {}, (data: unknown) => {
const event = data as
| NotificationEvent
@@ -243,9 +341,18 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin
| { type: 'end' }
if (event.type === 'ready') {
subscriptionId = (event as SubscribeResult).subscriptionId
reconnectReadyCount += 1
if (disposed) {
unsubscribeServer(subscriptionId)
unsubscribeStream()
return
}
// Why: first ready is the cold-open live stream — no catch-up needed.
// Every later ready is a reconnect; fetch what we missed from the
// watermark. Guard on watermarkLoaded so a fast reconnect doesn't
// fetch from a stale 0 watermark (which would re-push everything).
if (reconnectReadyCount > 1 && watermarkLoaded) {
void fetchMissed()
}
return
}
@@ -259,9 +366,9 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin
return
}
if (event.type === 'notification') {
void showLocalNotification(event as NotificationEvent, hostId)
void deliverLive('notification', event as NotificationEvent)
} else if (event.type === 'dismiss') {
void dismissLocalNotification(event as DismissNotificationEvent, hostId)
void deliverLive('dismiss', event as DismissNotificationEvent)
}
})
@@ -0,0 +1,97 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
// Why: the reconnect catch-up watermark + dedup helpers for #8129, extracted
// from mobile-notifications.ts so that file stays under its max-lines budget.
// The highest desktop notification seq this device has delivered is persisted
// per-host so it survives app restarts. On reconnect we send it to
// notifications.getMissedSince as the catch-up watermark — the desktop then
// returns only notifications dispatched after it, so we never re-push a
// notification we already delivered. The in-memory seen-set is a second guard
// against double-delivery for events that arrive on both the live stream and a
// replay (e.g. a brief liveness spell before a reap).
const LAST_SEQ_STORAGE_KEY_PREFIX = 'orca:mobileNotificationsLastSeq:'
function lastSeqStorageKey(hostId: string): string {
return LAST_SEQ_STORAGE_KEY_PREFIX + encodeURIComponent(hostId)
}
export async function loadLastSeenSeq(hostId: string): Promise<number> {
try {
const raw = await AsyncStorage.getItem(lastSeqStorageKey(hostId))
const parsed = raw == null ? 0 : Number(raw)
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0
} catch {
return 0
}
}
export async function saveLastSeenSeq(hostId: string, seq: number): Promise<void> {
if (!Number.isFinite(seq) || seq <= 0) {
return
}
try {
await AsyncStorage.setItem(lastSeqStorageKey(hostId), String(seq))
} catch {
// Why: persisting the watermark is best-effort. If it fails (or lags), the
// stored value stays BELOW what we delivered, so a later cold start can
// re-fetch — and, once the in-memory seen-set is gone, re-show — an already
// delivered notification. That's the accepted at-least-once trade-off;
// within a live session the in-memory watermark is authoritative, so only
// post-restart reconnects are affected.
}
}
// Why: bounded in-memory dedup window for notificationIds/dismiss ids observed
// on the current connection. The desktop already dedupes by seq on replay, but
// a socket that flickers background→foreground→background can deliver an event
// on the live stream and again in a replay; the seen-set guarantees each
// notificationId maps to at most one local push for the connection lifetime.
// Bounded so a long-lived session can't grow without limit — a 2x superset of
// the desktop's 256-entry replay buffer and the 256 scheduled-notification cap.
const RECENTLY_SEEN_CAP = 512
export function createSeenNotificationGuard(): {
has: (id: string) => boolean
add: (id: string) => void
} {
const seen = new Set<string>()
return {
has(id: string): boolean {
return seen.has(id)
},
add(id: string): void {
seen.add(id)
if (seen.size > RECENTLY_SEEN_CAP) {
// Why: insertion order; the oldest entries are first. Drop one to stay
// bounded without disturbing the more-recently-relevant keys.
const first = seen.values().next().value
if (first !== undefined) {
seen.delete(first)
}
}
}
}
}
// Why: key for the replay dedup guard. Uses notificationId when present, but
// disambiguates by seq so a legitimate live re-delivery of the same id at a
// NEW seq (content refresh, allowed by the existing behaviour) is NOT treated
// as a duplicate, while a replay re-returning the SAME id+seq already delivered
// live is suppressed. Replay events always carry a seq (the desktop assigns
// one), so the guard is effective on the reconnect path.
export function seenKeyForEvent(event: {
notificationId?: string
notificationSeq?: number
}): string | null {
const id = event.notificationId
if (id != null && event.notificationSeq != null) {
return `id:${id}#${event.notificationSeq}`
}
if (id != null) {
return `id:${id}`
}
if (event.notificationSeq != null) {
return `seq:${event.notificationSeq}`
}
return null
}
@@ -0,0 +1,61 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import type { ConnectionState } from '../transport/types'
import type { RpcSuccess } from '../transport/types'
import type { RpcClient } from '../transport/rpc-client'
import { createBotAuthorOverrideSet } from '../../../src/shared/pr-bot-author-overrides'
// Fetches the desktop's manual bot-author overrides (GlobalSettings.prBotAuthorOverrides,
// marked from the desktop Comments panel) so the mobile Humans/Bots comment filter
// classifies the same authors as bots. There is no settings-change stream over the
// mobile RPC, so callers pass a refreshKey (e.g. the PR payload identity) to re-fetch
// alongside PR data instead of holding a one-shot snapshot for the whole session.
export function usePRBotAuthorOverrides(
client: RpcClient | null,
connState: ConnectionState,
refreshKey?: unknown
): ReadonlySet<string> {
const [logins, setLogins] = useState<string[]>([])
const sourceClientRef = useRef<RpcClient | null>(null)
useEffect(() => {
if (!client || connState !== 'connected') {
sourceClientRef.current = null
setLogins([])
return
}
if (sourceClientRef.current !== client) {
// Why: overrides belong to the connected runtime; never render a prior
// host's classification while the replacement host is still loading.
sourceClientRef.current = client
setLogins([])
}
if (refreshKey == null) {
return
}
let stale = false
void client
.sendRequest('settings.get')
.then((response) => {
if (stale || !response.ok) {
return
}
const result = (response as RpcSuccess).result as {
settings?: { prBotAuthorOverrides?: unknown }
} | null
const overrides = result?.settings?.prBotAuthorOverrides
setLogins(
Array.isArray(overrides)
? overrides.filter((login): login is string => typeof login === 'string')
: []
)
})
.catch(() => {
// Best-effort: without the setting the heuristics still classify most bots.
})
return () => {
stale = true
}
}, [client, connState, refreshKey])
return useMemo(() => createBotAuthorOverrideSet(logins), [logins])
}
@@ -66,6 +66,8 @@ export function getMobilePrCreateBlockMessage(prefill: MobilePrPrefill): string
return `A ${copy.reviewLabel} already exists for this branch.`
case 'fork_head_unsupported':
return `Creating a ${copy.reviewLabel} from this fork is not supported.`
case 'base_not_on_remote':
return `Push the base branch before creating a ${copy.reviewLabel}.`
case 'needs_push':
case null:
case undefined:
@@ -0,0 +1,145 @@
import { describe, expect, it } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import { createBlankWorkspace } from './blank-workspace-create'
type Call = { method: string; params: unknown }
function fakeClient(script: (method: string, call: number) => unknown, calls: Call[]): RpcClient {
return {
sendRequest: async (method: string, params?: unknown) => {
calls.push({ method, params })
const result = script(method, calls.length)
if (result instanceof Error) {
return {
id: '1',
ok: false,
error: { code: 'x', message: result.message },
_meta: { runtimeId: 'r' }
}
}
return { id: '1', ok: true, result, _meta: { runtimeId: 'r' } }
}
} as unknown as RpcClient
}
describe('createBlankWorkspace', () => {
it('assembles exactly the params the modal historically sent, omitting empty extras', async () => {
const calls: Call[] = []
const client = fakeClient(() => ({ worktree: { id: 'wt-1' } }), calls)
const result = await createBlankWorkspace({
client,
repoId: 'repo-1',
baseName: 'octopus',
startupCommand: undefined,
createdWithAgentId: undefined,
comment: undefined,
setupDecision: 'inherit'
})
expect(result).toEqual({ worktreeId: 'wt-1', name: 'octopus' })
expect(calls).toHaveLength(1)
expect(calls[0]).toEqual({
method: 'worktree.create',
params: {
repo: 'id:repo-1',
startupCommand: undefined,
setupDecision: 'inherit',
name: 'octopus'
}
})
const params = calls[0]?.params as Record<string, unknown>
expect('createdWithAgent' in params).toBe(false)
expect('comment' in params).toBe(false)
})
it('includes createdWithAgent and comment only when provided', async () => {
const calls: Call[] = []
const client = fakeClient(() => ({ worktree: { id: 'wt-2' } }), calls)
await createBlankWorkspace({
client,
repoId: 'repo-2',
baseName: 'manatee',
startupCommand: 'claude',
createdWithAgentId: 'claude',
comment: 'spike',
setupDecision: 'run'
})
expect(calls[0]?.params).toMatchObject({
repo: 'id:repo-2',
name: 'manatee',
startupCommand: 'claude',
setupDecision: 'run',
createdWithAgent: 'claude',
comment: 'spike'
})
})
it('retries with a numeric suffix on a branch-collision error', async () => {
const calls: Call[] = []
const client = fakeClient((_method, call) => {
if (call === 1) {
return new Error('Branch "octopus" already exists locally. Pick a different branch name.')
}
return { worktree: { id: 'wt-3' } }
}, calls)
const result = await createBlankWorkspace({
client,
repoId: 'repo-1',
baseName: 'octopus',
startupCommand: undefined,
createdWithAgentId: undefined,
comment: undefined,
setupDecision: 'inherit'
})
expect(result).toEqual({ worktreeId: 'wt-3', name: 'octopus-2' })
expect(calls).toHaveLength(2)
const retryParams = calls[1]?.params as Record<string, unknown>
expect(retryParams.name).toBe('octopus-2')
})
it('retries on the bare older-runtime collision message', async () => {
const calls: Call[] = []
const client = fakeClient((_method, call) => {
if (call === 1) {
return new Error('Branch "octopus" already exists.')
}
return { worktree: { id: 'wt-4' } }
}, calls)
const result = await createBlankWorkspace({
client,
repoId: 'repo-1',
baseName: 'octopus',
startupCommand: undefined,
createdWithAgentId: undefined,
comment: undefined,
setupDecision: 'inherit'
})
expect(result).toEqual({ worktreeId: 'wt-4', name: 'octopus-2' })
expect(calls).toHaveLength(2)
})
it('surfaces a non-collision error without retrying', async () => {
const calls: Call[] = []
const client = fakeClient(() => new Error('SSH connection is not available'), calls)
const result = await createBlankWorkspace({
client,
repoId: 'repo-1',
baseName: 'octopus',
startupCommand: undefined,
createdWithAgentId: undefined,
comment: undefined,
setupDecision: 'skip'
})
expect(result).toEqual({ error: 'SSH connection is not available' })
expect(calls).toHaveLength(1)
})
})
@@ -0,0 +1,37 @@
import type { TuiAgent } from '../../../src/shared/types'
import type { RpcClient } from '../transport/rpc-client'
import { createWorktreeWithNameRetry, type WorktreeCreateResult } from './worktree-create-retry'
import type { WorkspaceCreateSetupDecision } from './workspace-create-params'
// The blank/named create path, extracted from NewWorktreeModal so the modal keeps
// only the UI-coupled setup-trust flow. Assembles worktree.create params and
// applies the shared name-collision retry.
export async function createBlankWorkspace(args: {
client: RpcClient
repoId: string
baseName: string
startupCommand: string | undefined
createdWithAgentId: TuiAgent | undefined
comment: string | undefined
setupDecision: WorkspaceCreateSetupDecision
}): Promise<WorktreeCreateResult> {
return createWorktreeWithNameRetry({
client: args.client,
baseName: args.baseName,
buildParams: (name) => {
const params: Record<string, unknown> = {
repo: `id:${args.repoId}`,
startupCommand: args.startupCommand,
setupDecision: args.setupDecision,
name
}
if (args.createdWithAgentId) {
params.createdWithAgent = args.createdWithAgentId
}
if (args.comment) {
params.comment = args.comment
}
return params
}
})
}
@@ -0,0 +1,243 @@
import { describe, expect, it } from 'vitest'
import type { GitHubWorkItem, GitLabWorkItem, LinearIssue } from '../../../src/shared/types'
import {
buildGitHubLinkedWorkItem,
buildGitLabLinkedWorkItem,
buildLinearLinkedWorkItem,
buildSmartNameSelection,
resolveComposerBranchPick,
resolveComposerCreateSelection,
resolveWorkItemAutoName,
shouldApplyAutoName
} from './composer-linked-work-item'
describe('linked work item builders', () => {
it('maps a GitHub PR into a linked work item', () => {
const linked = buildGitHubLinkedWorkItem({
type: 'pr',
number: 42,
title: 'Fix bug',
url: 'https://github.com/o/r/pull/42',
repoId: 'repo-1'
})
expect(linked).toMatchObject({ provider: 'github', type: 'pr', number: 42, repoId: 'repo-1' })
})
it('maps a GitLab MR into a linked work item', () => {
const linked = buildGitLabLinkedWorkItem({
type: 'mr',
number: 7,
title: 'Add feature',
url: 'https://gitlab.com/g/p/-/merge_requests/7',
repoId: 'repo-2'
})
expect(linked).toMatchObject({ provider: 'gitlab', type: 'mr', number: 7, repoId: 'repo-2' })
})
it('maps a Linear issue with identifier, workspace, and org key', () => {
const linked = buildLinearLinkedWorkItem({
identifier: 'ENG-9',
title: 'Ship it',
url: 'https://linear.app/acme/issue/ENG-9',
workspaceId: 'ws-1'
})
expect(linked).toMatchObject({
provider: 'linear',
type: 'issue',
number: 0,
linearIdentifier: 'ENG-9',
linearWorkspaceId: 'ws-1',
linearOrganizationUrlKey: 'acme'
})
})
})
describe('shouldApplyAutoName', () => {
it('applies when the name is empty or the previous auto-name', () => {
expect(shouldApplyAutoName({ currentName: '', lastAutoName: '' })).toBe(true)
expect(shouldApplyAutoName({ currentName: 'fix-bug', lastAutoName: 'fix-bug' })).toBe(true)
})
it('applies when the name is a lookup query (URL / #N)', () => {
expect(shouldApplyAutoName({ currentName: '#42', lastAutoName: 'x' })).toBe(true)
expect(shouldApplyAutoName({ currentName: 'ENG-9', lastAutoName: 'x' })).toBe(false)
})
it('keeps a deliberately typed name', () => {
expect(shouldApplyAutoName({ currentName: 'my custom name', lastAutoName: 'other' })).toBe(
false
)
})
})
describe('resolveWorkItemAutoName', () => {
it('slugifies the title subject', () => {
expect(
resolveWorkItemAutoName({
type: 'issue',
number: 3,
title: 'Fix the Login Bug',
provider: 'github'
})
).toBe('fix-the-login-bug')
})
})
describe('buildSmartNameSelection', () => {
const base = (over: Record<string, unknown>) => ({
provider: 'github' as const,
type: 'pr' as const,
number: 12,
title: 'T',
url: 'u',
...over
})
it('maps GitHub PR / issue kinds and numbers the label', () => {
expect(buildSmartNameSelection({ linkedWorkItem: base({}), baseBranch: undefined })).toEqual({
kind: 'github-pr',
label: '#12 T',
url: 'u'
})
expect(
buildSmartNameSelection({ linkedWorkItem: base({ type: 'issue' }), baseBranch: undefined })
).toMatchObject({ kind: 'github-issue' })
})
it('maps GitLab MR / issue kinds', () => {
expect(
buildSmartNameSelection({
linkedWorkItem: base({ provider: 'gitlab', type: 'mr' }),
baseBranch: undefined
})
).toMatchObject({ kind: 'gitlab-mr' })
expect(
buildSmartNameSelection({
linkedWorkItem: base({ provider: 'gitlab', type: 'issue' }),
baseBranch: undefined
})
).toMatchObject({ kind: 'gitlab-issue' })
})
it('maps Linear with a bare title label', () => {
expect(
buildSmartNameSelection({
linkedWorkItem: base({ provider: 'linear', type: 'issue', number: 0, title: 'ENG-9 Ship' }),
baseBranch: undefined
})
).toEqual({ kind: 'linear', label: 'ENG-9 Ship', url: 'u' })
})
it('falls back to a branch pill', () => {
expect(buildSmartNameSelection({ linkedWorkItem: null, baseBranch: 'main' })).toEqual({
kind: 'branch',
label: 'main'
})
})
it('returns null when nothing is selected', () => {
expect(buildSmartNameSelection({ linkedWorkItem: null, baseBranch: undefined })).toBeNull()
})
})
describe('resolveComposerCreateSelection', () => {
const baseCreateArgs = {
branch: null,
reuseEligibleBranch: null,
reuseSelectedBranch: false,
branchCreateIntent: false,
name: ''
}
it('prefers a linked work item and passes resolved base fields', () => {
const selection = resolveComposerCreateSelection({
...baseCreateArgs,
linkedWorkItem: {
provider: 'github',
type: 'pr',
number: 5,
title: 'T',
url: 'u',
repoId: 'repo-1'
},
base: { baseBranch: 'main', compareBaseRef: 'origin/main', branchNameOverride: 'pr-5' }
})
expect(selection).toMatchObject({
kind: 'work-item',
baseBranch: 'main',
compareBaseRef: 'origin/main',
branchNameOverride: 'pr-5'
})
})
it('marks reuse when the eligible branch is toggled on', () => {
const selection = resolveComposerCreateSelection({
...baseCreateArgs,
linkedWorkItem: null,
base: { baseBranch: 'feature', branchNameOverride: 'feature' },
branch: { refName: 'feature', localBranchName: 'feature' },
reuseEligibleBranch: 'feature',
reuseSelectedBranch: true
})
expect(selection).toEqual({
kind: 'branch',
baseBranch: 'feature',
refName: 'feature',
localBranchName: 'feature',
reuse: true,
branchNameOverride: 'feature'
})
})
it('returns a new-branch selection when create-branch intent is set', () => {
expect(
resolveComposerCreateSelection({
...baseCreateArgs,
linkedWorkItem: null,
base: {},
branchCreateIntent: true,
name: 'feature/login'
})
).toEqual({ kind: 'new-branch', branchName: 'feature/login' })
})
it('returns null with no work item, no branch base, and no intent', () => {
expect(
resolveComposerCreateSelection({ ...baseCreateArgs, linkedWorkItem: null, base: {} })
).toBeNull()
})
})
describe('resolveComposerBranchPick', () => {
it('auto-names and enables reuse for an unused local branch', () => {
const pick = resolveComposerBranchPick({
refName: 'feature',
localBranchName: 'feature',
currentName: '',
lastAutoName: '',
worktreeBranches: []
})
expect(pick.base).toEqual({ baseBranch: 'feature', branchNameOverride: 'feature' })
expect(pick).toMatchObject({
reuseEligibleBranch: 'feature',
reuseSelectedBranch: true,
name: 'feature'
})
})
it('does not reuse a branch already checked out elsewhere', () => {
const pick = resolveComposerBranchPick({
refName: 'feature',
localBranchName: 'feature',
currentName: '',
lastAutoName: '',
worktreeBranches: ['refs/heads/feature']
})
expect(pick.reuseEligibleBranch).toBeNull()
expect(pick.reuseSelectedBranch).toBe(false)
expect(pick.base.branchNameOverride).toBeUndefined()
})
})
// Keep the exported type aliases referenced so the module surface stays covered.
export type _Ref = [GitHubWorkItem, GitLabWorkItem, LinearIssue]
@@ -0,0 +1,153 @@
import type { GitHubWorkItem, GitLabWorkItem, LinearIssue } from '../../../src/shared/types'
import { getLinearIssueWorkspaceName } from '../../../src/shared/workspace-name'
import {
buildGitHubWorkspaceSource,
buildGitLabWorkspaceSource,
buildLinearWorkspaceSource,
buildWorkspaceSourceSelection,
getWorkspaceSourceName,
shouldApplyWorkspaceSourceAutoName
} from '../../../src/shared/new-workspace/workspace-source'
import { resolveComposerBranchPick as resolveSharedComposerBranchPick } from '../../../src/shared/composer-branch-selection'
import type {
MobileComposerCreateSelection,
MobileLinkedWorkItem,
SmartNameSelection
} from './mobile-composer-source-types'
import type { WorkspaceCreateGitPushTarget } from './workspace-create-params'
export function buildGitHubLinkedWorkItem(item: {
type: 'issue' | 'pr'
number: number
title: string
url: string
repoId: string
}): MobileLinkedWorkItem {
return buildGitHubWorkspaceSource(item)
}
export function buildGitLabLinkedWorkItem(item: {
type: 'issue' | 'mr'
number: number
title: string
url: string
repoId: string
}): MobileLinkedWorkItem {
return buildGitLabWorkspaceSource(item)
}
export function buildLinearLinkedWorkItem(issue: {
identifier: string
title: string
url: string
workspaceId?: string
}): MobileLinkedWorkItem {
return buildLinearWorkspaceSource(issue)
}
// Faithful port of desktop applyLinkedWorkItem's name gate: the derived name
// replaces the current field only when it's empty, still the last auto-name, or
// a lookup query — never a name the user deliberately typed.
export function shouldApplyAutoName(args: { currentName: string; lastAutoName: string }): boolean {
return shouldApplyWorkspaceSourceAutoName(args)
}
export function resolveWorkItemAutoName(item: {
type: 'issue' | 'pr' | 'mr'
number: number
title: string
provider: 'github' | 'gitlab' | 'linear'
linearIdentifier?: string
}): string {
return getWorkspaceSourceName({ ...item, url: '' }).seedName
}
export function resolveLinearAutoName(issue: { identifier: string; title: string }): string {
return getLinearIssueWorkspaceName(issue)
}
// Derives the pill descriptor from the linked item (or a plain branch base),
// mirroring desktop's smartNameSelection memo.
export function buildSmartNameSelection(args: {
linkedWorkItem: MobileLinkedWorkItem | null
baseBranch: string | undefined
}): SmartNameSelection | null {
return buildWorkspaceSourceSelection(args) as SmartNameSelection | null
}
// Derives the create-time selection from composer state: a linked work item wins
// (carrying its resolved base/push fields), else a picked branch, else null (a
// name-only/blank create).
export function resolveComposerCreateSelection(args: {
linkedWorkItem: MobileLinkedWorkItem | null
base: {
baseBranch?: string
compareBaseRef?: string
pushTarget?: WorkspaceCreateGitPushTarget
branchNameOverride?: string
}
branch: { refName: string; localBranchName: string } | null
reuseEligibleBranch: string | null
reuseSelectedBranch: boolean
branchCreateIntent: boolean
name: string
}): MobileComposerCreateSelection | null {
const { linkedWorkItem, base, branch, reuseEligibleBranch, reuseSelectedBranch } = args
if (linkedWorkItem) {
return {
kind: 'work-item',
item: linkedWorkItem,
baseBranch: base.baseBranch,
compareBaseRef: base.compareBaseRef,
pushTarget: base.pushTarget,
branchNameOverride: base.branchNameOverride
}
}
if (branch && base.baseBranch) {
return {
kind: 'branch',
baseBranch: base.baseBranch,
refName: branch.refName,
localBranchName: branch.localBranchName,
reuse: reuseSelectedBranch && reuseEligibleBranch === branch.localBranchName,
branchNameOverride: base.branchNameOverride
}
}
if (args.branchCreateIntent && args.name.trim()) {
return { kind: 'new-branch', branchName: args.name.trim() }
}
return null
}
export type ComposerBranchPick = {
base: { baseBranch: string; branchNameOverride?: string }
reuseEligibleBranch: string | null
reuseSelectedBranch: boolean
name?: string
lastAutoName?: string
}
// Pure port of desktop handleSmartBranchSelect's derivation: base + reuse
// eligibility/default + the auto-name to apply, from the shared branch helpers.
export function resolveComposerBranchPick(args: {
refName: string
localBranchName: string
currentName: string
lastAutoName: string
worktreeBranches: readonly string[]
}): ComposerBranchPick {
const selection = resolveSharedComposerBranchPick(args)
return {
base: {
baseBranch: selection.baseBranch,
branchNameOverride: selection.branchNameOverride
},
reuseEligibleBranch: selection.reuseEligibleBranch,
reuseSelectedBranch: selection.defaultReuse,
...(selection.name !== undefined && selection.lastAutoName !== undefined
? { name: selection.name, lastAutoName: selection.lastAutoName }
: {})
}
}
export type { GitHubWorkItem, GitLabWorkItem, LinearIssue }
@@ -0,0 +1,76 @@
import type { RpcClient } from '../transport/rpc-client'
import type { RpcSuccess } from '../transport/types'
import type { GitHubPrStartPoint } from '../../../src/shared/types'
// The resolved start point for a linked PR/MR: the base branch to create from
// plus the optional review-compare ref, push target, and exact branch name.
export type ComposerHostedBase = Pick<
GitHubPrStartPoint,
'baseBranch' | 'compareBaseRef' | 'pushTarget' | 'branchNameOverride' | 'maintainerCanModify'
>
type HostedBaseResult = ComposerHostedBase | { error: string }
// Resolves a GitHub PR's base via worktree.resolvePrBase, mirroring desktop's
// select-time resolution. The runtime returns a soft { error } payload rather
// than an RPC error for provider failures.
export async function resolveComposerPrBase(args: {
client: RpcClient
repoId: string
prNumber: number
headRefName?: string
baseRefName?: string
isCrossRepository?: boolean
}): Promise<GitHubPrStartPoint> {
const { client, repoId, prNumber, headRefName, baseRefName, isCrossRepository } = args
const response = await client.sendRequest(
'worktree.resolvePrBase',
{
repo: `id:${repoId}`,
prNumber,
...(headRefName ? { headRefName } : {}),
...(baseRefName ? { baseRefName } : {}),
...(isCrossRepository !== undefined ? { isCrossRepository } : {})
},
{ timeoutMs: 30_000 }
)
if (!response.ok) {
throw new Error(response.error.message)
}
const result = (response as RpcSuccess).result as GitHubPrStartPoint | { error: string }
if ('error' in result) {
throw new Error(result.error)
}
return result
}
// Resolves a GitLab MR's base via worktree.resolveMrBase.
export async function resolveComposerMrBase(args: {
client: RpcClient
repoId: string
mrIid: number
sourceBranch?: string
targetBranch?: string
isCrossRepository?: boolean
}): Promise<ComposerHostedBase> {
const { client, repoId, mrIid, sourceBranch, targetBranch, isCrossRepository } = args
const response = await client.sendRequest(
'worktree.resolveMrBase',
{
repo: `id:${repoId}`,
mrIid,
...(sourceBranch ? { sourceBranch } : {}),
...(targetBranch ? { targetBranch } : {}),
...(isCrossRepository !== undefined ? { isCrossRepository } : {})
},
{ timeoutMs: 30_000 }
)
if (!response.ok) {
throw new Error(response.error.message)
}
const result = (response as RpcSuccess).result as HostedBaseResult
if ('error' in result) {
throw new Error(result.error)
}
return result
}
@@ -0,0 +1,64 @@
import type { SmartNameMode } from '../../../src/shared/new-workspace/smart-workspace-source-results'
import type {
WorkspaceSourceLinkedItem,
WorkspaceSourceSelection
} from '../../../src/shared/new-workspace/workspace-source'
import type { WorkspaceCreateGitPushTarget } from './workspace-create-params'
export type { SmartNameMode }
export type ComposerBaseState = {
baseBranch?: string
compareBaseRef?: string
pushTarget?: WorkspaceCreateGitPushTarget
branchNameOverride?: string
}
// Mirrors the desktop composer's `linkedWorkItem` (a FolderWorkspaceLinkedTask
// superset): the one work item a Smart selection pins the workspace to. Linear
// items carry the workspace/org routing the runtime needs to relink the issue.
export type MobileLinkedWorkItem = Omit<WorkspaceSourceLinkedItem, 'provider'> & {
provider: Exclude<WorkspaceSourceLinkedItem['provider'], 'jira'>
}
export type SmartNameSelectionKind =
| 'github-pr'
| 'github-issue'
| 'gitlab-mr'
| 'gitlab-issue'
| 'branch'
| 'linear'
// The pill descriptor the field renders once a source is selected. Same shape
// as desktop's `SmartWorkspaceNameSelection`.
export type SmartNameSelection = Omit<WorkspaceSourceSelection, 'kind'> & {
kind: SmartNameSelectionKind
}
// GitLab MR-state filter chips, mirroring desktop's getMrStateFilters(). Default
// is 'opened' (Open).
export type MrStateFilter = 'opened' | 'merged' | 'closed' | 'all'
// The resolved selection the create flow consumes. Work-item selections carry
// the base/push fields the composer resolved at select time; branch selections
// carry the ref + reuse intent.
export type MobileComposerCreateSelection =
| {
kind: 'work-item'
item: MobileLinkedWorkItem
baseBranch?: string
compareBaseRef?: string
pushTarget?: WorkspaceCreateGitPushTarget
branchNameOverride?: string
}
| {
kind: 'branch'
baseBranch: string
refName: string
localBranchName: string
reuse: boolean
branchNameOverride?: string
}
// A brand-new branch created by name (no ref picked); the branch is created off
// the repo's default base and the typed name is kept verbatim as the branch.
| { kind: 'new-branch'; branchName: string }
@@ -0,0 +1,86 @@
import { describe, expect, it } from 'vitest'
import {
DEFAULT_MR_STATE_FILTER,
MR_STATE_FILTER_OPTIONS,
normalizeSmartMode,
resolveAvailableSmartModes,
resolveDefaultSmartMode
} from './mobile-smart-source-modes'
const fullyAvailable = {
textOnly: false,
tasksSupported: true,
hasRepo: true,
githubAvailable: true,
gitlabAvailable: true,
linearAvailable: true
}
describe('resolveAvailableSmartModes', () => {
it('lists every mode in desktop order when all are available', () => {
expect(resolveAvailableSmartModes(fullyAvailable)).toEqual([
'smart',
'github',
'linear',
'gitlab',
'branches',
'text'
])
})
it('collapses to Name for a non-git (text-only) repo', () => {
expect(resolveAvailableSmartModes({ ...fullyAvailable, textOnly: true })).toEqual(['text'])
})
it('drops provider + smart modes when the tasks RPC surface is missing', () => {
expect(resolveAvailableSmartModes({ ...fullyAvailable, tasksSupported: false })).toEqual([
'branches',
'text'
])
})
it('gates provider tabs on their availability and a selected repo', () => {
expect(
resolveAvailableSmartModes({
...fullyAvailable,
githubAvailable: false,
gitlabAvailable: false
})
).toEqual(['smart', 'linear', 'branches', 'text'])
expect(resolveAvailableSmartModes({ ...fullyAvailable, hasRepo: false })).toEqual([
'smart',
'linear',
'text'
])
})
})
describe('resolveDefaultSmartMode', () => {
it('defaults to smart for a git repo with search', () => {
expect(resolveDefaultSmartMode(fullyAvailable)).toBe('smart')
})
it('defaults to text for a non-git repo', () => {
expect(resolveDefaultSmartMode({ ...fullyAvailable, textOnly: true })).toBe('text')
})
it('defaults to branches for a git repo without the tasks surface', () => {
expect(resolveDefaultSmartMode({ ...fullyAvailable, tasksSupported: false })).toBe('branches')
})
})
describe('normalizeSmartMode', () => {
it('keeps a valid mode and snaps an unavailable one back to default', () => {
expect(normalizeSmartMode('gitlab', fullyAvailable)).toBe('gitlab')
expect(normalizeSmartMode('gitlab', { ...fullyAvailable, gitlabAvailable: false })).toBe(
'smart'
)
})
})
describe('MR state filters', () => {
it('exposes Open/Merged/Closed/All with an Open default', () => {
expect(MR_STATE_FILTER_OPTIONS.map((o) => o.id)).toEqual(['opened', 'merged', 'closed', 'all'])
expect(DEFAULT_MR_STATE_FILTER).toBe('opened')
})
})
@@ -0,0 +1,93 @@
import type { MrStateFilter, SmartNameMode } from './mobile-composer-source-types'
// Icon each tab renders: lucide glyphs for the neutral modes, the inline brand
// SVGs (TaskProviderLogo) for the provider modes since lucide dropped its brand
// icons.
export type SmartModeIcon =
| { type: 'lucide'; name: 'sparkles' | 'git-branch' | 'case-sensitive' }
| { type: 'provider'; provider: 'github' | 'gitlab' | 'linear' }
export type SmartModeOption = {
id: SmartNameMode
label: string
icon: SmartModeIcon
}
// Order + labels + icons mirror desktop getSmartWorkspaceNameModes():
// Smart · GitHub · Linear · GitLab · Branch · Name.
export const SMART_MODE_OPTIONS: readonly SmartModeOption[] = [
{ id: 'smart', label: 'Smart', icon: { type: 'lucide', name: 'sparkles' } },
{ id: 'github', label: 'GitHub', icon: { type: 'provider', provider: 'github' } },
{ id: 'linear', label: 'Linear', icon: { type: 'provider', provider: 'linear' } },
{ id: 'gitlab', label: 'GitLab', icon: { type: 'provider', provider: 'gitlab' } },
{ id: 'branches', label: 'Branch', icon: { type: 'lucide', name: 'git-branch' } },
{ id: 'text', label: 'Name', icon: { type: 'lucide', name: 'case-sensitive' } }
]
export type SmartModeAvailabilityInput = {
textOnly: boolean
tasksSupported: boolean
hasRepo: boolean
githubAvailable: boolean
gitlabAvailable: boolean
linearAvailable: boolean
}
// Faithful port of the desktop availableModes filter. Non-git repos collapse to
// the Name tab; provider tabs gate on availability + a selected repo + the tasks
// RPC surface; branches only need a git repo (new-branch-by-name works without
// the search capability).
export function resolveAvailableSmartModes(input: SmartModeAvailabilityInput): SmartNameMode[] {
if (input.textOnly) {
return ['text']
}
return SMART_MODE_OPTIONS.filter((option) => {
switch (option.id) {
case 'smart':
return input.tasksSupported
case 'github':
return input.tasksSupported && input.hasRepo && input.githubAvailable
case 'gitlab':
return input.tasksSupported && input.hasRepo && input.gitlabAvailable
case 'linear':
return input.tasksSupported && input.linearAvailable
case 'branches':
return input.hasRepo
case 'text':
return true
}
}).map((option) => option.id)
}
// Default mode when the picker opens: 'smart' for a git repo when search is
// available, else the first available mode (branches for git without tasks,
// 'text' for non-git).
export function resolveDefaultSmartMode(input: SmartModeAvailabilityInput): SmartNameMode {
const available = resolveAvailableSmartModes(input)
if (available.includes('smart')) {
return 'smart'
}
return available[0] ?? 'text'
}
// Keeps a chosen mode valid as availability changes (e.g. the repo switches to a
// non-git folder), mirroring desktop's snap-to-available effect.
export function normalizeSmartMode(
mode: SmartNameMode,
input: SmartModeAvailabilityInput
): SmartNameMode {
const available = resolveAvailableSmartModes(input)
return available.includes(mode) ? mode : resolveDefaultSmartMode(input)
}
export type MrStateFilterOption = { id: MrStateFilter; label: string }
// Desktop getMrStateFilters(): Open · Merged · Closed · All, default 'opened'.
export const MR_STATE_FILTER_OPTIONS: readonly MrStateFilterOption[] = [
{ id: 'opened', label: 'Open' },
{ id: 'merged', label: 'Merged' },
{ id: 'closed', label: 'Closed' },
{ id: 'all', label: 'All' }
]
export const DEFAULT_MR_STATE_FILTER: MrStateFilter = 'opened'
@@ -0,0 +1,5 @@
// Runtime capability the desktop advertises when it supports the mobile Tasks RPC
// surface (github/gitlab/linear work items + repo.searchRefs). Older paired
// desktops omit it, so mobile must degrade to blank/new-branch sources only.
// Mirrors the 'mobile.tasks.v1' entry in src/shared/protocol-version.ts.
export const MOBILE_TASKS_CAPABILITY = 'mobile.tasks.v1'
+23
View File
@@ -2,10 +2,12 @@ import { describe, expect, it } from 'vitest'
import {
isSetupHookTrusted,
normalizeSetupHookTrust,
persistSetupHookTrustApproval,
trustedOrcaHooksWithSetupApproval,
wasSetupHookPreviouslyApproved
} from './setup-hook-trust'
import type { PersistedTrustedOrcaHooks } from '../../../src/shared/types'
import type { RpcClient } from '../transport/rpc-client'
describe('setup hook trust', () => {
it('trusts a setup script only when the approved hash matches', () => {
@@ -71,6 +73,27 @@ describe('setup hook trust', () => {
})
})
it('persists and returns the approved trust state', async () => {
let persisted: unknown
const client = {
sendRequest: async (_method: string, params: unknown) => {
persisted = params
return { ok: true, result: null }
}
} as unknown as RpcClient
const next = await persistSetupHookTrustApproval({
client,
trust: {},
repoId: 'repo-1',
contentHash: 'setup-hash',
alwaysTrust: false
})
expect(persisted).toEqual({ trustedOrcaHooks: next })
expect(isSetupHookTrusted(next, 'repo-1', 'setup-hash')).toBe(true)
})
it('detects previous setup approval and ignores incomplete trust payloads', () => {
expect(
wasSetupHookPreviouslyApproved(
+16
View File
@@ -1,4 +1,5 @@
import type { PersistedTrustedOrcaHooks } from '../../../src/shared/types'
import type { RpcClient } from '../transport/rpc-client'
export type SetupHookTrust = {
contentHash: string
@@ -36,6 +37,21 @@ export function trustedOrcaHooksWithSetupApproval(args: {
return { ...args.trust, [args.repoId]: nextRepo }
}
export async function persistSetupHookTrustApproval(args: {
client: RpcClient
trust: PersistedTrustedOrcaHooks
repoId: string
contentHash: string
alwaysTrust: boolean
}): Promise<PersistedTrustedOrcaHooks> {
const next = trustedOrcaHooksWithSetupApproval(args)
const response = await args.client.sendRequest('ui.set', { trustedOrcaHooks: next })
if (!response.ok) {
throw new Error(response.error.message)
}
return next
}
export function normalizeSetupHookTrust(
setupTrust: SetupHookTrust | null | undefined
): SetupHookTrust | null {
@@ -0,0 +1,112 @@
import { describe, expect, it } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import { fanOutSmartSearch } from './smart-source-fan-out'
type Call = { method: string; params: Record<string, unknown> }
function fakeClient(byMethod: Record<string, unknown>, calls: Call[]): RpcClient {
return {
sendRequest: async (method: string, params?: unknown) => {
calls.push({ method, params: (params ?? {}) as Record<string, unknown> })
const result = byMethod[method]
if (result instanceof Error) {
return {
id: '1',
ok: false,
error: { code: 'x', message: result.message },
_meta: { runtimeId: 'r' }
}
}
return { id: '1', ok: true, result: result ?? { items: [] }, _meta: { runtimeId: 'r' } }
}
} as unknown as RpcClient
}
const smartArgs = {
mode: 'smart' as const,
query: 'bug',
repoId: 'repo-1',
githubAvailable: true,
gitlabAvailable: true,
linearAvailable: true,
mrStateFilter: 'opened' as const,
linearWorkspaceId: null
}
describe('fanOutSmartSearch', () => {
it('fans out to every provider in smart mode and stamps repoId', async () => {
const calls: Call[] = []
const client = fakeClient(
{
'github.listWorkItems': { items: [{ id: 'g1', type: 'issue', number: 1, title: 'A' }] },
'gitlab.listWorkItems': { items: [{ id: 'gl1', type: 'mr', number: 2, title: 'B' }] },
'linear.searchIssues': { items: [{ id: 'l1', identifier: 'ENG-1', title: 'C' }] },
'repo.searchRefs': { refDetails: [{ refName: 'main', localBranchName: 'main' }] }
},
calls
)
const result = await fanOutSmartSearch({ client, ...smartArgs })
expect(calls.map((c) => c.method).sort()).toEqual([
'github.listWorkItems',
'gitlab.listWorkItems',
'linear.searchIssues',
'repo.searchRefs'
])
expect(result.githubItems[0]).toMatchObject({ number: 1, repoId: 'repo-1' })
expect(result.gitlabItems[0]).toMatchObject({ number: 2, repoId: 'repo-1' })
expect(result.linearIssues[0]).toMatchObject({ identifier: 'ENG-1' })
expect(result.branches).toEqual([{ refName: 'main', localBranchName: 'main' }])
expect(result.error).toBe('')
})
it('swallows a single provider failure in smart mode (best-effort)', async () => {
const calls: Call[] = []
const client = fakeClient(
{
'github.listWorkItems': new Error('gh down'),
'gitlab.listWorkItems': { items: [{ id: 'gl1', type: 'mr', number: 2, title: 'B' }] },
'linear.searchIssues': { items: [] },
'repo.searchRefs': { refDetails: [] }
},
calls
)
const result = await fanOutSmartSearch({ client, ...smartArgs })
expect(result.error).toBe('')
expect(result.gitlabItems).toHaveLength(1)
})
it('surfaces the error for a single-provider mode', async () => {
const calls: Call[] = []
const client = fakeClient({ 'gitlab.listWorkItems': new Error('gl boom') }, calls)
const result = await fanOutSmartSearch({ ...smartArgs, mode: 'gitlab', client })
expect(calls.map((c) => c.method)).toEqual(['gitlab.listWorkItems'])
expect(result.error).toBe('gl boom')
})
it('only searches branches in smart mode when the query is non-empty', async () => {
const calls: Call[] = []
const client = fakeClient({}, calls)
await fanOutSmartSearch({ ...smartArgs, query: '', client })
expect(calls.map((c) => c.method)).not.toContain('repo.searchRefs')
})
it('skips GitHub in smart mode when GitHub is unavailable', async () => {
const calls: Call[] = []
const client = fakeClient({}, calls)
await fanOutSmartSearch({ ...smartArgs, githubAvailable: false, client })
expect(calls.map((c) => c.method)).not.toContain('github.listWorkItems')
})
it('does not send oversized source queries to any provider', async () => {
const calls: Call[] = []
const client = fakeClient({}, calls)
const result = await fanOutSmartSearch({ ...smartArgs, query: 'x'.repeat(2049), client })
expect(calls).toEqual([])
expect(result).toMatchObject({
githubItems: [],
gitlabItems: [],
linearIssues: [],
branches: []
})
})
})
+141
View File
@@ -0,0 +1,141 @@
import type {
BaseRefSearchResult,
GitHubWorkItem,
GitLabWorkItem,
LinearIssue
} from '../../../src/shared/types'
import {
isSmartWorkspaceSourceQueryWithinLimit,
type SmartNameMode
} from '../../../src/shared/new-workspace/smart-workspace-source-results'
import type { RpcClient } from '../transport/rpc-client'
import { isGitHubWorkItemsSshRemoteRequiredError } from './mobile-work-items'
import type { MrStateFilter } from './mobile-composer-source-types'
import {
searchBranches,
searchGitHubItems,
searchGitLabItems,
searchLinearIssues
} from './smart-source-search-requests'
export type SmartFanOutResult = {
githubItems: GitHubWorkItem[]
gitlabItems: GitLabWorkItem[]
linearIssues: LinearIssue[]
branches: BaseRefSearchResult[]
needsGitHubRemote: boolean
error: string
}
const EMPTY: Omit<SmartFanOutResult, 'needsGitHubRemote' | 'error'> = {
githubItems: [],
gitlabItems: [],
linearIssues: [],
branches: []
}
function shouldSearchGitHub(mode: SmartNameMode, githubAvailable: boolean): boolean {
return githubAvailable && (mode === 'smart' || mode === 'github')
}
function shouldSearchGitLab(mode: SmartNameMode, gitlabAvailable: boolean): boolean {
return gitlabAvailable && (mode === 'smart' || mode === 'gitlab')
}
function shouldSearchLinear(mode: SmartNameMode, linearAvailable: boolean): boolean {
return linearAvailable && (mode === 'smart' || mode === 'linear')
}
function shouldSearchBranches(mode: SmartNameMode, query: string): boolean {
return mode === 'branches' || (mode === 'smart' && query.trim().length > 0)
}
type FanOutArgs = {
client: RpcClient
mode: SmartNameMode
query: string
repoId: string | null
githubAvailable: boolean
gitlabAvailable: boolean
linearAvailable: boolean
mrStateFilter: MrStateFilter
linearWorkspaceId: string | null | undefined
}
// Runs every provider search the active mode needs, concurrently. Smart mode is
// best-effort (a single provider failure never blocks the others); single-provider
// modes surface the failure. No cross-provider ranking/dedup — the shared row
// builder concatenates in provider order.
export async function fanOutSmartSearch(args: FanOutArgs): Promise<SmartFanOutResult> {
if (!isSmartWorkspaceSourceQueryWithinLimit(args.query)) {
// Why: the source limit is an outbound-request boundary, not only a render
// limit; pasted payloads must never fan out to provider CLIs or SSH hosts.
return { ...EMPTY, needsGitHubRemote: false, error: '' }
}
const {
client,
mode,
query,
repoId,
githubAvailable,
gitlabAvailable,
linearAvailable,
mrStateFilter
} = args
const isSmart = mode === 'smart'
const tasks = {
github:
shouldSearchGitHub(mode, githubAvailable) && repoId
? searchGitHubItems(client, repoId, query)
: null,
gitlab:
shouldSearchGitLab(mode, gitlabAvailable) && repoId
? searchGitLabItems(client, repoId, query, mrStateFilter)
: null,
linear: shouldSearchLinear(mode, linearAvailable)
? searchLinearIssues(client, query, args.linearWorkspaceId)
: null,
branches:
shouldSearchBranches(mode, query) && repoId ? searchBranches(client, repoId, query) : null
}
const [github, gitlab, linear, branches] = await Promise.allSettled([
tasks.github ?? Promise.resolve<GitHubWorkItem[]>([]),
tasks.gitlab ?? Promise.resolve<GitLabWorkItem[]>([]),
tasks.linear ?? Promise.resolve<LinearIssue[]>([]),
tasks.branches ?? Promise.resolve<BaseRefSearchResult[]>([])
])
let needsGitHubRemote = false
let error = ''
const fail = (reason: unknown) => {
if (!isSmart) {
error = reason instanceof Error ? reason.message : 'Search failed'
}
}
if (github.status === 'rejected') {
if (isGitHubWorkItemsSshRemoteRequiredError(github.reason)) {
needsGitHubRemote = true
} else {
fail(github.reason)
}
}
if (gitlab.status === 'rejected') {
fail(gitlab.reason)
}
if (linear.status === 'rejected') {
fail(linear.reason)
}
if (branches.status === 'rejected') {
fail(branches.reason)
}
return {
...EMPTY,
githubItems: github.status === 'fulfilled' ? github.value : [],
gitlabItems: gitlab.status === 'fulfilled' ? gitlab.value : [],
linearIssues: linear.status === 'fulfilled' ? linear.value : [],
branches: branches.status === 'fulfilled' ? branches.value : [],
needsGitHubRemote,
error
}
}
@@ -0,0 +1,142 @@
import { describe, expect, it } from 'vitest'
import {
deriveRepoSlug,
findRepoMatchingSlug,
findRepoMatchingSlugForPaste,
resolvePasteIntent,
type PasteRepoCandidate
} from './smart-source-paste-intent'
import type { RpcClient } from '../transport/rpc-client'
describe('resolvePasteIntent', () => {
it('classifies a GitHub issue/PR URL as a github-link', () => {
expect(resolvePasteIntent('https://github.com/acme/widgets/pull/12')).toEqual({
kind: 'github-link',
link: { slug: { owner: 'acme', repo: 'widgets' }, number: 12, type: 'pr' }
})
})
it('classifies a bare #number as a github-number', () => {
expect(resolvePasteIntent('#42')).toEqual({ kind: 'github-number', number: 42 })
})
it('classifies a GitLab MR URL as a gitlab-link', () => {
const intent = resolvePasteIntent('https://gitlab.com/group/proj/-/merge_requests/8')
expect(intent?.kind).toBe('gitlab-link')
if (intent?.kind === 'gitlab-link') {
expect(intent.link).toMatchObject({ number: 8, type: 'mr' })
}
})
it('returns null for plain search text', () => {
expect(resolvePasteIntent('login bug')).toBeNull()
})
it('rejects oversized paste intents before any exact lookup', () => {
expect(
resolvePasteIntent(`https://github.com/acme/widgets/issues/12/${'x'.repeat(2048)}`)
).toBeNull()
})
})
describe('deriveRepoSlug', () => {
it('prefers the upstream identity', () => {
expect(deriveRepoSlug({ upstream: { owner: 'up', repo: 'stream' } })).toEqual({
owner: 'up',
repo: 'stream'
})
})
it('parses an SSH remote URL', () => {
expect(
deriveRepoSlug({ gitRemoteIdentity: { remoteUrl: 'git@github.com:acme/widgets.git' } })
).toEqual({ owner: 'acme', repo: 'widgets' })
})
it('parses an HTTPS remote URL', () => {
expect(
deriveRepoSlug({ gitRemoteIdentity: { remoteUrl: 'https://github.com/acme/widgets' } })
).toEqual({ owner: 'acme', repo: 'widgets' })
})
it('returns null when no slug can be derived', () => {
expect(deriveRepoSlug({})).toBeNull()
})
})
describe('findRepoMatchingSlug', () => {
const repos: PasteRepoCandidate[] = [
{ id: 'a', displayName: 'A', slug: { owner: 'acme', repo: 'widgets' } },
{ id: 'b', displayName: 'B', slug: null }
]
it('matches case-insensitively', () => {
expect(findRepoMatchingSlug(repos, { owner: 'Acme', repo: 'Widgets' })?.id).toBe('a')
})
it('returns null when no repo matches', () => {
expect(findRepoMatchingSlug(repos, { owner: 'other', repo: 'thing' })).toBeNull()
})
it('falls back to the host-aware repo slug RPC for SSH and enterprise repos', async () => {
const calls: string[] = []
const client = {
sendRequest: async (_method: string, params: unknown) => {
const repo = (params as { repo: string }).repo
calls.push(repo)
return {
ok: true,
result: repo === 'id:b' ? { owner: 'enterprise', repo: 'widgets' } : null
}
}
} as unknown as RpcClient
await expect(
findRepoMatchingSlugForPaste(
client,
repos,
{ owner: 'enterprise', repo: 'widgets' },
new Map()
)
).resolves.toMatchObject({ id: 'b' })
expect(calls).toEqual(['id:a', 'id:b'])
})
it('keeps local matching usable when an older desktop lacks the repo slug RPC', async () => {
let calls = 0
const client = {
sendRequest: async () => {
calls += 1
return {
ok: false,
error: { code: 'method_not_found', message: 'Unknown method: github.repoSlug' }
}
}
} as unknown as RpcClient
const cache = new Map<string, { owner: string; repo: string } | null>()
await expect(
findRepoMatchingSlugForPaste(client, repos, { owner: 'enterprise', repo: 'widgets' }, cache)
).resolves.toBeNull()
await expect(
findRepoMatchingSlugForPaste(client, repos, { owner: 'enterprise', repo: 'other' }, cache)
).resolves.toBeNull()
expect(calls).toBe(1)
})
it('keeps local matching usable when the optional repo slug lookup rejects', async () => {
const client = {
sendRequest: async () => {
throw new Error('connection closed')
}
} as unknown as RpcClient
await expect(
findRepoMatchingSlugForPaste(
client,
repos,
{ owner: 'enterprise', repo: 'widgets' },
new Map()
)
).resolves.toBeNull()
})
})
@@ -0,0 +1,178 @@
import type { GitHubWorkItem, GitLabWorkItem } from '../../../src/shared/types'
import {
normalizeGitHubLinkQuery,
parseGitHubIssueOrPRLink,
type GitHubIssueOrPRLink,
type RepoSlug
} from '../../../src/shared/new-workspace/github-links'
import { parseGitLabIssueOrMRLink } from '../../../src/shared/new-workspace/gitlab-links'
import { isSmartWorkspaceSourceQueryWithinLimit } from '../../../src/shared/new-workspace/smart-workspace-source-results'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcSuccess } from '../transport/types'
// A repo the picker can switch to for a cross-repo GitHub paste. Slug is derived
// best-effort from the repo's remote metadata.
export type PasteRepoCandidate = {
id: string
displayName: string
slug: RepoSlug | null
}
export type GitHubPasteIntent =
| { kind: 'github-link'; link: GitHubIssueOrPRLink }
| { kind: 'github-number'; number: number }
export type GitLabPasteIntent = {
kind: 'gitlab-link'
link: NonNullable<ReturnType<typeof parseGitLabIssueOrMRLink>>
}
export type PasteIntent = GitHubPasteIntent | GitLabPasteIntent | null
// Pure: classify pasted text into a work-item lookup intent. A slug-bearing
// GitHub URL becomes 'github-link'; a bare "#123"/number becomes 'github-number';
// a GitLab issue/MR URL becomes 'gitlab-link'.
export function resolvePasteIntent(query: string): PasteIntent {
if (!isSmartWorkspaceSourceQueryWithinLimit(query)) {
return null
}
const trimmed = query.trim()
if (!trimmed) {
return null
}
const ghLink = parseGitHubIssueOrPRLink(trimmed)
if (ghLink) {
return { kind: 'github-link', link: ghLink }
}
const normalizedGh = normalizeGitHubLinkQuery(trimmed)
if (normalizedGh.directNumber !== null && !/^https?:\/\//i.test(trimmed)) {
return { kind: 'github-number', number: normalizedGh.directNumber }
}
const glLink = parseGitLabIssueOrMRLink(trimmed)
if (glLink) {
return { kind: 'gitlab-link', link: glLink }
}
return null
}
// Pure: derive an owner/repo slug from a repo's remote metadata so a pasted
// cross-repo URL can be matched to a locally known repo.
export function deriveRepoSlug(repo: {
upstream?: { owner: string; repo: string } | null
gitRemoteIdentity?: { remoteUrl?: string; canonicalKey?: string } | null
}): RepoSlug | null {
if (repo.upstream?.owner && repo.upstream.repo) {
return { owner: repo.upstream.owner, repo: repo.upstream.repo }
}
const source = repo.gitRemoteIdentity?.remoteUrl ?? repo.gitRemoteIdentity?.canonicalKey ?? ''
const match = /(?:github\.com[/:]|^)([^/\s:]+)\/([^/\s]+?)(?:\.git)?$/i.exec(source)
if (match) {
return { owner: match[1], repo: match[2] }
}
return null
}
function slugsEqual(a: RepoSlug | null, b: RepoSlug | null): boolean {
if (!a || !b) {
return false
}
return (
a.owner.toLowerCase() === b.owner.toLowerCase() && a.repo.toLowerCase() === b.repo.toLowerCase()
)
}
export function findRepoMatchingSlug(
repos: readonly PasteRepoCandidate[],
slug: RepoSlug
): PasteRepoCandidate | null {
return repos.find((repo) => slugsEqual(repo.slug, slug)) ?? null
}
export async function findRepoMatchingSlugForPaste(
client: RpcClient,
repos: readonly PasteRepoCandidate[],
slug: RepoSlug,
cache: Map<string, RepoSlug | null>
): Promise<PasteRepoCandidate | null> {
const projected = findRepoMatchingSlug(repos, slug)
if (projected) {
return projected
}
// Why: projected remote metadata is incomplete for SSH and GitHub Enterprise;
// ask each repo's owning runtime instead of assuming github.com URL syntax.
for (const repo of repos) {
let resolved = cache.get(repo.id)
if (!cache.has(repo.id)) {
try {
const response = await client.sendRequest('github.repoSlug', { repo: `id:${repo.id}` })
if (!response.ok && response.error.code === 'method_not_found') {
// Why: RPC availability is host-wide; avoid repeating an unsupported
// probe for every repo or on the next paste attempt.
repos.forEach((candidate) => cache.set(candidate.id, null))
return null
}
resolved = response.ok ? ((response as RpcSuccess).result as RepoSlug | null) : null
} catch {
resolved = null
}
cache.set(repo.id, resolved ?? null)
}
if (slugsEqual(resolved ?? null, slug)) {
return repo
}
}
return null
}
export async function lookupGitHubItemByNumber(
client: RpcClient,
repoId: string,
number: number
): Promise<GitHubWorkItem | null> {
const response = await client.sendRequest('github.workItem', { repo: `id:${repoId}`, number })
if (!response.ok) {
throw new Error(response.error.message)
}
const item = (response as RpcSuccess).result as GitHubWorkItem | null
return item ? { ...item, repoId } : null
}
export async function lookupGitHubItemByOwnerRepo(
client: RpcClient,
repoId: string,
slug: RepoSlug,
number: number,
type: 'issue' | 'pr'
): Promise<GitHubWorkItem | null> {
const response = await client.sendRequest('github.workItemByOwnerRepo', {
repo: `id:${repoId}`,
owner: slug.owner,
ownerRepo: slug.repo,
number,
type
})
if (!response.ok) {
throw new Error(response.error.message)
}
const item = (response as RpcSuccess).result as GitHubWorkItem | null
return item ? { ...item, repoId } : null
}
export async function lookupGitLabItemByPath(
client: RpcClient,
repoId: string,
link: NonNullable<ReturnType<typeof parseGitLabIssueOrMRLink>>
): Promise<GitLabWorkItem | null> {
const response = await client.sendRequest('gitlab.workItemByPath', {
repo: `id:${repoId}`,
host: link.slug.host,
path: link.slug.path,
iid: link.number,
type: link.type
})
if (!response.ok) {
throw new Error(response.error.message)
}
const item = (response as RpcSuccess).result as GitLabWorkItem | null
return item ? { ...item, repoId } : null
}
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import { scopeGitHubQuery, searchLinearIssues } from './smart-source-search-requests'
type Call = { method: string; params: Record<string, unknown> }
function fakeClient(result: unknown, calls: Call[]): RpcClient {
return {
sendRequest: async (method: string, params?: unknown) => {
calls.push({ method, params: (params ?? {}) as Record<string, unknown> })
return { id: '1', ok: true, result, _meta: { runtimeId: 'r' } }
}
} as unknown as RpcClient
}
describe('scopeGitHubQuery', () => {
it('passes the raw trimmed query so BOTH issues and PRs are returned', () => {
// Empty stays empty (runtime lists recent issues + PRs); no forced is:issue.
expect(scopeGitHubQuery('')).toBe('')
expect(scopeGitHubQuery(' login bug ')).toBe('login bug')
})
it('preserves an explicit is:pr / is:issue scope the user typed', () => {
expect(scopeGitHubQuery('is:pr auth')).toBe('is:pr auth')
expect(scopeGitHubQuery('is:issue auth')).toBe('is:issue auth')
})
})
describe('searchLinearIssues', () => {
it('lists assigned issues for an empty query (desktop default)', async () => {
const calls: Call[] = []
const client = fakeClient({ items: [] }, calls)
await searchLinearIssues(client, '', null)
expect(calls[0]!.method).toBe('linear.listIssues')
expect(calls[0]!.params).toMatchObject({ filter: 'assigned' })
})
it('searches when a query is present', async () => {
const calls: Call[] = []
const client = fakeClient({ items: [] }, calls)
await searchLinearIssues(client, 'bug', 'ws-1')
expect(calls[0]!.method).toBe('linear.searchIssues')
expect(calls[0]!.params).toMatchObject({ query: 'bug', workspaceId: 'ws-1' })
})
})
@@ -0,0 +1,119 @@
import type {
BaseRefSearchResult,
GitHubWorkItem,
GitLabWorkItem,
LinearIssue
} from '../../../src/shared/types'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcSuccess } from '../transport/types'
import { extractLinearIssueReadItems } from './linear-mobile-issue-read'
import { PER_REPO_FETCH_LIMIT } from './mobile-work-items'
import type { MrStateFilter } from './mobile-composer-source-types'
const GITLAB_PER_PAGE = 50
const LINEAR_LIMIT = 50
const BRANCH_LIMIT = 20
// Why: the desktop Smart picker returns BOTH issues and PRs — the runtime's
// parseTaskQuery defaults scope 'all', and an empty query lists recent items of
// both types. So pass the raw trimmed query straight through (an explicit
// `is:pr`/`is:issue` the user typed is honored by the runtime); empty stays empty
// so the runtime lists recent issues + PRs.
export function scopeGitHubQuery(query: string): string {
return query.trim()
}
export async function searchGitHubItems(
client: RpcClient,
repoId: string,
query: string
): Promise<GitHubWorkItem[]> {
const response = await client.sendRequest('github.listWorkItems', {
repo: `id:${repoId}`,
limit: PER_REPO_FETCH_LIMIT,
query: scopeGitHubQuery(query)
})
if (!response.ok) {
throw new Error(response.error.message)
}
const envelope = (response as RpcSuccess).result as { items: GitHubWorkItem[] }
// Stamp repoId so the shared row builder + create flow can attribute each item
// to the searched repo (the runtime omits it, like the desktop fetcher).
return (envelope.items ?? []).map((item) => ({ ...item, repoId }))
}
export async function searchGitLabItems(
client: RpcClient,
repoId: string,
query: string,
state: MrStateFilter
): Promise<GitLabWorkItem[]> {
const response = await client.sendRequest('gitlab.listWorkItems', {
repo: `id:${repoId}`,
state,
page: 1,
perPage: GITLAB_PER_PAGE,
query: query.trim() || undefined
})
if (!response.ok) {
throw new Error(response.error.message)
}
const envelope = (response as RpcSuccess).result as {
items: GitLabWorkItem[]
error?: { type?: string; message: string }
}
if (envelope.error?.type && envelope.error.type !== 'not_found') {
throw new Error(envelope.error.message)
}
return (envelope.items ?? []).map((item) => ({ ...item, repoId }))
}
export async function searchLinearIssues(
client: RpcClient,
query: string,
linearWorkspaceId: string | null | undefined
): Promise<LinearIssue[]> {
const trimmed = query.trim()
const response = trimmed
? await client.sendRequest('linear.searchIssues', {
query: trimmed,
limit: LINEAR_LIMIT,
workspaceId: linearWorkspaceId ?? undefined
})
: await client.sendRequest('linear.listIssues', {
// Empty query lists the viewer's assigned issues, matching desktop's
// Smart picker default (SmartWorkspaceNameField uses listLinearIssues('assigned')).
filter: 'assigned',
limit: LINEAR_LIMIT,
workspaceId: linearWorkspaceId ?? undefined
})
if (!response.ok) {
throw new Error(response.error.message)
}
// extractLinearIssueReadItems yields the mobile issue-read shape; the fields the
// row builder/create flow read (id/identifier/title/url/state/team) are a subset.
return extractLinearIssueReadItems((response as RpcSuccess).result) as unknown as LinearIssue[]
}
export async function searchBranches(
client: RpcClient,
repoId: string,
query: string
): Promise<BaseRefSearchResult[]> {
const response = await client.sendRequest(
'repo.searchRefs',
{ repo: `id:${repoId}`, query: query.trim(), limit: BRANCH_LIMIT },
{ timeoutMs: 30_000 }
)
if (!response.ok) {
throw new Error(response.error.message)
}
const result = (response as RpcSuccess).result as {
refDetails?: BaseRefSearchResult[]
refs?: string[]
}
return (
result.refDetails ??
(result.refs ?? []).map((refName) => ({ refName, localBranchName: refName }))
)
}
@@ -0,0 +1,220 @@
import { describe, expect, it } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import { createWorkspaceFromComposerSource } from './source-workspace-create'
import type { MobileComposerCreateSelection } from './mobile-composer-source-types'
type Call = { method: string; params: Record<string, unknown> }
function fakeClient(handle: (method: string, call: number) => unknown, calls: Call[]): RpcClient {
return {
sendRequest: async (method: string, params?: unknown) => {
calls.push({ method, params: (params ?? {}) as Record<string, unknown> })
const result = handle(method, calls.length)
if (result instanceof Error) {
return {
id: '1',
ok: false,
error: { code: 'x', message: result.message },
_meta: { runtimeId: 'r' }
}
}
return { id: '1', ok: true, result, _meta: { runtimeId: 'r' } }
}
} as unknown as RpcClient
}
const agent = { choice: 'blank' as const, startupCommand: undefined }
const baseArgs = {
targetRepoId: 'repo-1',
setupDecision: 'inherit' as const,
agent,
workspaceName: undefined,
note: undefined
}
describe('createWorkspaceFromComposerSource', () => {
it('creates a GitHub issue workspace linking the issue to its own repo', async () => {
const calls: Call[] = []
const client = fakeClient(() => ({ worktree: { id: 'wt-1' } }), calls)
const selection: MobileComposerCreateSelection = {
kind: 'work-item',
item: {
provider: 'github',
type: 'issue',
number: 7,
title: 'Bug',
url: 'u',
repoId: 'repo-9'
}
}
// The composer supplies the title-derived name as workspaceName; with none,
// buildTaskWorkspaceCreateParams falls back to the "<type>-<number>" slug.
const result = await createWorkspaceFromComposerSource({ client, selection, ...baseArgs })
expect(result).toEqual({ worktreeId: 'wt-1', name: 'issue-7' })
expect(calls).toHaveLength(1)
expect(calls[0]!.method).toBe('worktree.create')
expect(calls[0]!.params).toMatchObject({
repo: 'id:repo-9',
linkedIssue: 7,
displayName: 'Bug'
})
})
it('passes composer-resolved PR base fields straight through (no re-resolve)', async () => {
const calls: Call[] = []
const client = fakeClient(() => ({ worktree: { id: 'wt-2' } }), calls)
const selection: MobileComposerCreateSelection = {
kind: 'work-item',
item: {
provider: 'github',
type: 'pr',
number: 3,
title: 'Feat',
url: 'u',
repoId: 'repo-1'
},
baseBranch: 'main',
compareBaseRef: 'origin/main',
pushTarget: { remoteName: 'origin', branchName: 'feat-3' },
branchNameOverride: 'feat-3'
}
await createWorkspaceFromComposerSource({ client, selection, ...baseArgs })
expect(calls.map((c) => c.method)).toEqual(['worktree.create'])
expect(calls[0]!.params).toMatchObject({
linkedPR: 3,
baseBranch: 'main',
compareBaseRef: 'origin/main',
branchNameOverride: 'feat-3',
pushTarget: { remoteName: 'origin', branchName: 'feat-3' }
})
})
it('resolves a PR base as a fallback when the selection carries none', async () => {
const calls: Call[] = []
const client = fakeClient(
(method) =>
method === 'worktree.resolvePrBase'
? { baseBranch: 'develop' }
: { worktree: { id: 'wt-3' } },
calls
)
const selection: MobileComposerCreateSelection = {
kind: 'work-item',
item: { provider: 'github', type: 'pr', number: 4, title: 'X', url: 'u', repoId: 'repo-1' }
}
await createWorkspaceFromComposerSource({ client, selection, ...baseArgs })
expect(calls.map((c) => c.method)).toEqual(['worktree.resolvePrBase', 'worktree.create'])
expect(calls[1]!.params).toMatchObject({ baseBranch: 'develop', linkedPR: 4 })
})
it('creates a Linear workspace with workspace + org routing', async () => {
const calls: Call[] = []
const client = fakeClient(() => ({ worktree: { id: 'wt-4' } }), calls)
const selection: MobileComposerCreateSelection = {
kind: 'work-item',
item: {
provider: 'linear',
type: 'issue',
number: 0,
title: 'Ship it',
url: 'https://linear.app/acme/issue/ENG-9',
linearIdentifier: 'ENG-9',
linearWorkspaceId: 'ws-1',
linearOrganizationUrlKey: 'acme'
}
}
await createWorkspaceFromComposerSource({ client, selection, ...baseArgs })
expect(calls[0]!.params).toMatchObject({
repo: 'id:repo-1',
linkedLinearIssue: 'ENG-9',
linkedLinearIssueWorkspaceId: 'ws-1',
linkedLinearIssueOrganizationUrlKey: 'acme'
})
})
it('reuses an existing branch with a single attempt (no suffix retry)', async () => {
const calls: Call[] = []
const client = fakeClient(() => new Error('Branch "feature" already exists.'), calls)
const selection: MobileComposerCreateSelection = {
kind: 'branch',
baseBranch: 'feature',
refName: 'feature',
localBranchName: 'feature',
reuse: true,
branchNameOverride: 'feature'
}
const result = await createWorkspaceFromComposerSource({ client, selection, ...baseArgs })
expect('error' in result).toBe(true)
expect(calls).toHaveLength(1)
expect(calls[0]!.params).toMatchObject({
baseBranch: 'feature',
branchNameOverride: 'feature'
})
})
it('creates a brand-new branch by name, keeping a slashy name as the branch', async () => {
const calls: Call[] = []
const client = fakeClient(() => ({ worktree: { id: 'wt-nb' } }), calls)
const selection: MobileComposerCreateSelection = {
kind: 'new-branch',
branchName: 'feature/login'
}
const result = await createWorkspaceFromComposerSource({ client, selection, ...baseArgs })
expect(result).toEqual({ worktreeId: 'wt-nb', name: 'feature/login' })
expect(calls[0]!.params).toMatchObject({
repo: 'id:repo-1',
name: 'feature/login',
branchNameOverride: 'feature/login'
})
})
it('suppresses displayName when the name is user-edited (not auto-managed)', async () => {
const calls: Call[] = []
const client = fakeClient(() => ({ worktree: { id: 'wt-dn' } }), calls)
const selection: MobileComposerCreateSelection = {
kind: 'work-item',
item: {
provider: 'github',
type: 'issue',
number: 7,
title: 'Bug',
url: 'u',
repoId: 'repo-1'
}
}
await createWorkspaceFromComposerSource({
client,
selection,
...baseArgs,
workspaceName: 'my-name',
nameIsAutoManaged: false
})
expect(calls[0]!.params.displayName).toBeUndefined()
expect(calls[0]!.params).toMatchObject({ name: 'my-name', linkedIssue: 7 })
})
it('creates a new branch off a ref, bumping the branch on collision', async () => {
const calls: Call[] = []
const client = fakeClient(
(_m, n) => (n === 1 ? new Error('already exists locally') : { worktree: { id: 'wt-5' } }),
calls
)
const selection: MobileComposerCreateSelection = {
kind: 'branch',
baseBranch: 'main',
refName: 'main',
localBranchName: 'topic',
reuse: false,
branchNameOverride: 'topic'
}
const result = await createWorkspaceFromComposerSource({ client, selection, ...baseArgs })
expect(result).toEqual({ worktreeId: 'wt-5', name: 'topic-2' })
expect(calls).toHaveLength(2)
expect(calls[1]!.params).toMatchObject({
baseBranch: 'main',
branchNameOverride: 'topic-2',
name: 'topic-2'
})
})
})
+250
View File
@@ -0,0 +1,250 @@
import type { RpcClient } from '../transport/rpc-client'
import { resolveComposerMrBase, resolveComposerPrBase } from './composer-source-base-resolve'
import type {
MobileComposerCreateSelection,
MobileLinkedWorkItem
} from './mobile-composer-source-types'
import { resolveMobileWorkspaceCreateName } from './mobile-workspace-name'
import type { WorkspaceAgentChoice } from './workspace-agent-selection'
import {
buildTaskWorkspaceCreateParams,
type WorkspaceCreateSetupDecision,
type WorkspaceCreateTaskItem
} from './workspace-create-params'
import { createWorktreeWithNameRetry, type WorktreeCreateResult } from './worktree-create-retry'
// The agent bundle the modal already resolved: the choice drives
// buildTaskWorkspaceCreateParams for work-item sources; the explicit launch
// command is used for branch sources (which have no work-item URL to seed the draft).
export type WorkspaceCreateAgentBundle = {
choice: WorkspaceAgentChoice
startupCommand: string | undefined
}
export type CreateWorkspaceFromComposerArgs = {
client: RpcClient
selection: MobileComposerCreateSelection
targetRepoId: string
setupDecision: WorkspaceCreateSetupDecision
agent: WorkspaceCreateAgentBundle
workspaceName: string | undefined
note: string | undefined
nameIsAutoManaged?: boolean
}
export async function createWorkspaceFromComposerSource(
args: CreateWorkspaceFromComposerArgs
): Promise<WorktreeCreateResult> {
if (args.selection.kind === 'branch') {
return createBranchWorkspace({ ...args, selection: args.selection })
}
if (args.selection.kind === 'new-branch') {
return createNewBranchWorkspace({ ...args, selection: args.selection })
}
return createWorkItemWorkspace({ ...args, selection: args.selection })
}
function toTaskItem(item: MobileLinkedWorkItem, targetRepoId: string): WorkspaceCreateTaskItem {
if (item.provider === 'github') {
return {
provider: 'github',
source: {
type: item.type === 'pr' ? 'pr' : 'issue',
repoId: item.repoId ?? targetRepoId,
number: item.number,
title: item.title,
url: item.url
}
}
}
if (item.provider === 'gitlab') {
return {
provider: 'gitlab',
source: {
type: item.type === 'mr' ? 'mr' : 'issue',
repoId: item.repoId ?? targetRepoId,
number: item.number,
title: item.title,
url: item.url
}
}
}
return {
provider: 'linear',
source: {
identifier: item.linearIdentifier ?? '',
title: item.title,
url: item.url,
...(item.linearWorkspaceId ? { workspaceId: item.linearWorkspaceId } : {}),
...(item.linearOrganizationUrlKey
? { organizationUrlKey: item.linearOrganizationUrlKey }
: {})
}
}
}
async function createWorkItemWorkspace(args: {
client: RpcClient
selection: Extract<MobileComposerCreateSelection, { kind: 'work-item' }>
targetRepoId: string
setupDecision: WorkspaceCreateSetupDecision
agent: WorkspaceCreateAgentBundle
workspaceName: string | undefined
note: string | undefined
nameIsAutoManaged?: boolean
}): Promise<WorktreeCreateResult> {
const { client, selection, targetRepoId, setupDecision, agent, workspaceName, note } = args
const item = selection.item
const taskItem = toTaskItem(item, targetRepoId)
// The composer resolves PR/MR base at select time; only re-resolve as a
// fallback when a linked PR/MR reached create without one.
let baseBranch = selection.baseBranch
let compareBaseRef = selection.compareBaseRef
let pushTarget = selection.pushTarget
let branchNameOverride = selection.branchNameOverride
if (!baseBranch && item.provider !== 'linear' && (item.type === 'pr' || item.type === 'mr')) {
const repoId = item.repoId ?? targetRepoId
const resolved =
item.type === 'pr'
? await resolveComposerPrBase({ client, repoId, prNumber: item.number }).catch(() => null)
: await resolveComposerMrBase({ client, repoId, mrIid: item.number }).catch(() => null)
if (resolved) {
baseBranch = resolved.baseBranch
compareBaseRef = resolved.compareBaseRef
pushTarget = resolved.pushTarget
branchNameOverride = resolved.branchNameOverride ?? branchNameOverride
}
}
const params = buildTaskWorkspaceCreateParams({
item: taskItem,
targetRepoId,
setupDecision,
agent: agent.choice,
workspaceName,
note,
baseBranch,
compareBaseRef,
branchNameOverride,
pushTarget,
nameIsAutoManaged: args.nameIsAutoManaged
})
// buildTaskWorkspaceCreateParams computes the name; reuse it as the retry base
// so collisions still append -2, -3, ... like the blank path does.
const baseName = String(params.name)
return createWorktreeWithNameRetry({
client,
baseName,
buildParams: (name) => ({ ...params, name })
})
}
async function createBranchWorkspace(args: {
client: RpcClient
selection: Extract<MobileComposerCreateSelection, { kind: 'branch' }>
targetRepoId: string
setupDecision: WorkspaceCreateSetupDecision
agent: WorkspaceCreateAgentBundle
workspaceName: string | undefined
note: string | undefined
}): Promise<WorktreeCreateResult> {
const { client, selection, targetRepoId, setupDecision, agent, workspaceName, note } = args
const createdWithAgentId = agent.choice === 'blank' ? undefined : agent.choice
const comment = note?.trim()
const applyCommon = (params: Record<string, unknown>): Record<string, unknown> => {
if (createdWithAgentId) {
params.createdWithAgent = createdWithAgentId
}
if (comment) {
params.comment = comment
}
return params
}
if (selection.reuse) {
// Reusing a fixed existing branch: branchNameOverride is pinned to the reused
// branch, so a branch collision can't be cleared by suffixing the display
// name — fail fast instead of burning the retry budget.
const baseName = resolveMobileWorkspaceCreateName({
draft: workspaceName,
fallback: selection.localBranchName
})
return createWorktreeWithNameRetry({
client,
baseName,
maxAttempts: 1,
buildParams: (name) =>
applyCommon({
repo: `id:${targetRepoId}`,
name,
setupDecision,
baseBranch: selection.refName,
branchNameOverride: selection.localBranchName,
startupCommand: agent.startupCommand
})
})
}
// New branch off the selected ref. The retry base is the branch name so a
// collision bumps the branch itself.
const baseName = resolveMobileWorkspaceCreateName({
draft: workspaceName,
fallback: selection.branchNameOverride || selection.localBranchName
})
return createWorktreeWithNameRetry({
client,
baseName,
buildParams: (candidate) => {
const params: Record<string, unknown> = {
repo: `id:${targetRepoId}`,
name: candidate,
setupDecision,
baseBranch: selection.baseBranch,
startupCommand: agent.startupCommand
}
if (selection.branchNameOverride) {
params.branchNameOverride = candidate
}
return applyCommon(params)
}
})
}
async function createNewBranchWorkspace(args: {
client: RpcClient
selection: Extract<MobileComposerCreateSelection, { kind: 'new-branch' }>
targetRepoId: string
setupDecision: WorkspaceCreateSetupDecision
agent: WorkspaceCreateAgentBundle
workspaceName: string | undefined
note: string | undefined
}): Promise<WorktreeCreateResult> {
const { client, selection, targetRepoId, setupDecision, agent, note } = args
const createdWithAgentId = agent.choice === 'blank' ? undefined : agent.choice
const comment = note?.trim()
// A brand-new branch off the repo's default base. The typed name is kept as the
// git branch (via branchNameOverride) so a slash like `feature/login` survives;
// the runtime sanitizes the worktree folder from the same name. The retry base is
// the branch name so a collision bumps the branch (and folder) together.
return createWorktreeWithNameRetry({
client,
baseName: selection.branchName,
buildParams: (candidate) => {
const params: Record<string, unknown> = {
repo: `id:${targetRepoId}`,
name: candidate,
setupDecision,
branchNameOverride: candidate,
startupCommand: agent.startupCommand
}
if (createdWithAgentId) {
params.createdWithAgent = createdWithAgentId
}
if (comment) {
params.comment = comment
}
return params
}
})
}
@@ -0,0 +1,330 @@
import { useCallback, useMemo, useRef, useState } from 'react'
import type { GitHubWorkItem, GitLabWorkItem, LinearIssue } from '../../../src/shared/types'
import { resolveComposerManualBranchNameChange } from '../../../src/shared/composer-branch-selection'
import { resolveGitHubWorkItemIdentity } from '../../../src/shared/new-workspace/github-work-item-identity'
import { getForkPushWarning } from '../../../src/shared/new-workspace/fork-push-warning'
import type { RpcClient } from '../transport/rpc-client'
import {
buildGitHubLinkedWorkItem,
buildGitLabLinkedWorkItem,
buildLinearLinkedWorkItem,
buildSmartNameSelection,
resolveComposerBranchPick,
resolveComposerCreateSelection,
resolveLinearAutoName,
resolveWorkItemAutoName,
shouldApplyAutoName
} from './composer-linked-work-item'
import {
resolveComposerMrBase,
resolveComposerPrBase,
type ComposerHostedBase
} from './composer-source-base-resolve'
import type {
ComposerBaseState,
MobileComposerCreateSelection,
MobileLinkedWorkItem,
SmartNameSelection
} from './mobile-composer-source-types'
const EMPTY_BASE: ComposerBaseState = {}
export type UseMobileComposerSourceArgs = {
client: RpcClient | null
selectedRepoId: string | null
worktreeBranches?: readonly string[]
onError?: (message: string) => void
}
export function useMobileComposerSource(args: UseMobileComposerSourceArgs) {
const { client, selectedRepoId, worktreeBranches = [], onError } = args
const [name, setNameState] = useState('')
const [linkedWorkItem, setLinkedWorkItem] = useState<MobileLinkedWorkItem | null>(null)
const [base, setBase] = useState<ComposerBaseState>(EMPTY_BASE)
const [reuseEligibleBranch, setReuseEligibleBranch] = useState<string | null>(null)
const [reuseSelectedBranch, setReuseSelectedBranch] = useState(false)
const [forkPushWarning, setForkPushWarning] = useState<string | null>(null)
const [resolvingBase, setResolvingBase] = useState(false)
// Set when the "Create branch <name>" row is picked, so the typed name (which
// may contain slashes) is kept verbatim as the git branch (folder is sanitized).
const [branchCreateIntent, setBranchCreateIntent] = useState(false)
const lastAutoNameRef = useRef('')
const branchSelectionRef = useRef<{ refName: string; localBranchName: string } | null>(null)
// Guards async base resolution: only the latest selection applies its result.
const resolveTokenRef = useRef(0)
const setName = useCallback((value: string) => setNameState(value), [])
const applyAutoName = useCallback((suggested: string, currentName: string) => {
if (suggested && shouldApplyAutoName({ currentName, lastAutoName: lastAutoNameRef.current })) {
setNameState(suggested)
lastAutoNameRef.current = suggested
}
}, [])
const clearBaseAndBranch = useCallback(() => {
branchSelectionRef.current = null
setBranchCreateIntent(false)
setBase(EMPTY_BASE)
setReuseEligibleBranch(null)
setReuseSelectedBranch(false)
setForkPushWarning(null)
// Why: a superseding selection bumps the resolve token, so an in-flight base
// resolve's token-gated finally can no longer clear this — reset it here so
// resolvingBase never sticks true after switching sources.
setResolvingBase(false)
}, [])
// Applies an async PR/MR base resolution guarded by the current token so only
// the latest selection wins; failures clear the base and surface the error.
const runBaseResolve = useCallback(
(token: number, resolve: Promise<ComposerHostedBase>) => {
setResolvingBase(true)
void resolve
.then((result) => {
if (resolveTokenRef.current !== token) {
return
}
setBase({
baseBranch: result.baseBranch,
compareBaseRef: result.compareBaseRef,
pushTarget: result.pushTarget,
branchNameOverride: result.branchNameOverride
})
setForkPushWarning(getForkPushWarning(result))
})
.catch((error: unknown) => {
if (resolveTokenRef.current !== token) {
return
}
setBase(EMPTY_BASE)
onError?.(error instanceof Error ? error.message : 'Failed to resolve base branch.')
})
.finally(() => {
if (resolveTokenRef.current === token) {
setResolvingBase(false)
}
})
},
[onError]
)
const handleSmartGitHubItemSelect = useCallback(
(item: GitHubWorkItem) => {
const token = (resolveTokenRef.current += 1)
const identity = resolveGitHubWorkItemIdentity(item)
// Resolve the PR base against the item's OWN repo — a cross-repo accept
// switches repos then selects synchronously, so selectedRepoId is stale.
const repoId = item.repoId || selectedRepoId
setLinkedWorkItem(
buildGitHubLinkedWorkItem({
type: identity.type,
number: identity.number,
title: item.title,
url: item.url,
repoId: item.repoId
})
)
applyAutoName(
resolveWorkItemAutoName({ ...identity, title: item.title, provider: 'github' }),
name
)
clearBaseAndBranch()
if (identity.type !== 'pr' || !client || !repoId) {
return
}
runBaseResolve(
token,
resolveComposerPrBase({
client,
repoId,
prNumber: identity.number,
...(item.branchName ? { headRefName: item.branchName } : {}),
...(item.baseRefName ? { baseRefName: item.baseRefName } : {}),
...(item.isCrossRepository !== undefined
? { isCrossRepository: item.isCrossRepository }
: {})
})
)
},
[applyAutoName, clearBaseAndBranch, client, name, runBaseResolve, selectedRepoId]
)
const handleSmartGitLabItemSelect = useCallback(
(item: GitLabWorkItem) => {
const token = (resolveTokenRef.current += 1)
// Resolve the MR base against the item's OWN repo (see the GitHub handler).
const repoId = item.repoId || selectedRepoId
setLinkedWorkItem(
buildGitLabLinkedWorkItem({
type: item.type,
number: item.number,
title: item.title,
url: item.url,
repoId: item.repoId
})
)
applyAutoName(
resolveWorkItemAutoName({
type: item.type,
number: item.number,
title: item.title,
provider: 'gitlab'
}),
name
)
clearBaseAndBranch()
if (item.type !== 'mr' || !client || !repoId) {
return
}
runBaseResolve(
token,
resolveComposerMrBase({
client,
repoId,
mrIid: item.number,
...(item.branchName ? { sourceBranch: item.branchName } : {}),
...(item.baseRefName ? { targetBranch: item.baseRefName } : {}),
...(item.isCrossRepository !== undefined
? { isCrossRepository: item.isCrossRepository }
: {})
})
)
},
[applyAutoName, clearBaseAndBranch, client, name, runBaseResolve, selectedRepoId]
)
const handleSmartLinearIssueSelect = useCallback(
(issue: LinearIssue) => {
resolveTokenRef.current += 1
setLinkedWorkItem(buildLinearLinkedWorkItem(issue))
const suggested = resolveLinearAutoName(issue)
const identifierTyped = name.trim().toLowerCase() === issue.identifier.toLowerCase()
if (
suggested &&
(identifierTyped ||
shouldApplyAutoName({ currentName: name, lastAutoName: lastAutoNameRef.current }))
) {
setNameState(suggested)
lastAutoNameRef.current = suggested
}
clearBaseAndBranch()
},
[clearBaseAndBranch, name]
)
const handleSmartBranchSelect = useCallback(
(refName: string, localBranchName: string) => {
resolveTokenRef.current += 1
setLinkedWorkItem(null)
setForkPushWarning(null)
setBranchCreateIntent(false)
setResolvingBase(false)
const pick = resolveComposerBranchPick({
refName,
localBranchName,
currentName: name,
lastAutoName: lastAutoNameRef.current,
worktreeBranches
})
setReuseEligibleBranch(pick.reuseEligibleBranch)
setReuseSelectedBranch(pick.reuseSelectedBranch)
setBase(pick.base)
branchSelectionRef.current = { refName, localBranchName }
if (pick.name !== undefined) {
setNameState(pick.name)
lastAutoNameRef.current = pick.lastAutoName ?? ''
}
},
[name, worktreeBranches]
)
// Picking "Create branch <name>": name the workspace and mark a new-branch
// intent so the typed (possibly slashy) name is kept verbatim as the git branch.
const handleSmartCreateBranch = useCallback(
(branchName: string) => {
resolveTokenRef.current += 1
setLinkedWorkItem(null)
clearBaseAndBranch()
setNameState(branchName)
lastAutoNameRef.current = branchName
setBranchCreateIntent(true)
},
[clearBaseAndBranch]
)
const handleClearSmartNameSelection = useCallback(() => {
resolveTokenRef.current += 1
setLinkedWorkItem(null)
clearBaseAndBranch()
setResolvingBase(false)
if (name === lastAutoNameRef.current) {
setNameState('')
lastAutoNameRef.current = ''
}
}, [clearBaseAndBranch, name])
const handleBranchNameOverrideChange = useCallback(
(value: string) => {
const next = resolveComposerManualBranchNameChange({
value,
pushTarget: base.pushTarget,
forkPushWarning
})
setBase({
...base,
branchNameOverride: next.branchNameOverride,
pushTarget: next.pushTarget
})
setForkPushWarning(next.forkPushWarning)
},
[base, forkPushWarning]
)
const smartNameSelection = useMemo<SmartNameSelection | null>(
() => buildSmartNameSelection({ linkedWorkItem, baseBranch: base.baseBranch }),
[base.baseBranch, linkedWorkItem]
)
const createSelection = useMemo<MobileComposerCreateSelection | null>(
() =>
resolveComposerCreateSelection({
linkedWorkItem,
base,
branch: branchSelectionRef.current,
reuseEligibleBranch,
reuseSelectedBranch,
branchCreateIntent,
name
}),
[base, branchCreateIntent, linkedWorkItem, name, reuseEligibleBranch, reuseSelectedBranch]
)
// Auto-managed until the user edits the name away from the last derived value;
// desktop suppresses the workspace displayName once the name is user-edited.
const isNameAutoManaged = !name.trim() || name === lastAutoNameRef.current
return {
name,
setName,
linkedWorkItem,
branchNameOverride: base.branchNameOverride,
handleBranchNameOverrideChange,
reuseEligibleBranch,
reuseSelectedBranch,
setReuseSelectedBranch,
forkPushWarning,
resolvingBase,
isNameAutoManaged,
smartNameSelection,
createSelection,
handleSmartGitHubItemSelect,
handleSmartGitLabItemSelect,
handleSmartLinearIssueSelect,
handleSmartBranchSelect,
handleSmartCreateBranch,
handleClearSmartNameSelection
}
}
export type MobileComposerSource = ReturnType<typeof useMobileComposerSource>
@@ -0,0 +1,233 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { GitHubWorkItem, GitLabWorkItem } from '../../../src/shared/types'
import {
buildSmartWorkspaceSourceRows,
getSmartWorkspaceEmptyHint,
type SmartNameMode,
type SmartWorkspaceSourceRow
} from '../../../src/shared/new-workspace/smart-workspace-source-results'
import type { RpcClient } from '../transport/rpc-client'
import { fanOutSmartSearch, type SmartFanOutResult } from './smart-source-fan-out'
import type { MrStateFilter } from './mobile-composer-source-types'
import {
findRepoMatchingSlugForPaste,
lookupGitHubItemByNumber,
lookupGitHubItemByOwnerRepo,
lookupGitLabItemByPath,
resolvePasteIntent,
type PasteRepoCandidate
} from './smart-source-paste-intent'
const DEBOUNCE_MS = 200
const RESULT_LIMIT = 36
export type SmartCrossRepoPrompt = {
link: { slug: { owner: string; repo: string }; number: number; type: 'issue' | 'pr' }
matchingRepo: PasteRepoCandidate
}
export type UseSmartWorkspaceSourceArgs = {
client: RpcClient | null
enabled: boolean
mode: SmartNameMode
query: string
repoId: string | null
githubAvailable: boolean
gitlabAvailable: boolean
linearAvailable: boolean
mrStateFilter: MrStateFilter
linearWorkspaceId?: string | null
repos: readonly PasteRepoCandidate[]
}
const EMPTY_FAN: SmartFanOutResult = {
githubItems: [],
gitlabItems: [],
linearIssues: [],
branches: [],
needsGitHubRemote: false,
error: ''
}
type PasteResolved = { github: GitHubWorkItem | null; gitlab: GitLabWorkItem | null }
export function useSmartWorkspaceSource(args: UseSmartWorkspaceSourceArgs) {
const {
client,
enabled,
mode,
query,
repoId,
githubAvailable,
gitlabAvailable,
linearAvailable,
mrStateFilter,
linearWorkspaceId,
repos
} = args
const [fan, setFan] = useState<SmartFanOutResult>(EMPTY_FAN)
const [paste, setPaste] = useState<PasteResolved>({ github: null, gitlab: null })
const [loading, setLoading] = useState(false)
const [crossRepoPrompt, setCrossRepoPrompt] = useState<SmartCrossRepoPrompt | null>(null)
// Why: preserve results across keystrokes (debounce) but drop them the moment
// the mode/repo changes so one provider's rows never render under another tab.
const scopeRef = useRef('')
const dismissedPasteRef = useRef<string>('')
const repoSlugCacheRef = useRef<Map<string, { owner: string; repo: string } | null>>(new Map())
useEffect(() => {
if (!client || !enabled || mode === 'text') {
setFan(EMPTY_FAN)
setPaste({ github: null, gitlab: null })
setLoading(false)
setCrossRepoPrompt(null)
return
}
const scope = `${mode}:${repoId ?? ''}`
const scopeChanged = scopeRef.current !== scope
scopeRef.current = scope
if (scopeChanged) {
setFan(EMPTY_FAN)
setPaste({ github: null, gitlab: null })
setCrossRepoPrompt(null)
}
setLoading(true)
let stale = false
const timer = setTimeout(() => {
void runSmartSearch({
client,
mode,
query,
repoId,
githubAvailable,
gitlabAvailable,
linearAvailable,
mrStateFilter,
linearWorkspaceId,
repos,
dismissedPasteRef,
repoSlugCache: repoSlugCacheRef.current
})
.then((result) => {
if (stale) {
return
}
setFan(result.fan)
setPaste(result.paste)
setCrossRepoPrompt(result.crossRepoPrompt)
setLoading(false)
})
.catch(() => {
if (!stale) {
setLoading(false)
}
})
}, DEBOUNCE_MS)
return () => {
stale = true
clearTimeout(timer)
}
}, [
client,
enabled,
mode,
query,
repoId,
githubAvailable,
gitlabAvailable,
linearAvailable,
mrStateFilter,
linearWorkspaceId,
repos
])
const rows = useMemo<SmartWorkspaceSourceRow[]>(
() =>
buildSmartWorkspaceSourceRows({
branches: fan.branches,
githubItems: paste.github ? [paste.github] : fan.githubItems,
gitlabAvailable,
gitlabItems: paste.gitlab ? [paste.gitlab] : fan.gitlabItems,
linearAvailable,
linearIssues: fan.linearIssues,
mode,
resultLimit: RESULT_LIMIT,
value: query
}),
[fan, gitlabAvailable, linearAvailable, mode, paste, query]
)
const dismissCrossRepoPrompt = useCallback(() => {
dismissedPasteRef.current = query.trim()
setCrossRepoPrompt(null)
}, [query])
return {
rows,
loading,
error: fan.error,
needsGitHubRemote: fan.needsGitHubRemote,
emptyHint: getSmartWorkspaceEmptyHint(mode),
crossRepoPrompt,
dismissCrossRepoPrompt
}
}
async function runSmartSearch(args: {
client: RpcClient
mode: SmartNameMode
query: string
repoId: string | null
githubAvailable: boolean
gitlabAvailable: boolean
linearAvailable: boolean
mrStateFilter: MrStateFilter
linearWorkspaceId: string | null | undefined
repos: readonly PasteRepoCandidate[]
dismissedPasteRef: { current: string }
repoSlugCache: Map<string, { owner: string; repo: string } | null>
}): Promise<{
fan: SmartFanOutResult
paste: PasteResolved
crossRepoPrompt: SmartCrossRepoPrompt | null
}> {
const { client, mode, query, repoId, repos, dismissedPasteRef, repoSlugCache } = args
const fan = await fanOutSmartSearch(args)
const paste: PasteResolved = { github: null, gitlab: null }
let crossRepoPrompt: SmartCrossRepoPrompt | null = null
const intent =
mode === 'branches' || dismissedPasteRef.current === query.trim()
? null
: resolvePasteIntent(query)
if (intent && repoId) {
try {
if (intent.kind === 'github-number') {
paste.github = await lookupGitHubItemByNumber(client, repoId, intent.number)
} else if (intent.kind === 'github-link') {
const matchingRepo = await findRepoMatchingSlugForPaste(
client,
repos,
intent.link.slug,
repoSlugCache
)
if (matchingRepo && matchingRepo.id !== repoId) {
crossRepoPrompt = { link: intent.link, matchingRepo }
} else {
paste.github = await lookupGitHubItemByOwnerRepo(
client,
repoId,
intent.link.slug,
intent.link.number,
intent.link.type
)
}
} else if (intent.kind === 'gitlab-link') {
paste.gitlab = await lookupGitLabItemByPath(client, repoId, intent.link)
}
} catch {
// Best-effort paste resolution; fall back to the fan-out results.
}
}
return { fan, paste, crossRepoPrompt }
}

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