mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 00:03:20 +00:00
Integrate merge-ready PR review fixes
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
+1987
-551
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,
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
})
|
||||
@@ -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",
|
||||
|
||||
@@ -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 |
@@ -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.
|
||||
@@ -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'),
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
getActiveProviderRateLimits,
|
||||
getInactiveProviderUsage,
|
||||
getUsageBarState,
|
||||
getWindowResetLabel,
|
||||
hasActiveProviderUsage,
|
||||
UsageBar
|
||||
} from '../../../src/components/AccountUsage'
|
||||
@@ -40,6 +41,14 @@ export default function AccountsScreen() {
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [busyAccountId, setBusyAccountId] = useState<string | null>(null)
|
||||
|
||||
// Why: the reset countdown must stay fresh while the screen sits open —
|
||||
// snapshot pushes only arrive when the desktop's rate-limit poll completes.
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setNow(Date.now()), 60_000)
|
||||
return () => clearInterval(id)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!hostId) {
|
||||
return
|
||||
@@ -163,12 +172,14 @@ export default function AccountsScreen() {
|
||||
usedPercent={activeSessionBar.usedPercent}
|
||||
unavailable={activeSessionBar.unavailable}
|
||||
loading={activeSessionBar.loading}
|
||||
resetText={getWindowResetLabel(activeUsage, 'session', now)}
|
||||
/>
|
||||
<UsageBar
|
||||
label="7d"
|
||||
usedPercent={activeWeeklyBar.usedPercent}
|
||||
unavailable={activeWeeklyBar.unavailable}
|
||||
loading={activeWeeklyBar.loading}
|
||||
resetText={getWindowResetLabel(activeUsage, 'weekly', now)}
|
||||
/>
|
||||
</View>
|
||||
) : null}
|
||||
@@ -211,12 +222,14 @@ export default function AccountsScreen() {
|
||||
usedPercent={sessionBar.usedPercent}
|
||||
unavailable={sessionBar.unavailable}
|
||||
loading={sessionBar.loading}
|
||||
resetText={getWindowResetLabel(usage, 'session', now)}
|
||||
/>
|
||||
<UsageBar
|
||||
label="7d"
|
||||
usedPercent={weeklyBar.usedPercent}
|
||||
unavailable={weeklyBar.unavailable}
|
||||
loading={weeklyBar.loading}
|
||||
resetText={getWindowResetLabel(usage, 'weekly', now)}
|
||||
/>
|
||||
</View>
|
||||
{usage?.error ? (
|
||||
|
||||
@@ -1,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
|
||||
}}
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Generated
+97
-39
@@ -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
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
|
||||
overrides:
|
||||
xcode>uuid: 11.1.1
|
||||
@@ -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
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -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'
|
||||
@@ -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(
|
||||
|
||||
@@ -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: []
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
Reference in New Issue
Block a user