mirror of
https://github.com/stablyai/orca.git
synced 2026-10-25 16:03:53 +00:00
Merge main and harden reopen-closed tab lifecycle
Resolve terminal retirement and close-reason conflicts while preserving user-only reopen history. Bound bulk history work, harden runtime ownership and cross-platform cwd remapping, and suppress autonomous parked exits without changing pinned-close reliability.
This commit is contained in:
@@ -6,3 +6,6 @@
|
||||
/config/scripts/run-internal-dev-setup.mjs text eol=lf
|
||||
/config/scripts/verify-cli-bin.mjs text eol=lf
|
||||
/config/scripts/verify-release-required-assets.mjs text eol=lf
|
||||
/skill-guides/*.md text eol=lf
|
||||
/skills/*/SKILL.md text eol=lf
|
||||
/src/cli/bundled-skill-guides.ts text eol=lf
|
||||
|
||||
@@ -6,6 +6,8 @@ on:
|
||||
- '.github/workflows/computer-e2e.yml'
|
||||
- 'config/electron-builder.config.cjs'
|
||||
- 'config/scripts/build-computer-macos.mjs'
|
||||
- 'config/scripts/build-windows-cli-launcher.mjs'
|
||||
- 'config/scripts/build-windows-cli-launcher.test.mjs'
|
||||
- 'config/scripts/computer-e2e-workflow.test.mjs'
|
||||
- 'config/scripts/computer-use-skill-guidance.test.mjs'
|
||||
- 'config/scripts/computer-use-smoke.mjs'
|
||||
@@ -22,12 +24,15 @@ on:
|
||||
- 'native/computer-use-macos/**'
|
||||
- 'native/computer-use-linux/**'
|
||||
- 'native/computer-use-windows/**'
|
||||
- 'native/windows-cli-launcher/**'
|
||||
- 'skills/computer-use/SKILL.md'
|
||||
- 'src/cli/**'
|
||||
- 'src/main/computer/**'
|
||||
- 'src/main/runtime/rpc/dispatcher.ts'
|
||||
- 'src/main/runtime/rpc/errors.ts'
|
||||
- 'src/main/runtime/rpc/methods/computer*.ts'
|
||||
- 'src/main/ssh/ssh-remote-cli-launcher.ts'
|
||||
- 'src/main/ssh/ssh-remote-cli-launcher.test.ts'
|
||||
- 'src/shared/computer-use-*.ts'
|
||||
- 'tests/e2e/computer-linux.e2e.ts'
|
||||
- 'tests/e2e/computer-mac.e2e.ts'
|
||||
@@ -73,6 +78,7 @@ jobs:
|
||||
- run: >-
|
||||
pnpm vitest run
|
||||
config/scripts/build-windows-cli-launcher.test.mjs
|
||||
src/main/ssh/ssh-remote-cli-launcher.test.ts
|
||||
config/scripts/computer-e2e-workflow.test.mjs
|
||||
config/scripts/computer-use-skill-guidance.test.mjs
|
||||
config/scripts/computer-use-smoke.test.mjs
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -9,6 +9,8 @@ on:
|
||||
- 'tests/e2e/fixtures/terminal-emoji-table.md'
|
||||
- 'tests/e2e/helpers/**'
|
||||
- 'tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts'
|
||||
- 'tests/e2e/terminal-webgl-atlas-budget.spec.ts'
|
||||
- 'config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch'
|
||||
- 'src/renderer/src/components/sidebar/SidebarHeader.tsx'
|
||||
- 'src/renderer/src/lib/pane-manager/**'
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Typecheck
|
||||
run: npx tsc --noEmit
|
||||
run: pnpm typecheck
|
||||
|
||||
- name: Test
|
||||
run: pnpm test
|
||||
|
||||
@@ -77,6 +77,11 @@ jobs:
|
||||
- name: Enforce max-lines ratchet (no new bypasses)
|
||||
run: pnpm run check:max-lines-ratchet
|
||||
|
||||
# Why: the CLI embeds guide content while the skills CLI installs generated
|
||||
# projections from the repository, so stale output would split those two truths.
|
||||
- name: Verify bundled skill guides
|
||||
run: pnpm run verify:bundled-skill-guides
|
||||
|
||||
# Why: project-owned type declarations must live in .ts so tsc
|
||||
# actually checks them. TypeScript's skipLibCheck: true (inherited
|
||||
# from @electron-toolkit/tsconfig) silently widens unresolved names
|
||||
|
||||
@@ -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:
|
||||
|
||||
+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
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
Monitor and steer your agents from your phone — get notified when an agent finishes and send follow-ups from anywhere.
|
||||
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.27](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.31](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
|
||||
Pair with your desktop app to monitor and steer your agents from your phone.
|
||||
|
||||
- **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) or [join TestFlight](https://testflight.apple.com/join/YjeGMQBA)
|
||||
- **Android:** [Download APK 0.0.27](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk)
|
||||
- **Android:** [Download APK 0.0.31](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
@@ -261,6 +261,9 @@ Want to contribute or run locally? See our [CONTRIBUTING.md](.github/CONTRIBUTIN
|
||||
<img src="docs/assets/star-history.png" alt="GitHub star history chart for stablyai/orca" width="880" />
|
||||
</p>
|
||||
|
||||
## Signed Builds
|
||||
Windows code signing sponored/provided by [SignPath.io](https://signpath.io), certificate by [SignPath Foundation](https://signpath.org).
|
||||
|
||||
## License
|
||||
|
||||
Orca is free and open source under the [MIT License](LICENSE).
|
||||
|
||||
@@ -62,6 +62,9 @@ module.exports = {
|
||||
'!mobile{,/**/*}',
|
||||
'!native{,/**/*}',
|
||||
'!skills{,/**/*}',
|
||||
// Why: authoritative guide markdown is compiled into out/cli; shipping the
|
||||
// authoring sources too would duplicate content without a runtime consumer.
|
||||
'!skill-guides{,/**/*}',
|
||||
'!tests{,/**/*}',
|
||||
'!Casks{,/**/*}',
|
||||
'!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.md}',
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1586
-39
File diff suppressed because it is too large
Load Diff
@@ -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 PLATFORMS = [
|
||||
'linux-x64',
|
||||
@@ -42,7 +43,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: {
|
||||
@@ -51,9 +67,15 @@ 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
|
||||
// so a watcher-only change always deploys beside the matching relay host.
|
||||
const relayContent = readFileSync(join(outDir, 'relay.js'))
|
||||
const hash = createHash('sha256').update(relayContent).digest('hex').slice(0, 12)
|
||||
const watcherContent = readFileSync(join(outDir, 'relay-watcher.js'))
|
||||
const hash = createHash('sha256')
|
||||
.update(relayContent)
|
||||
.update(watcherContent)
|
||||
.digest('hex')
|
||||
.slice(0, 12)
|
||||
writeFileSync(join(outDir, '.version'), `${RELAY_VERSION}+${hash}`)
|
||||
|
||||
console.log(`Built relay for ${platform} → ${outDir}/relay.js`)
|
||||
|
||||
@@ -5,7 +5,11 @@ import { existsSync, mkdirSync } from 'node:fs'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
process.exit(0)
|
||||
// Why: electron-builder treats a skipped native build like success and can
|
||||
// continue toward a Windows package whose declared orca.exe does not exist.
|
||||
throw new Error(
|
||||
'Windows CLI launcher compilation requires a Windows host; refusing to package without it.'
|
||||
)
|
||||
}
|
||||
|
||||
const repoRoot = resolve(import.meta.dirname, '../..')
|
||||
|
||||
@@ -4,10 +4,33 @@ 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, '../..')
|
||||
// Why: cold csc.exe startup exceeds Vitest's 5s unit budget on hosted Windows;
|
||||
// keep the larger allowance scoped to the real compiler integration test.
|
||||
function itWindows(name, test) {
|
||||
const runner = process.platform === 'win32' ? it : it.skip
|
||||
runner(name, { timeout: 15_000 }, test)
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ describe('electron-builder config', () => {
|
||||
'!mobile{,/**/*}',
|
||||
'!native{,/**/*}',
|
||||
'!skills{,/**/*}',
|
||||
'!skill-guides{,/**/*}',
|
||||
'!tests{,/**/*}',
|
||||
'!Casks{,/**/*}',
|
||||
'!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.md}',
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { constants } from 'node:fs'
|
||||
import { access, mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { parse } from 'yaml'
|
||||
|
||||
const SCRIPT_DIR = import.meta.dirname
|
||||
const REPO_ROOT = path.resolve(SCRIPT_DIR, '..', '..')
|
||||
|
||||
const CANONICAL_GUIDE_NAMES = [
|
||||
'computer-use',
|
||||
'linear-tickets',
|
||||
'orca-cli',
|
||||
'orca-emulator',
|
||||
'orca-emulator-android',
|
||||
'orca-linear',
|
||||
'orca-per-workspace-env',
|
||||
'orchestration'
|
||||
]
|
||||
|
||||
// Why: old discovery stubs can outlive a rename indefinitely, so aliases are
|
||||
// a compatibility ledger: add entries for renames, but never remove them.
|
||||
const GUIDE_ALIASES = {
|
||||
'computer-use': [],
|
||||
'linear-tickets': [],
|
||||
'orca-cli': [],
|
||||
'orca-emulator': [],
|
||||
'orca-emulator-android': [],
|
||||
'orca-linear': [],
|
||||
'orca-per-workspace-env': [],
|
||||
orchestration: []
|
||||
}
|
||||
|
||||
function normalizeMarkdown(markdown) {
|
||||
return markdown.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
||||
}
|
||||
|
||||
function parseFrontmatter(markdown, sourcePath) {
|
||||
const normalized = normalizeMarkdown(markdown)
|
||||
const match = /^---\s*\n([\s\S]*?)\n---\s*(?:\n|$)/.exec(normalized)
|
||||
if (!match) {
|
||||
throw new Error(`Guide source has no YAML frontmatter: ${sourcePath}`)
|
||||
}
|
||||
let values
|
||||
try {
|
||||
values = parse(match[1])
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Guide source has invalid YAML frontmatter: ${sourcePath}: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
}
|
||||
if (
|
||||
!values ||
|
||||
typeof values !== 'object' ||
|
||||
typeof values.name !== 'string' ||
|
||||
typeof values.description !== 'string'
|
||||
) {
|
||||
throw new Error(`Guide source must declare name and description: ${sourcePath}`)
|
||||
}
|
||||
return {
|
||||
name: values.name,
|
||||
description: values.description.replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
}
|
||||
|
||||
function constantName(name) {
|
||||
return `${name.replace(/-/g, '_').toUpperCase()}_MARKDOWN`
|
||||
}
|
||||
|
||||
function serializeEmbeddedModule(guides) {
|
||||
const markdownConstants = guides
|
||||
.map(
|
||||
(guide) =>
|
||||
`// oxfmt-ignore\nconst ${constantName(guide.name)} = ${JSON.stringify(guide.markdown)}`
|
||||
)
|
||||
.join('\n\n')
|
||||
const guideEntries = guides
|
||||
.map((guide) => {
|
||||
const markdownConstant = constantName(guide.name)
|
||||
return [
|
||||
' {',
|
||||
` name: ${JSON.stringify(guide.name)},`,
|
||||
` description: ${JSON.stringify(guide.description)},`,
|
||||
` markdown: ${markdownConstant},`,
|
||||
` fullMarkdown: ${markdownConstant},`,
|
||||
` aliases: ${JSON.stringify(guide.aliases)}`,
|
||||
' }'
|
||||
].join('\n')
|
||||
})
|
||||
.join(',\n')
|
||||
|
||||
return `// Generated by config/scripts/generate-bundled-skill-guides.mjs. Do not edit.\n\nexport type BundledSkillGuide = {\n readonly name: string\n readonly description: string\n readonly markdown: string\n readonly fullMarkdown: string\n readonly aliases: readonly string[]\n}\n\n${markdownConstants}\n\n// Why: no current guide has bundled reference documents, so --full is byte-identical for now.\n// oxfmt-ignore\nexport const BUNDLED_SKILL_GUIDES = [\n${guideEntries}\n] as const satisfies readonly BundledSkillGuide[]\n`
|
||||
}
|
||||
|
||||
function assertAliasContract(guides) {
|
||||
const canonicalNames = new Set(guides.map((guide) => guide.name))
|
||||
const seenAliases = new Set()
|
||||
for (const guide of guides) {
|
||||
for (const alias of guide.aliases) {
|
||||
if (canonicalNames.has(alias)) {
|
||||
throw new Error(`Guide alias collides with canonical name: ${alias}`)
|
||||
}
|
||||
if (seenAliases.has(alias)) {
|
||||
throw new Error(`Guide alias is assigned more than once: ${alias}`)
|
||||
}
|
||||
seenAliases.add(alias)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function buildArtifacts(repoRoot = REPO_ROOT) {
|
||||
const guideRoot = path.join(repoRoot, 'skill-guides')
|
||||
const sourceFiles = (await readdir(guideRoot, { withFileTypes: true }))
|
||||
.filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
|
||||
.map((entry) => entry.name.slice(0, -3))
|
||||
.sort((left, right) => left.localeCompare(right, 'en'))
|
||||
const expectedNames = [...CANONICAL_GUIDE_NAMES].sort((left, right) =>
|
||||
left.localeCompare(right, 'en')
|
||||
)
|
||||
if (JSON.stringify(sourceFiles) !== JSON.stringify(expectedNames)) {
|
||||
throw new Error(
|
||||
`Guide sources must match the canonical topic list.\nExpected: ${expectedNames.join(', ')}\nFound: ${sourceFiles.join(', ')}`
|
||||
)
|
||||
}
|
||||
|
||||
const guides = []
|
||||
const projections = []
|
||||
for (const name of expectedNames) {
|
||||
const sourcePath = path.join(guideRoot, `${name}.md`)
|
||||
// Why: Git may render text with native EOLs despite repository policy; the
|
||||
// embedded guide and generated projection must have one platform-neutral identity.
|
||||
const markdown = normalizeMarkdown(await readFile(sourcePath, 'utf8'))
|
||||
const frontmatter = parseFrontmatter(markdown, path.relative(repoRoot, sourcePath))
|
||||
if (frontmatter.name !== name) {
|
||||
throw new Error(`Guide source ${name}.md declares mismatched name ${frontmatter.name}`)
|
||||
}
|
||||
const aliases = GUIDE_ALIASES[name]
|
||||
guides.push({ name, description: frontmatter.description, markdown, aliases })
|
||||
projections.push({
|
||||
path: path.join(repoRoot, 'skills', name, 'SKILL.md'),
|
||||
content: markdown
|
||||
})
|
||||
}
|
||||
assertAliasContract(guides)
|
||||
|
||||
return [
|
||||
{
|
||||
path: path.join(repoRoot, 'src', 'cli', 'bundled-skill-guides.ts'),
|
||||
content: serializeEmbeddedModule(guides)
|
||||
},
|
||||
...projections
|
||||
]
|
||||
}
|
||||
|
||||
async function writeArtifacts(artifacts) {
|
||||
for (const artifact of artifacts) {
|
||||
await mkdir(path.dirname(artifact.path), { recursive: true })
|
||||
await writeFile(artifact.path, artifact.content, 'utf8')
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyArtifacts(artifacts, repoRoot = REPO_ROOT) {
|
||||
const stale = []
|
||||
for (const artifact of artifacts) {
|
||||
try {
|
||||
await access(artifact.path, constants.R_OK)
|
||||
if ((await readFile(artifact.path, 'utf8')) !== artifact.content) {
|
||||
stale.push(artifact.path)
|
||||
}
|
||||
} catch {
|
||||
stale.push(artifact.path)
|
||||
}
|
||||
}
|
||||
if (stale.length > 0) {
|
||||
throw new Error(
|
||||
`Generated bundled skill guides are stale:\n${stale
|
||||
.map((filePath) => path.relative(repoRoot, filePath))
|
||||
.join('\n')}\nRun node config/scripts/generate-bundled-skill-guides.mjs --write.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const artifacts = await buildArtifacts()
|
||||
await (process.argv.includes('--write') ? writeArtifacts : verifyArtifacts)(artifacts)
|
||||
}
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === import.meta.filename) {
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : error)
|
||||
process.exitCode = 1
|
||||
})
|
||||
}
|
||||
|
||||
export {
|
||||
CANONICAL_GUIDE_NAMES,
|
||||
GUIDE_ALIASES,
|
||||
assertAliasContract,
|
||||
buildArtifacts,
|
||||
normalizeMarkdown,
|
||||
parseFrontmatter,
|
||||
serializeEmbeddedModule,
|
||||
verifyArtifacts,
|
||||
writeArtifacts
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { BUNDLED_SKILL_GUIDES } from '../../src/cli/bundled-skill-guides'
|
||||
import {
|
||||
CANONICAL_GUIDE_NAMES,
|
||||
GUIDE_ALIASES,
|
||||
assertAliasContract,
|
||||
buildArtifacts,
|
||||
normalizeMarkdown,
|
||||
parseFrontmatter,
|
||||
verifyArtifacts,
|
||||
writeArtifacts
|
||||
} from './generate-bundled-skill-guides.mjs'
|
||||
|
||||
const projectDir = path.resolve(import.meta.dirname, '..', '..')
|
||||
const temporaryDirectories = []
|
||||
|
||||
async function createFixture() {
|
||||
const root = await mkdtemp(path.join(tmpdir(), 'orca-bundled-skill-guides-'))
|
||||
temporaryDirectories.push(root)
|
||||
await Promise.all([
|
||||
cp(path.join(projectDir, 'skill-guides'), path.join(root, 'skill-guides'), {
|
||||
recursive: true
|
||||
}),
|
||||
cp(path.join(projectDir, 'skills'), path.join(root, 'skills'), { recursive: true }),
|
||||
mkdir(path.join(root, 'src', 'cli'), { recursive: true })
|
||||
])
|
||||
return root
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true }))
|
||||
)
|
||||
})
|
||||
|
||||
describe('bundled skill guide generator', () => {
|
||||
it('keeps every first-phase fat projection byte-identical to its authoritative source', async () => {
|
||||
for (const name of CANONICAL_GUIDE_NAMES) {
|
||||
const source = await readFile(path.join(projectDir, 'skill-guides', `${name}.md`))
|
||||
const projection = await readFile(path.join(projectDir, 'skills', name, 'SKILL.md'))
|
||||
expect(projection, name).toEqual(source)
|
||||
}
|
||||
})
|
||||
|
||||
it('embeds canonical names, discovery descriptions, Markdown, and append-only aliases', async () => {
|
||||
expect(BUNDLED_SKILL_GUIDES.map((guide) => guide.name)).toEqual(
|
||||
[...CANONICAL_GUIDE_NAMES].sort((left, right) => left.localeCompare(right, 'en'))
|
||||
)
|
||||
|
||||
for (const guide of BUNDLED_SKILL_GUIDES) {
|
||||
const source = await readFile(
|
||||
path.join(projectDir, 'skill-guides', `${guide.name}.md`),
|
||||
'utf8'
|
||||
)
|
||||
const frontmatter = parseFrontmatter(source, `${guide.name}.md`)
|
||||
expect(guide.description).toBe(frontmatter.description)
|
||||
expect(guide.markdown).toBe(source)
|
||||
expect(guide.fullMarkdown).toBe(source)
|
||||
expect(guide.aliases).toEqual(GUIDE_ALIASES[guide.name])
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps CLI guide examples safe across shells and Linux command names', async () => {
|
||||
for (const name of ['orca-cli', 'computer-use', 'orca-emulator', 'orca-emulator-android']) {
|
||||
const source = await readFile(path.join(projectDir, 'skill-guides', `${name}.md`), 'utf8')
|
||||
|
||||
expect(source).toContain('ORCA_CLI_COMMAND')
|
||||
expect(source).toContain('orca-dev')
|
||||
expect(source).toContain('orca-ide')
|
||||
expect(source).toContain('PowerShell')
|
||||
expect(source).toContain('cmd.exe')
|
||||
expect(source).toMatch(/^ORCA .+--json$/mu)
|
||||
// Why: bare command lines can launch GNOME Orca, while shell variables make
|
||||
// the same guide unusable from PowerShell and cmd.exe.
|
||||
expect(source).not.toMatch(/^orca /mu)
|
||||
expect(source).not.toMatch(/\$ORCA(?:_|\b)/u)
|
||||
}
|
||||
})
|
||||
|
||||
it('builds deterministic artifacts and verifies the checked-in outputs', async () => {
|
||||
const first = await buildArtifacts(projectDir)
|
||||
const second = await buildArtifacts(projectDir)
|
||||
|
||||
expect(second).toEqual(first)
|
||||
await expect(verifyArtifacts(first, projectDir)).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('generates platform-identical output from CRLF guide sources', async () => {
|
||||
const expected = await buildArtifacts(projectDir)
|
||||
const root = await createFixture()
|
||||
for (const name of CANONICAL_GUIDE_NAMES) {
|
||||
const sourcePath = path.join(root, 'skill-guides', `${name}.md`)
|
||||
const source = await readFile(sourcePath, 'utf8')
|
||||
await writeFile(sourcePath, source.replaceAll('\n', '\r\n'))
|
||||
}
|
||||
|
||||
const actual = await buildArtifacts(root)
|
||||
expect(actual.map((artifact) => artifact.content)).toEqual(
|
||||
expected.map((artifact) => artifact.content)
|
||||
)
|
||||
})
|
||||
|
||||
it('pins guide sources, projections, and embedded output to LF in Git', async () => {
|
||||
const attributes = await readFile(path.join(projectDir, '.gitattributes'), 'utf8')
|
||||
expect(normalizeMarkdown(attributes)).toContain('/skill-guides/*.md text eol=lf\n')
|
||||
expect(normalizeMarkdown(attributes)).toContain('/skills/*/SKILL.md text eol=lf\n')
|
||||
expect(normalizeMarkdown(attributes)).toContain(
|
||||
'/src/cli/bundled-skill-guides.ts text eol=lf\n'
|
||||
)
|
||||
})
|
||||
|
||||
it('reports stale outputs and write mode repairs all projections', async () => {
|
||||
const root = await createFixture()
|
||||
const artifacts = await buildArtifacts(root)
|
||||
|
||||
await expect(verifyArtifacts(artifacts, root)).rejects.toThrow(
|
||||
'src/cli/bundled-skill-guides.ts'
|
||||
)
|
||||
await writeArtifacts(artifacts)
|
||||
await expect(verifyArtifacts(artifacts, root)).resolves.toBeUndefined()
|
||||
|
||||
await writeFile(path.join(root, 'skills', 'computer-use', 'SKILL.md'), 'stale\n')
|
||||
await expect(verifyArtifacts(artifacts, root)).rejects.toThrow('skills/computer-use/SKILL.md')
|
||||
})
|
||||
|
||||
it('rejects mismatched source names and ambiguous aliases', async () => {
|
||||
const root = await createFixture()
|
||||
await writeFile(
|
||||
path.join(root, 'skill-guides', 'computer-use.md'),
|
||||
'---\nname: wrong\ndescription: present\n---\n'
|
||||
)
|
||||
await expect(buildArtifacts(root)).rejects.toThrow('declares mismatched name wrong')
|
||||
|
||||
expect(() =>
|
||||
assertAliasContract([
|
||||
{ name: 'first', aliases: ['legacy'] },
|
||||
{ name: 'second', aliases: ['legacy'] }
|
||||
])
|
||||
).toThrow('assigned more than once')
|
||||
expect(() =>
|
||||
assertAliasContract([
|
||||
{ name: 'first', aliases: ['second'] },
|
||||
{ name: 'second', aliases: [] }
|
||||
])
|
||||
).toThrow('collides with canonical name')
|
||||
})
|
||||
})
|
||||
@@ -40,7 +40,7 @@ describe('orca CLI skill guidance', () => {
|
||||
'`task-create` is also forbidden because it records coordinator-owned tracking state'
|
||||
)
|
||||
expect(skill).toContain(
|
||||
'orca worktree create --name <task-name> --no-parent --agent codex --prompt'
|
||||
'ORCA worktree create --name <task-name> --no-parent --agent codex --prompt'
|
||||
)
|
||||
expect(skill).toContain('codex --model gpt-5.5 -c model_reasoning_effort="xhigh"')
|
||||
expect(skill).toContain('wait only for TUI readiness if needed to avoid losing input')
|
||||
|
||||
@@ -198,6 +198,13 @@ describe('orchestration skill guidance', () => {
|
||||
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')
|
||||
|
||||
@@ -7,6 +7,19 @@ const projectDir = resolve(import.meta.dirname, '../..')
|
||||
const packageJson = JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf8'))
|
||||
|
||||
describe('Electron runtime package contract', () => {
|
||||
it('keeps shared WebGL atlas invalidation reproducible from vendored source', () => {
|
||||
const patch = readFileSync(
|
||||
join(projectDir, 'config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch'),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
expect(patch).toContain('diff --git a/src/Types.ts b/src/Types.ts')
|
||||
expect(patch).toContain('readonly clearModelGeneration: number')
|
||||
expect(patch).toContain('const generation = this._atlas.clearModelGeneration')
|
||||
expect(patch).toContain('this.clearModelGeneration++')
|
||||
expect(patch).toContain('this._atlas._clearModelGeneration||0')
|
||||
})
|
||||
|
||||
it('keeps root postinstall as the single Electron binary install owner', () => {
|
||||
expect(packageJson.scripts.postinstall).toBe('node config/scripts/rebuild-native-deps.mjs')
|
||||
expect(packageJson.pnpm.onlyBuiltDependencies).not.toContain('electron')
|
||||
@@ -91,6 +104,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')
|
||||
@@ -464,6 +548,9 @@ describe('Electron runtime package contract', () => {
|
||||
expect(packageScripts['test:e2e:terminal-rendering-golden']).toContain(
|
||||
'terminal-raw-emoji-table-scroll-restore.spec.ts'
|
||||
)
|
||||
expect(packageScripts['test:e2e:terminal-rendering-golden']).toContain(
|
||||
'terminal-webgl-atlas-budget.spec.ts'
|
||||
)
|
||||
expect(packageScripts['test:e2e:terminal-rendering-golden']).not.toContain(
|
||||
'terminal-long-table-scroll-restore.spec.ts'
|
||||
)
|
||||
@@ -477,6 +564,8 @@ describe('Electron runtime package contract', () => {
|
||||
expect(runStep?.run).toContain('pnpm run test:e2e:terminal-rendering-golden')
|
||||
}
|
||||
expect(pullRequestPaths).toContain('tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts')
|
||||
expect(pullRequestPaths).toContain('tests/e2e/terminal-webgl-atlas-budget.spec.ts')
|
||||
expect(pullRequestPaths).toContain('config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch')
|
||||
expect(pullRequestPaths).toContain('tests/e2e/fixtures/terminal-emoji-table.md')
|
||||
expect(pullRequestPaths).toContain('src/renderer/src/lib/pane-manager/**')
|
||||
expect(releaseBuildNeeds).not.toContain('terminal-rendering-golden')
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1,257 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdtemp, realpath, rm } from 'node:fs/promises'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { promisify } from 'node:util'
|
||||
import { build } from 'esbuild'
|
||||
|
||||
const ENTRY_PATH = resolve('out/main/parcel-watcher-process-entry.js')
|
||||
const POOL_SOURCE = resolve('src/main/ipc/runtime-watcher-process-pool.ts')
|
||||
const FAILURE_SOURCE = resolve('src/main/ipc/parcel-watcher-process-failure.ts')
|
||||
const REGISTRY_SOURCE = resolve('src/main/ipc/parcel-watcher-child-registry.ts')
|
||||
const MAX_CHILD_RSS_KIB = 128 * 1024
|
||||
const MAX_CHILD_CPU_PERCENT = 50
|
||||
const MAX_QUARANTINE_RSS_KIB = 512 * 1024
|
||||
const MAX_QUARANTINE_CPU_PERCENT = 100
|
||||
const PHYSICAL_CHILD_CAP = 8
|
||||
const WAIT_TIMEOUT_MS = 15_000
|
||||
const execFileAsync = promisify(execFile)
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
async function main() {
|
||||
if (!existsSync(ENTRY_PATH)) {
|
||||
throw new Error(`Missing ${ENTRY_PATH}; run pnpm run build:electron-vite first`)
|
||||
}
|
||||
|
||||
const createdRoots = []
|
||||
const exactPids = new Set()
|
||||
let bundleDir
|
||||
let pool
|
||||
let result
|
||||
try {
|
||||
bundleDir = await mkdtemp(join(tmpdir(), 'orca-runtime-watcher-resource-'))
|
||||
const { RuntimeWatcherProcessPool, WatcherProcessFailure, reserveWatcherChild } =
|
||||
await loadProbe(bundleDir)
|
||||
const roots = []
|
||||
for (let index = 0; index < 5; index++) {
|
||||
const createdRoot = await mkdtemp(join(tmpdir(), `orca-watcher-resource-${index}-`))
|
||||
createdRoots.push(createdRoot)
|
||||
roots.push(await realpath(createdRoot))
|
||||
}
|
||||
|
||||
pool = new RuntimeWatcherProcessPool()
|
||||
await Promise.all(roots.map((rootPath) => pool.subscribe(rootPath, () => undefined, {}, {})))
|
||||
const healthyPids = activeChildPids(pool)
|
||||
assertPidCount(healthyPids, 1, 'five healthy roots')
|
||||
healthyPids.forEach((pid) => exactPids.add(pid))
|
||||
const healthyResources = await sampleResources(healthyPids)
|
||||
assertResourceBudget(healthyResources, 'healthy pool', {
|
||||
maxTotalRssKiB: MAX_CHILD_RSS_KIB,
|
||||
maxTotalCpuPercent: MAX_CHILD_CPU_PERCENT
|
||||
})
|
||||
assertRegistryCapacity(reserveWatcherChild, 7)
|
||||
|
||||
const sharedSlot = [...pool.activeSlots][0]
|
||||
const failure = new WatcherProcessFailure(
|
||||
'resource probe synthetic shared-shard fault',
|
||||
'supervisor',
|
||||
'supervisor_crash_fuse'
|
||||
)
|
||||
for (const record of sharedSlot.supervisor.records.values()) {
|
||||
record.hooks.onTerminalError?.(failure)
|
||||
}
|
||||
await waitFor(() => !isPidAlive(healthyPids[0]), 'healthy child exit')
|
||||
|
||||
await Promise.all(
|
||||
roots.slice(0, 4).map((rootPath) => pool.subscribe(rootPath, () => undefined, {}, {}))
|
||||
)
|
||||
const quarantinePids = activeChildPids(pool)
|
||||
assertPidCount(quarantinePids, 4, 'four fault-quarantine roots')
|
||||
quarantinePids.forEach((pid) => exactPids.add(pid))
|
||||
const quarantineResources = await sampleResources(quarantinePids)
|
||||
assertResourceBudget(quarantineResources, 'quarantine pool', {
|
||||
maxTotalRssKiB: MAX_QUARANTINE_RSS_KIB,
|
||||
maxTotalCpuPercent: MAX_QUARANTINE_CPU_PERCENT
|
||||
})
|
||||
// Four live quarantine children leave four reservations before the global
|
||||
// eight-physical-child ceiling rejects the next launch.
|
||||
assertRegistryCapacity(reserveWatcherChild, 4)
|
||||
|
||||
result = {
|
||||
hostPid: process.pid,
|
||||
healthy: summarizeResources(healthyResources),
|
||||
quarantine: summarizeResources(quarantineResources),
|
||||
physicalChildCap: PHYSICAL_CHILD_CAP,
|
||||
budgets: {
|
||||
maxChildRssKiB: MAX_CHILD_RSS_KIB,
|
||||
maxChildCpuPercent: MAX_CHILD_CPU_PERCENT,
|
||||
maxQuarantineRssKiB: MAX_QUARANTINE_RSS_KIB,
|
||||
maxQuarantineCpuPercent: MAX_QUARANTINE_CPU_PERCENT,
|
||||
maxPhysicalRssKiB: MAX_CHILD_RSS_KIB * PHYSICAL_CHILD_CAP,
|
||||
maxPhysicalCpuPercent: MAX_CHILD_CPU_PERCENT * PHYSICAL_CHILD_CAP
|
||||
},
|
||||
exactPidCleanup: true
|
||||
}
|
||||
} finally {
|
||||
pool?.dispose()
|
||||
await Promise.all(
|
||||
[...exactPids].map((pid) => waitFor(() => !isPidAlive(pid), `watcher PID ${pid} cleanup`))
|
||||
)
|
||||
await Promise.all([
|
||||
...createdRoots.map((rootPath) => rm(rootPath, { recursive: true, force: true })),
|
||||
bundleDir ? rm(bundleDir, { recursive: true, force: true }) : Promise.resolve()
|
||||
])
|
||||
}
|
||||
console.log(JSON.stringify(result))
|
||||
}
|
||||
|
||||
async function loadProbe(bundleDir) {
|
||||
const outfile = join(bundleDir, 'watcher-resource-probe.cjs')
|
||||
await build({
|
||||
stdin: {
|
||||
contents: [
|
||||
`export { RuntimeWatcherProcessPool } from ${JSON.stringify(POOL_SOURCE)}`,
|
||||
`export { WatcherProcessFailure } from ${JSON.stringify(FAILURE_SOURCE)}`,
|
||||
`export { reserveWatcherChild } from ${JSON.stringify(REGISTRY_SOURCE)}`
|
||||
].join('\n'),
|
||||
resolveDir: process.cwd(),
|
||||
sourcefile: 'runtime-file-watcher-resource-probe-entry.ts',
|
||||
loader: 'ts'
|
||||
},
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'cjs',
|
||||
outfile,
|
||||
external: ['@parcel/watcher', 'electron'],
|
||||
logLevel: 'silent'
|
||||
})
|
||||
return require(outfile)
|
||||
}
|
||||
|
||||
function activeChildPids(pool) {
|
||||
return [...pool.activeSlots]
|
||||
.map((slot) => slot.supervisor.child?.pid)
|
||||
.filter((pid) => Number.isInteger(pid))
|
||||
}
|
||||
|
||||
function assertPidCount(pids, expected, label) {
|
||||
if (new Set(pids).size !== expected || pids.some((pid) => !isPidAlive(pid))) {
|
||||
throw new Error(
|
||||
`${label} used ${new Set(pids).size} live watcher children; expected ${expected}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function assertRegistryCapacity(reserveWatcherChild, availableReservations) {
|
||||
const releases = []
|
||||
try {
|
||||
for (let index = 0; index < availableReservations; index++) {
|
||||
const release = reserveWatcherChild()
|
||||
if (!release) {
|
||||
throw new Error(`Watcher child registry rejected reservation ${index + 1}`)
|
||||
}
|
||||
releases.push(release)
|
||||
}
|
||||
const unexpectedReservation = reserveWatcherChild()
|
||||
if (unexpectedReservation) {
|
||||
unexpectedReservation()
|
||||
throw new Error('Watcher child registry exceeded its global physical-process cap')
|
||||
}
|
||||
} finally {
|
||||
releases.forEach((release) => release())
|
||||
}
|
||||
}
|
||||
|
||||
async function sampleResources(pids) {
|
||||
await new Promise((resolveDelay) => setTimeout(resolveDelay, 250))
|
||||
return Promise.all(pids.map((pid) => sampleProcess(pid)))
|
||||
}
|
||||
|
||||
async function sampleProcess(pid) {
|
||||
if (process.platform === 'win32') {
|
||||
const command =
|
||||
`$p=Get-Process -Id ${pid};` +
|
||||
`[pscustomobject]@{rssKiB=[math]::Round($p.WorkingSet64/1KB);cpuSeconds=$p.CPU}|ConvertTo-Json -Compress`
|
||||
const first = JSON.parse(
|
||||
(await execFileAsync('powershell.exe', ['-NoProfile', '-Command', command])).stdout.trim()
|
||||
)
|
||||
await new Promise((resolveDelay) => setTimeout(resolveDelay, 250))
|
||||
const second = JSON.parse(
|
||||
(await execFileAsync('powershell.exe', ['-NoProfile', '-Command', command])).stdout.trim()
|
||||
)
|
||||
return {
|
||||
pid,
|
||||
rssKiB: second.rssKiB,
|
||||
cpuPercent: Math.max(0, (second.cpuSeconds - first.cpuSeconds) * 400)
|
||||
}
|
||||
}
|
||||
const { stdout } = await execFileAsync('ps', ['-o', 'rss=', '-o', '%cpu=', '-p', String(pid)], {
|
||||
env: { ...process.env, LC_ALL: 'C' }
|
||||
})
|
||||
const [rssKiB, cpuPercent] = stdout.trim().split(/\s+/).map(Number)
|
||||
return { pid, rssKiB, cpuPercent }
|
||||
}
|
||||
|
||||
function assertResourceBudget(resources, label, { maxTotalRssKiB, maxTotalCpuPercent }) {
|
||||
for (const resource of resources) {
|
||||
if (!Number.isFinite(resource.rssKiB) || resource.rssKiB <= 0) {
|
||||
throw new Error(`Missing RSS evidence for watcher PID ${resource.pid}`)
|
||||
}
|
||||
if (resource.rssKiB > MAX_CHILD_RSS_KIB) {
|
||||
throw new Error(
|
||||
`Watcher PID ${resource.pid} RSS ${resource.rssKiB} KiB exceeded ${MAX_CHILD_RSS_KIB} KiB`
|
||||
)
|
||||
}
|
||||
if (!Number.isFinite(resource.cpuPercent) || resource.cpuPercent < 0) {
|
||||
throw new Error(`Missing CPU evidence for watcher PID ${resource.pid}`)
|
||||
}
|
||||
if (resource.cpuPercent > MAX_CHILD_CPU_PERCENT) {
|
||||
throw new Error(
|
||||
`Watcher PID ${resource.pid} CPU ${resource.cpuPercent}% exceeded ${MAX_CHILD_CPU_PERCENT}%`
|
||||
)
|
||||
}
|
||||
}
|
||||
const summary = summarizeResources(resources)
|
||||
if (summary.totalRssKiB > maxTotalRssKiB) {
|
||||
throw new Error(
|
||||
`${label} RSS ${summary.totalRssKiB} KiB exceeded aggregate budget ${maxTotalRssKiB} KiB`
|
||||
)
|
||||
}
|
||||
if (summary.sampledCpuPercent > maxTotalCpuPercent) {
|
||||
throw new Error(
|
||||
`${label} CPU ${summary.sampledCpuPercent}% exceeded aggregate budget ${maxTotalCpuPercent}%`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function summarizeResources(resources) {
|
||||
return {
|
||||
childCount: resources.length,
|
||||
pids: resources.map(({ pid }) => pid),
|
||||
totalRssKiB: resources.reduce((total, { rssKiB }) => total + rssKiB, 0),
|
||||
sampledCpuPercent: resources.reduce((total, { cpuPercent }) => total + cpuPercent, 0)
|
||||
}
|
||||
}
|
||||
|
||||
function isPidAlive(pid) {
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function waitFor(predicate, label) {
|
||||
const deadline = Date.now() + WAIT_TIMEOUT_MS
|
||||
while (!predicate()) {
|
||||
if (Date.now() >= deadline) {
|
||||
throw new Error(`Timed out waiting for ${label}`)
|
||||
}
|
||||
await new Promise((resolveDelay) => setTimeout(resolveDelay, 25))
|
||||
}
|
||||
}
|
||||
|
||||
await main()
|
||||
@@ -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.7m">
|
||||
<title>downloads: 4.7m</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 5.6m">
|
||||
<title>downloads: 5.6m</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.7m</text>
|
||||
<text x="93.5" y="14">4.7m</text>
|
||||
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">5.6m</text>
|
||||
<text x="93.5" y="14">5.6m</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 943 B After Width: | Height: | Size: 943 B |
@@ -36,7 +36,7 @@
|
||||
|
||||
Supervisa y dirige a tus agentes desde el teléfono — recibe una notificación cuando un agente termine y envía instrucciones de seguimiento desde cualquier lugar.
|
||||
|
||||
[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
[App Store de iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [APK para Android](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
|
||||
Vincúlala con tu app de escritorio para supervisar y dirigir a tus agentes desde el teléfono.
|
||||
|
||||
- **iOS:** [Descargar desde App Store](https://apps.apple.com/us/app/orca-ide/id6766130217)
|
||||
- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk)
|
||||
- **Android:** [Descargar el APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
スマートフォンからエージェントを監視・操作 — エージェントの完了を通知で受け取り、どこからでもフォローアップを送信できます。
|
||||
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile)
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk) · [ドキュメント →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
|
||||
デスクトップアプリとペアリングして、スマートフォンからエージェントを監視・操作できます。
|
||||
|
||||
- **iOS:** [App Store からダウンロード](https://apps.apple.com/us/app/orca-ide/id6766130217)
|
||||
- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk)
|
||||
- **Android:** [APK をダウンロード](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
휴대폰에서 에이전트를 모니터링하고 조종하세요 — 에이전트가 완료되면 알림을 받고 어디서든 후속 지시를 보낼 수 있습니다.
|
||||
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk) · [문서 →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
|
||||
데스크톱 앱과 페어링해 휴대폰에서 에이전트를 모니터링하고 조종하세요.
|
||||
|
||||
- **iOS:** [App Store에서 다운로드](https://apps.apple.com/us/app/orca-ide/id6766130217)
|
||||
- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk)
|
||||
- **Android:** [APK 다운로드](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
Monitore e conduza seus agentes pelo celular — receba uma notificação quando um agente terminar e envie instruções de acompanhamento de qualquer lugar.
|
||||
|
||||
[App Store para iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.27](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
[App Store para iOS](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [APK Android 0.0.31](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
|
||||
Conecte ao app desktop para monitorar e conduzir seus agentes pelo celular.
|
||||
|
||||
- **iOS:** [Baixar na App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) ou [entrar no TestFlight](https://testflight.apple.com/join/YjeGMQBA)
|
||||
- **Android:** [Baixar APK 0.0.27](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk)
|
||||
- **Android:** [Baixar APK 0.0.31](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
用手机监控并指挥你的智能体 — 智能体完成时收到通知,随时随地发送后续指令。
|
||||
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile)
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk) · [文档 →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -227,7 +227,7 @@ yay -S stably-orca-bin
|
||||
与桌面应用配对,用手机监控并指挥你的智能体。
|
||||
|
||||
- **iOS:** [从 App Store 下载](https://apps.apple.com/us/app/orca-ide/id6766130217)
|
||||
- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.27/app-release.apk)
|
||||
- **Android:** [下载 APK](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
# 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 (and Antigravity's/Devin's) cmd fast path is the bare, directly
|
||||
spawnable `.cmd` path. These agents launch the hook command as a program
|
||||
(argv[0]), not through cmd.exe, so the launcher cannot lead with a cmd builtin
|
||||
such as `if` — that argv[0] is unspawnable and fails every hook. A missing
|
||||
script therefore surfaces a normal launch failure on this fast path; only
|
||||
launchers that already require a real interpreter (encoded PowerShell, Git
|
||||
Bash) drain a missing script. Paths cmd.exe cannot invoke verbatim fall back to
|
||||
the encoded PowerShell launcher, which owns stdin for the missing case.
|
||||
- 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.
|
||||
@@ -35,7 +35,7 @@ authority.
|
||||
|
||||
| Capability | Preferred behavior | Compatibility behavior |
|
||||
| ----------------------- | ------------------------------------------------- | --------------------------------------------------------------------------------------- |
|
||||
| `worktree-list-z` | NUL-delimited worktree paths | Line-block parser for Git before `worktree list -z` |
|
||||
| `worktree-list-z` | NUL-delimited worktree paths with `prunable` marks | Line-block parser for Git before `worktree list -z` (2.36); the `prunable`/`locked` annotations still parse on Git 2.31–2.35, and a path-existence probe restores `prunable` detection for Git before 2.31 |
|
||||
| `rev-parse-path-format` | Absolute repo metadata paths | Resolve legacy relative output against the scanned repo |
|
||||
| `for-each-ref-exclude` | Exclude remote HEAD before the output limit | Request extra refs, then filter remote HEAD in Orca |
|
||||
| `merge-tree-write-tree` | Derive real-merge conflicts and no-op tree proofs | Omit the conflict summary and keep conservative branch cleanup behavior before Git 2.38 |
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# Kerberos / GSSAPI SSH Authentication
|
||||
|
||||
Orca's ssh2-based SSH stack cannot speak `gssapi-with-mic` — the ssh2 library
|
||||
has no GSSAPI userauth support, and adding it would mean forking ssh2's
|
||||
protocol layer plus shipping the `kerberos` native module (MIT krb5 / Heimdal /
|
||||
Windows SSPI) as a prebuilt Electron dependency on three platforms. Instead,
|
||||
hosts that need Kerberos ride the existing **system OpenSSH transport** — the
|
||||
same parallel transport already used for `ProxyCommand`/`ProxyJump` hosts —
|
||||
which delegates GSSAPI, ticket lookup (`kinit` cache, Windows domain logon),
|
||||
and credential delegation to the platform's own `ssh` binary on macOS, Linux,
|
||||
and Windows (Win32-OpenSSH uses SSPI).
|
||||
|
||||
## Transport selection
|
||||
|
||||
Two tiers, deliberately asymmetric because RHEL-family distros ship
|
||||
`GSSAPIAuthentication yes` in the global `/etc/ssh/ssh_config`, which makes
|
||||
`ssh -G` report GSSAPI enabled for *every* host:
|
||||
|
||||
1. **Proactive** — a target with `gssapiAuthentication: true` (parsed from an
|
||||
explicit `GSSAPIAuthentication yes` in the host's `~/.ssh/config` block, or
|
||||
set on the target directly) tries the system-ssh probe first. If that fails
|
||||
(e.g. no ticket), the connect falls through to the normal ssh2 key/agent
|
||||
path where passphrase/password prompts remain available — OpenSSH semantics
|
||||
allow other auth methods alongside GSSAPI.
|
||||
2. **Auth-failure fallback** — when ssh2 exhausts key/agent auth and the
|
||||
`ssh -G`-resolved config enables GSSAPI (`isGssapiSystemSshFallbackCandidate`
|
||||
in `ssh-connection-utils.ts`), the connection retries over system ssh
|
||||
*before* prompting the user for credentials. Kerberos-only hosts on
|
||||
distro-default configs connect this way; hosts where keys work never leave
|
||||
the ssh2 path.
|
||||
|
||||
Manual (non-ssh-config) targets flagged for GSSAPI get an explicit
|
||||
`-o GSSAPIAuthentication=yes` in `system-ssh-args.ts`; config-backed targets
|
||||
inherit the option from their own `Host` block since the system binary re-reads
|
||||
ssh_config.
|
||||
|
||||
Both tiers work headless (`orca serve`): the system-ssh probe needs no
|
||||
credential callbacks, and GSSAPI itself is non-interactive once a ticket
|
||||
exists.
|
||||
@@ -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,564 @@
|
||||
# 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 a plain user
|
||||
terminal's process group (nohup-style survivors remain user intent there).
|
||||
For **agent sessions**, close/kill additionally terminates the snapshotted
|
||||
descendant tree — including detached-pgid children the PTY's SIGHUP cannot
|
||||
reach — via `pty-descendant-termination.ts` (bounded fresh snapshot with
|
||||
same-turn coalescing, SIGTERM, grace window, then identity-safe SIGKILL).
|
||||
Completed process tables are never reused as signal targets, and identity
|
||||
checks use C-locale timestamps from the source scan. Later requests start a
|
||||
fresh same-turn-coalesced successor inside their own deadline instead of
|
||||
waiting behind older scans. A session is marked as terminating before capture
|
||||
and keeps request ownership through natural exit, so reattach, duplicate kill,
|
||||
and graceful-to-immediate upgrade cannot race the snapshot; descendant signals
|
||||
still require the exact root session/handle to be live. Windows and SSH-hosted
|
||||
PTYs keep the previous foreground-tree contract for now.
|
||||
- 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'),
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
"expo": {
|
||||
"name": "Orca",
|
||||
"slug": "orca-mobile",
|
||||
"version": "0.0.27",
|
||||
"version": "0.0.31",
|
||||
"orientation": "default",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "automatic",
|
||||
@@ -75,7 +75,7 @@
|
||||
"allowBackup": false,
|
||||
"permissions": ["RECORD_AUDIO", "MODIFY_AUDIO_SETTINGS"],
|
||||
"package": "com.stably.orca.mobile",
|
||||
"versionCode": 7
|
||||
"versionCode": 8
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
|
||||
@@ -11,6 +11,7 @@ import { RpcClientProvider } from '../src/transport/client-context'
|
||||
import { getNotificationNavigationPath } from '../src/notifications/notification-routing'
|
||||
import { loadHosts } from '../src/transport/host-store'
|
||||
import { extractPairingCodeFromUrl } from '../src/transport/pairing'
|
||||
import { recoverMobileRelayPairing } from '../src/transport/mobile-relay-pairing-recovery'
|
||||
|
||||
// Why: keeps the native splash screen visible until the React tree is mounted
|
||||
// and ready to render. Without this the user sees a blank white/black frame
|
||||
@@ -35,6 +36,12 @@ export default function RootLayout() {
|
||||
const router = useRouter()
|
||||
const handledNotificationIdsRef = useRef<Set<string>>(new Set())
|
||||
|
||||
useEffect(() => {
|
||||
// Why: pairing publication is journaled across process death; startup must
|
||||
// reconcile the server result before another scan can replace that journal.
|
||||
void recoverMobileRelayPairing()
|
||||
}, [])
|
||||
|
||||
// Why: route `orca://pair?...` deep links to the confirm screen so
|
||||
// the same pairing flow runs whether the link arrived via QR scan,
|
||||
// paste, AirDrop, Messages, or `xcrun simctl openurl`. getInitialURL
|
||||
@@ -171,6 +178,10 @@ export default function RootLayout() {
|
||||
<Stack.Screen name="pair-scan" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="pair" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="pair-confirm" options={{ headerShown: false }} />
|
||||
<Stack.Screen
|
||||
name="notification-opt-in"
|
||||
options={{ headerShown: false, presentation: 'modal', gestureEnabled: false }}
|
||||
/>
|
||||
<Stack.Screen name="settings" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="terminal-settings" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="browser-settings" options={{ headerShown: false }} />
|
||||
|
||||
@@ -9,11 +9,11 @@ import { colors, spacing, typography } from '../src/theme/mobile-theme'
|
||||
import { ConnectionLog } from '../src/components/ConnectionLog'
|
||||
import { loadHosts } from '../src/transport/host-store'
|
||||
import { connectionLogStore } from '../src/transport/connection-log-buffer'
|
||||
import { useHostClient } from '../src/transport/client-context'
|
||||
import {
|
||||
useHostClient,
|
||||
useLastConnectedAt,
|
||||
useReconnectAttempt
|
||||
} from '../src/transport/client-context'
|
||||
} from '../src/transport/client-context-connection-metrics'
|
||||
import { buildConnectionDiagnosticsReport } from '../src/diagnostics/connection-diagnostics-report'
|
||||
import type { ConnectionLogEntry, HostProfile } from '../src/transport/types'
|
||||
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
SectionList,
|
||||
Pressable,
|
||||
ActivityIndicator,
|
||||
Alert
|
||||
Alert,
|
||||
RefreshControl
|
||||
} from 'react-native'
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useFocusEffect, useLocalSearchParams, usePathname, useRouter } from 'expo-router'
|
||||
@@ -33,10 +34,13 @@ import { removeHostAndCloseClient } from '../../../src/transport/host-removal-li
|
||||
import {
|
||||
useHostClient,
|
||||
useCloseHost,
|
||||
useForceReconnect,
|
||||
useReconnectAttempt,
|
||||
useLastConnectedAt
|
||||
useForceReconnect
|
||||
} from '../../../src/transport/client-context'
|
||||
import { useWorktreeResync } from '../../../src/transport/use-worktree-resync'
|
||||
import {
|
||||
useLastConnectedAt,
|
||||
useReconnectAttempt
|
||||
} from '../../../src/transport/client-context-connection-metrics'
|
||||
import {
|
||||
classifyConnection,
|
||||
type ConnectionVerdict
|
||||
@@ -59,7 +63,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'
|
||||
@@ -459,6 +463,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.
|
||||
@@ -613,6 +625,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) => {
|
||||
@@ -1229,6 +1252,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}
|
||||
@@ -1428,6 +1461,7 @@ export function HostScreen({
|
||||
client={client}
|
||||
hostId={hostId}
|
||||
existingWorktreePaths={existingWorktreePaths}
|
||||
existingWorktrees={worktrees}
|
||||
onVisibleChange={(visible) => {
|
||||
newWorktreeModalVisibleRef.current = visible
|
||||
}}
|
||||
|
||||
@@ -56,12 +56,11 @@ import {
|
||||
saveTerminalTextScale,
|
||||
type MobileTerminalLinkOpenMode
|
||||
} from '../../../../src/storage/preferences'
|
||||
import { useHostClient, useForceReconnect } from '../../../../src/transport/client-context'
|
||||
import {
|
||||
useHostClient,
|
||||
useForceReconnect,
|
||||
useReconnectAttempt,
|
||||
useLastConnectedAt
|
||||
} from '../../../../src/transport/client-context'
|
||||
useLastConnectedAt,
|
||||
useReconnectAttempt
|
||||
} from '../../../../src/transport/client-context-connection-metrics'
|
||||
import {
|
||||
classifyConnection,
|
||||
verdictDisplayLabel
|
||||
@@ -185,6 +184,7 @@ import { useLiveWorktreeName } from '../../../../src/session/use-live-worktree-n
|
||||
import {
|
||||
acceptSessionSnapshot,
|
||||
applyClosedTabTombstones,
|
||||
confirmsMirroredTabSelection,
|
||||
type AppliedSnapshotMarker
|
||||
} from '../../../../src/session/session-tab-snapshot-gate'
|
||||
import {
|
||||
@@ -198,8 +198,14 @@ import {
|
||||
isDictationSetupRequiredError
|
||||
} from '../../../../src/dictation/mobile-dictation-setup'
|
||||
import { TerminalPaneView } from '../../../../src/session/TerminalPaneView'
|
||||
import {
|
||||
activateMobileSessionTab,
|
||||
focusMobileTerminal
|
||||
} from '../../../../src/session/mobile-session-tab-activation'
|
||||
import { MobileTerminalDiagnostics } from '../../../../src/session/mobile-terminal-diagnostics'
|
||||
import {
|
||||
getRepoIdFromMobileWorktreeId,
|
||||
getActiveTabIdForHandle,
|
||||
isFileExistsErrorMessage,
|
||||
isGestureMouseTrackingMode,
|
||||
MOBILE_SESSION_STATUS_LABELS,
|
||||
@@ -207,7 +213,8 @@ import {
|
||||
TERMINAL_GESTURE_INPUT_FLUSH_DELAY_MS,
|
||||
TERMINAL_GESTURE_INPUT_MAX_PENDING_SEQUENCES,
|
||||
TERMINAL_GESTURE_INPUT_MAX_QUEUE_AGE_MS,
|
||||
TERMINAL_GESTURE_INPUT_REFILL_PER_SECOND
|
||||
TERMINAL_GESTURE_INPUT_REFILL_PER_SECOND,
|
||||
updateTerminalCwdFromStreamEvent
|
||||
} from '../../../../src/session/mobile-session-route-helpers'
|
||||
import { resolveMarkdownFloatingActionsBottom } from '../../../../src/session/markdown-floating-actions-layout'
|
||||
import { resolveTabStripScrollOffset } from '../../../../src/session/tab-strip-scroll'
|
||||
@@ -246,21 +253,6 @@ type TerminalLiveAccessoryInput = ReturnType<typeof createTerminalLiveAccessoryI
|
||||
|
||||
const TERMINAL_KEYBOARD_DISMISS_ACTION_SHEET_FALLBACK_MS = 450
|
||||
|
||||
function getActiveTabIdForHandle(
|
||||
tabs: MobileSessionTab[],
|
||||
terminalHandle: string | null
|
||||
): string | null {
|
||||
if (!terminalHandle) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
tabs.find(
|
||||
(tab): tab is Extract<MobileSessionTab, { type: 'terminal' }> =>
|
||||
tab.type === 'terminal' && tab.terminal === terminalHandle
|
||||
)?.id ?? terminalHandle
|
||||
)
|
||||
}
|
||||
|
||||
function MarkdownReader({
|
||||
documentId,
|
||||
doc,
|
||||
@@ -801,21 +793,6 @@ function FileReader({
|
||||
return renderSourceText(doc.content)
|
||||
}
|
||||
|
||||
function updateTerminalCwdFromStreamEvent(
|
||||
handle: string,
|
||||
data: Record<string, unknown>,
|
||||
terminalCwd: Map<string, string>
|
||||
): void {
|
||||
if (!('cwd' in data)) {
|
||||
return
|
||||
}
|
||||
if (typeof data.cwd === 'string' && data.cwd.trim().length > 0) {
|
||||
terminalCwd.set(handle, data.cwd)
|
||||
return
|
||||
}
|
||||
terminalCwd.delete(handle)
|
||||
}
|
||||
|
||||
export default function SessionScreen() {
|
||||
const {
|
||||
hostId,
|
||||
@@ -1032,6 +1009,7 @@ export default function SessionScreen() {
|
||||
const terminalUnsubsRef = useRef<Map<string, () => void>>(new Map())
|
||||
const subscribingHandlesRef = useRef<Set<string>>(new Set())
|
||||
const initializedHandlesRef = useRef<Set<string>>(new Set())
|
||||
const terminalDiagnosticsRef = useRef(new MobileTerminalDiagnostics())
|
||||
// Why: WebViews load xterm.js from CDN asynchronously. Hidden WebViews
|
||||
// (opacity:0) may have delayed JS execution on iOS. We must not subscribe
|
||||
// until the WebView has fired web-ready, otherwise init() messages queue
|
||||
@@ -1080,7 +1058,6 @@ 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)
|
||||
|
||||
const activeSessionTab = sessionTabs.find((tab) => tab.id === activeSessionTabId) ?? null
|
||||
const {
|
||||
clearPendingLiveInputCommit,
|
||||
@@ -1317,6 +1294,7 @@ export default function SessionScreen() {
|
||||
terminalUnsubsRef.current.get(handle)?.()
|
||||
terminalUnsubsRef.current.delete(handle)
|
||||
subscribingHandlesRef.current.delete(handle)
|
||||
terminalDiagnosticsRef.current.terminalUnsubscribed(handle)
|
||||
subscribeSeqRef.current.set(handle, (subscribeSeqRef.current.get(handle) ?? 0) + 1)
|
||||
// Why: a fresh subscription will land on a new server-side state machine
|
||||
// run (or the same one with a higher seq); reset the high-water mark so
|
||||
@@ -1331,6 +1309,7 @@ export default function SessionScreen() {
|
||||
terminalUnsubsRef.current.clear()
|
||||
subscribingHandlesRef.current.clear()
|
||||
initializedHandlesRef.current.clear()
|
||||
terminalDiagnosticsRef.current.clearTerminalCache()
|
||||
webReadyHandlesRef.current.clear()
|
||||
subscribeSeqRef.current.clear()
|
||||
layoutSeqRef.current.clear()
|
||||
@@ -1352,6 +1331,7 @@ export default function SessionScreen() {
|
||||
const dims = await getTerminalRef(handle)?.measureFitDimensions(
|
||||
terminalFrameHeightRef.current || undefined
|
||||
)
|
||||
terminalDiagnosticsRef.current.viewportMeasured(handle, dims, terminalFrameHeightRef.current)
|
||||
if (dims) {
|
||||
viewportRef.current = dims
|
||||
viewportMeasuredRef.current = true
|
||||
@@ -1362,25 +1342,34 @@ export default function SessionScreen() {
|
||||
|
||||
const subscribeToTerminal = useCallback(
|
||||
(handle: string) => {
|
||||
const diagnostics = terminalDiagnosticsRef.current
|
||||
const logSkippedGate = (reason: string) =>
|
||||
diagnostics.streamSkipped(handle, reason, handle === activeHandleRef.current)
|
||||
if (!client) {
|
||||
logSkippedGate('no-client')
|
||||
return
|
||||
}
|
||||
if (terminalUnsubsRef.current.has(handle)) {
|
||||
logSkippedGate('already-subscribed')
|
||||
return
|
||||
}
|
||||
if (subscribingHandlesRef.current.has(handle)) {
|
||||
logSkippedGate('subscribe-in-flight')
|
||||
return
|
||||
}
|
||||
if (!getTerminalRef(handle)) {
|
||||
logSkippedGate('no-webview-ref')
|
||||
return
|
||||
}
|
||||
if (!webReadyHandlesRef.current.has(handle)) {
|
||||
logSkippedGate('webview-not-ready')
|
||||
return
|
||||
}
|
||||
|
||||
subscribingHandlesRef.current.add(handle)
|
||||
const seq = (subscribeSeqRef.current.get(handle) ?? 0) + 1
|
||||
subscribeSeqRef.current.set(handle, seq)
|
||||
diagnostics.streamArmed(handle, seq, viewportRef.current)
|
||||
|
||||
// Why: server handles auto-fit on subscribe — no terminal.focus call needed.
|
||||
// The viewport is embedded in the subscribe params so the server resizes
|
||||
@@ -1399,6 +1388,7 @@ export default function SessionScreen() {
|
||||
return
|
||||
}
|
||||
const data = result as Record<string, unknown>
|
||||
diagnostics.firstStreamEvent(handle, seq, data.type)
|
||||
// Why: stale-event filter. Server-side state machine bumps a
|
||||
// monotonic seq on every applyLayout. Drop `resized` events
|
||||
// whose seq is strictly older than what we've already observed
|
||||
@@ -1431,6 +1421,7 @@ export default function SessionScreen() {
|
||||
return
|
||||
}
|
||||
if (data.type === 'scrollback') {
|
||||
diagnostics.streamScrollback(handle, seq, eventSeq, data)
|
||||
if (initializedHandlesRef.current.has(handle)) {
|
||||
return
|
||||
}
|
||||
@@ -1522,6 +1513,7 @@ export default function SessionScreen() {
|
||||
// server still has a null viewport for THIS subscriber
|
||||
// record — we MUST resubscribe so the server stores it.
|
||||
if (dims) {
|
||||
diagnostics.streamResubscribing(handle, seq, dims)
|
||||
viewportRef.current = dims
|
||||
viewportMeasuredRef.current = true
|
||||
unsubscribeTerminal(handle)
|
||||
@@ -1566,6 +1558,7 @@ export default function SessionScreen() {
|
||||
const cols = (data.cols as number) || 80
|
||||
const rows = (data.rows as number) || 24
|
||||
const serialized = typeof data.serialized === 'string' ? data.serialized : null
|
||||
diagnostics.streamResized(handle, seq, eventSeq, data, getTerminalRef(handle) != null)
|
||||
const oscLinks = isTerminalOscLinkRanges(data.oscLinks) ? data.oscLinks : undefined
|
||||
if (serialized != null) {
|
||||
getTerminalRef(handle)?.init(cols, rows, serialized, true, oscLinks)
|
||||
@@ -1735,6 +1728,7 @@ export default function SessionScreen() {
|
||||
|
||||
const applySessionTabs = useCallback(
|
||||
(result: SessionTabsResult) => {
|
||||
const diagnostics = terminalDiagnosticsRef.current
|
||||
// Reject out-of-order snapshots, then suppress just-closed tabs until the
|
||||
// publisher confirms their absence. See session-tab-snapshot-gate.
|
||||
if (!acceptSessionSnapshot(result, appliedSnapshotMarkerRef.current)) {
|
||||
@@ -1791,15 +1785,21 @@ export default function SessionScreen() {
|
||||
const pendingActiveSessionTabId = pendingActiveSessionTabIdRef.current
|
||||
const pendingActiveTerminalHandle = pendingActiveTerminalHandleRef.current
|
||||
let active = snapshotActive
|
||||
let selectionSource = 'snapshot'
|
||||
if (pendingActiveSessionTabId) {
|
||||
if (snapshotActive?.id === pendingActiveSessionTabId) {
|
||||
pendingActiveSessionTabIdRef.current = null
|
||||
if (confirmsMirroredTabSelection(result.publicationEpoch)) {
|
||||
pendingActiveSessionTabIdRef.current = null
|
||||
} else {
|
||||
selectionSource = 'pending-tab-local-ack'
|
||||
}
|
||||
} else {
|
||||
const pendingTab = nextTabs.find((tab) => tab.id === pendingActiveSessionTabId)
|
||||
if (pendingTab) {
|
||||
// Why: desktop tab snapshots can lag a mobile tap while activate RPC
|
||||
// is in flight. Keep the locally selected tab to avoid snapping back.
|
||||
active = pendingTab
|
||||
selectionSource = 'pending-tab'
|
||||
} else {
|
||||
pendingActiveSessionTabIdRef.current = null
|
||||
}
|
||||
@@ -1817,12 +1817,17 @@ export default function SessionScreen() {
|
||||
snapshotActive?.type === 'terminal' &&
|
||||
snapshotActive.terminal === pendingActiveTerminalHandle
|
||||
) {
|
||||
pendingActiveTerminalHandleRef.current = null
|
||||
if (confirmsMirroredTabSelection(result.publicationEpoch)) {
|
||||
pendingActiveTerminalHandleRef.current = null
|
||||
} else {
|
||||
selectionSource = 'pending-handle-local-ack'
|
||||
}
|
||||
} else if (pendingTerminalTab) {
|
||||
// Why: desktop active flags can lag a mobile terminal tap. Key by
|
||||
// terminal handle too, because fallback PTY tabs may not yet have a
|
||||
// stable session tab id during new-worktree startup.
|
||||
active = pendingTerminalTab
|
||||
selectionSource = 'pending-handle-tab'
|
||||
} else if (pendingTerminalExists) {
|
||||
const nextActiveTabId = getActiveTabIdForHandle(nextTabs, pendingActiveTerminalHandle)
|
||||
activeSessionTabIdRef.current = nextActiveTabId
|
||||
@@ -1835,6 +1840,7 @@ export default function SessionScreen() {
|
||||
pendingActiveTerminalHandleRef.current = null
|
||||
}
|
||||
}
|
||||
diagnostics.tabsApplied(result, nextTabs, active, selectionSource)
|
||||
activeSessionTabTypeRef.current = active?.type ?? null
|
||||
activeSessionTabIdRef.current = active?.id ?? null
|
||||
setActiveSessionTabId(active?.id ?? null)
|
||||
@@ -2379,20 +2385,25 @@ export default function SessionScreen() {
|
||||
|
||||
const fetchSessionTabs = useCallback(async () => {
|
||||
if (!client) {
|
||||
terminalDiagnosticsRef.current.tabsFetchSkipped('no-client')
|
||||
return
|
||||
}
|
||||
if (fetchSessionTabsInFlightRef.current) {
|
||||
terminalDiagnosticsRef.current.tabsFetchSkipped('already-in-flight')
|
||||
return
|
||||
}
|
||||
fetchSessionTabsInFlightRef.current = true
|
||||
terminalDiagnosticsRef.current.tabsFetchStarted(worktreeId)
|
||||
try {
|
||||
const response = await client.sendRequest('session.tabs.list', {
|
||||
worktree: `id:${worktreeId}`
|
||||
})
|
||||
if (!response.ok) {
|
||||
terminalDiagnosticsRef.current.tabsFetchFailed((response as RpcFailure).error.code)
|
||||
return
|
||||
}
|
||||
const result = (response as RpcSuccess).result as SessionTabsResult
|
||||
terminalDiagnosticsRef.current.tabsFetchSucceeded(result)
|
||||
applySessionTabs(result)
|
||||
// Focus a just-opened browser tab once it appears in the snapshot, via the
|
||||
// normal activate path so it sticks and the user can still switch away.
|
||||
@@ -2406,7 +2417,8 @@ export default function SessionScreen() {
|
||||
switchSessionTabRef.current?.(browserTab)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
} catch (error) {
|
||||
terminalDiagnosticsRef.current.tabsFetchErrored(error)
|
||||
// Keep the last tab snapshot visible during reconnect/backoff.
|
||||
} finally {
|
||||
fetchSessionTabsInFlightRef.current = false
|
||||
@@ -2589,7 +2601,7 @@ export default function SessionScreen() {
|
||||
// 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.
|
||||
useTerminalViewportRefit({
|
||||
const { notifyTerminalFrameHeight, notifyKeyboardVisibility } = useTerminalViewportRefit({
|
||||
activeHandleRef,
|
||||
terminalRefs,
|
||||
terminalFrameHeightRef,
|
||||
@@ -2608,9 +2620,11 @@ export default function SessionScreen() {
|
||||
|
||||
useEffect(() => {
|
||||
const onShow = (e: KeyboardEvent) => {
|
||||
notifyKeyboardVisibility(true)
|
||||
setKeyboardHeight(e.endCoordinates?.height ?? 0)
|
||||
}
|
||||
const onHide = () => {
|
||||
notifyKeyboardVisibility(false)
|
||||
setKeyboardHeight(0)
|
||||
}
|
||||
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'
|
||||
@@ -2621,7 +2635,7 @@ export default function SessionScreen() {
|
||||
showSub.remove()
|
||||
hideSub.remove()
|
||||
}
|
||||
}, [])
|
||||
}, [notifyKeyboardVisibility])
|
||||
|
||||
const scrollActiveTabIntoView = useCallback((tabId: string | null, animated: boolean) => {
|
||||
if (!tabId) {
|
||||
@@ -2689,6 +2703,7 @@ export default function SessionScreen() {
|
||||
pendingBrowserFocusPageIdRef.current = null
|
||||
pendingTerminalActivationAttemptRef.current = null
|
||||
initialEmptySessionAutoCreateRef.current = null
|
||||
terminalDiagnosticsRef.current.resetRoute()
|
||||
appliedSnapshotMarkerRef.current = { epoch: null, version: -1 }
|
||||
closedTabTombstonesRef.current.clear()
|
||||
for (const queued of terminalGestureInputQueuesRef.current.values()) {
|
||||
@@ -2912,6 +2927,7 @@ export default function SessionScreen() {
|
||||
(tab): tab is Extract<MobileSessionTab, { type: 'terminal' }> =>
|
||||
tab.type === 'terminal' && tab.terminal === handle
|
||||
)
|
||||
terminalDiagnosticsRef.current.tabSwitch('terminal', matchingTab?.id ?? '', false, handle)
|
||||
pendingActiveSessionTabIdRef.current = matchingTab?.id ?? null
|
||||
pendingActiveTerminalHandleRef.current = handle
|
||||
activeSessionTabTypeRef.current = 'terminal'
|
||||
@@ -2931,15 +2947,15 @@ export default function SessionScreen() {
|
||||
}
|
||||
subscribeToTerminal(handle)
|
||||
if (client) {
|
||||
void client.sendRequest('terminal.focus', { terminal: handle }).catch(() => {})
|
||||
void focusMobileTerminal(client, handle).catch(() => {})
|
||||
if (matchingTab) {
|
||||
void client
|
||||
.sendRequest('session.tabs.activate', {
|
||||
worktree: `id:${worktreeId}`,
|
||||
tabId: matchingTab.id,
|
||||
notifyClients: false
|
||||
})
|
||||
.catch(() => {})
|
||||
// Why: persist selection for headless hosts; the snapshot gate keeps
|
||||
// this phone-local acknowledgement from impersonating desktop focus.
|
||||
void activateMobileSessionTab(client, {
|
||||
worktree: `id:${worktreeId}`,
|
||||
tabId: matchingTab.id,
|
||||
notifyClients: false
|
||||
}).catch(() => {})
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -2960,6 +2976,7 @@ export default function SessionScreen() {
|
||||
switchTab(tab.terminal)
|
||||
return
|
||||
}
|
||||
terminalDiagnosticsRef.current.tabSwitch('terminal', tab.id, true)
|
||||
triggerSelection()
|
||||
pendingActiveSessionTabIdRef.current = tab.id
|
||||
pendingActiveTerminalHandleRef.current = null
|
||||
@@ -2973,18 +2990,17 @@ export default function SessionScreen() {
|
||||
activeHandleRef.current = null
|
||||
setActiveHandle(null)
|
||||
if (client) {
|
||||
void client
|
||||
.sendRequest('session.tabs.activate', {
|
||||
worktree: `id:${worktreeId}`,
|
||||
tabId: tab.id,
|
||||
notifyClients: false
|
||||
})
|
||||
.catch(() => {})
|
||||
void activateMobileSessionTab(client, {
|
||||
worktree: `id:${worktreeId}`,
|
||||
tabId: tab.id,
|
||||
notifyClients: false
|
||||
}).catch(() => {})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
triggerSelection()
|
||||
terminalDiagnosticsRef.current.tabSwitch(tab.type, tab.id, false)
|
||||
pendingActiveSessionTabIdRef.current = tab.id
|
||||
pendingActiveTerminalHandleRef.current = null
|
||||
activeSessionTabTypeRef.current = tab.type
|
||||
@@ -2997,13 +3013,11 @@ export default function SessionScreen() {
|
||||
activeHandleRef.current = null
|
||||
setActiveHandle(null)
|
||||
if (client) {
|
||||
void client
|
||||
.sendRequest('session.tabs.activate', {
|
||||
worktree: `id:${worktreeId}`,
|
||||
tabId: tab.id,
|
||||
notifyClients: false
|
||||
})
|
||||
.catch(() => {})
|
||||
void activateMobileSessionTab(client, {
|
||||
worktree: `id:${worktreeId}`,
|
||||
tabId: tab.id,
|
||||
notifyClients: false
|
||||
}).catch(() => {})
|
||||
}
|
||||
if (tab.type === 'browser') {
|
||||
return
|
||||
@@ -3031,6 +3045,7 @@ export default function SessionScreen() {
|
||||
// init messages. This prevents the blank terminal race where init() was
|
||||
// queued before the WebView loaded.
|
||||
const setTerminalWebViewRef = useCallback((handle: string, ref: TerminalWebViewHandle | null) => {
|
||||
terminalDiagnosticsRef.current.webViewRef(handle, ref != null)
|
||||
if (ref) {
|
||||
terminalRefs.current.set(handle, ref)
|
||||
} else {
|
||||
@@ -3049,6 +3064,11 @@ export default function SessionScreen() {
|
||||
(handle: string) => {
|
||||
const wasAlreadyReady = webReadyHandlesRef.current.has(handle)
|
||||
webReadyHandlesRef.current.add(handle)
|
||||
terminalDiagnosticsRef.current.webViewReady(
|
||||
handle,
|
||||
wasAlreadyReady,
|
||||
handle === activeHandleRef.current
|
||||
)
|
||||
if (wasAlreadyReady && initializedHandlesRef.current.has(handle)) {
|
||||
// Why: the native WebView reloaded (Metro hot reload or Android
|
||||
// process churn). The old xterm buffer is gone, so force a fresh
|
||||
@@ -3085,7 +3105,7 @@ export default function SessionScreen() {
|
||||
})()
|
||||
}
|
||||
},
|
||||
[measureViewportOnce, subscribeToTerminal, unsubscribeTerminal]
|
||||
[getTerminalRef, measureViewportOnce, subscribeToTerminal, unsubscribeTerminal]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -4302,13 +4322,12 @@ export default function SessionScreen() {
|
||||
// Why: a hydrated headless/server-owned tab can already be active but still
|
||||
// pending; activation is the RPC that materializes or focuses its PTY handle.
|
||||
pendingTerminalActivationAttemptRef.current = activationKey
|
||||
void client
|
||||
.sendRequest('session.tabs.activate', {
|
||||
worktree: `id:${worktreeId}`,
|
||||
tabId: activePendingTerminalTab.id,
|
||||
leafId: activePendingTerminalTab.leafId,
|
||||
notifyClients: false
|
||||
})
|
||||
void activateMobileSessionTab(client, {
|
||||
worktree: `id:${worktreeId}`,
|
||||
tabId: activePendingTerminalTab.id,
|
||||
leafId: activePendingTerminalTab.leafId,
|
||||
notifyClients: false
|
||||
})
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
if (pendingTerminalActivationAttemptRef.current === activationKey) {
|
||||
@@ -4831,10 +4850,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.
|
||||
// Why: notify height imperatively so dock settling re-fits the
|
||||
// PTY without rerendering SessionScreen for layout callbacks.
|
||||
const nextWidth = Math.round(e.nativeEvent.layout.width)
|
||||
const nextHeight = Math.round(e.nativeEvent.layout.height)
|
||||
setTerminalFrameWidth((prev) => (prev === nextWidth ? prev : nextWidth))
|
||||
notifyTerminalFrameHeight(nextHeight)
|
||||
}}
|
||||
>
|
||||
{terminals.map((terminal) => (
|
||||
|
||||
@@ -34,11 +34,11 @@ import {
|
||||
} from 'lucide-react-native'
|
||||
import type { RpcClient } from '../../../src/transport/rpc-client'
|
||||
import type { RpcSuccess } from '../../../src/transport/types'
|
||||
import { useHostClient } from '../../../src/transport/client-context'
|
||||
import {
|
||||
useHostClient,
|
||||
useLastConnectedAt,
|
||||
useReconnectAttempt
|
||||
} from '../../../src/transport/client-context'
|
||||
} from '../../../src/transport/client-context-connection-metrics'
|
||||
import { classifyConnection } from '../../../src/transport/connection-health'
|
||||
import { StatusDot } from '../../../src/components/StatusDot'
|
||||
import { ActionSheetModal } from '../../../src/components/ActionSheetModal'
|
||||
@@ -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
|
||||
|
||||
+32
-97
@@ -3,7 +3,6 @@ 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 {
|
||||
Monitor,
|
||||
QrCode,
|
||||
Settings,
|
||||
ChevronRight,
|
||||
@@ -35,12 +34,13 @@ import {
|
||||
useForceReconnect,
|
||||
usePrimeHosts
|
||||
} from '../src/transport/client-context'
|
||||
import { classifyConnection, verdictDisplayLabel } from '../src/transport/connection-health'
|
||||
import { classifyConnection } from '../src/transport/connection-health'
|
||||
import { subscribeToDesktopNotifications } from '../src/notifications/mobile-notifications'
|
||||
import { shouldPresentNotificationOptIn } from '../src/notifications/notification-opt-in-gate'
|
||||
import type { ConnectionState, HostProfile } from '../src/transport/types'
|
||||
import { triggerMediumImpact } from '../src/platform/haptics'
|
||||
import { OrcaLogo } from '../src/components/OrcaLogo'
|
||||
import { StatusDot } from '../src/components/StatusDot'
|
||||
import { MobileHostCard } from '../src/components/MobileHostCard'
|
||||
import { TaskProviderLogo } from '../src/components/TaskProviderLogo'
|
||||
import { TextInputModal } from '../src/components/TextInputModal'
|
||||
import { ActionSheetModal, type ActionSheetAction } from '../src/components/ActionSheetModal'
|
||||
@@ -318,12 +318,17 @@ export default function HomeScreen() {
|
||||
const [lastVisited, setLastVisited] = useState<{ hostId: string; worktreeId: string } | null>(
|
||||
null
|
||||
)
|
||||
const notificationOptInCheckedRef = useRef(false)
|
||||
|
||||
// Why: read shared clients from the per-host store. Replaces the prior
|
||||
// pattern of opening N independent WebSockets here. See
|
||||
// docs/mobile-shared-client-per-host.md.
|
||||
const hostIds = useMemo(() => hosts.map((h) => h.id), [hosts])
|
||||
const allClients = useAllHostClients(hostIds)
|
||||
const hostPaths = useMemo(
|
||||
() => Object.fromEntries(allClients.map(({ hostId, path }) => [hostId, path])),
|
||||
[allClients]
|
||||
)
|
||||
const closeHostClient = useCloseHost()
|
||||
const forceReconnectHost = useForceReconnect()
|
||||
const primeHosts = usePrimeHosts()
|
||||
@@ -392,9 +397,18 @@ export default function HomeScreen() {
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
let stale = false
|
||||
void loadHosts().then((h) => {
|
||||
if (!stale) {
|
||||
setHosts(h)
|
||||
void loadHosts().then(async (h) => {
|
||||
if (stale) {
|
||||
return
|
||||
}
|
||||
setHosts(h)
|
||||
if (h.length === 0 || notificationOptInCheckedRef.current) {
|
||||
return
|
||||
}
|
||||
notificationOptInCheckedRef.current = true
|
||||
const showNotificationOptIn = await shouldPresentNotificationOptIn()
|
||||
if (!stale && showNotificationOptIn) {
|
||||
router.replace('/notification-opt-in')
|
||||
}
|
||||
})
|
||||
void AsyncStorage.getItem('orca:last-visited-worktree').then((raw) => {
|
||||
@@ -416,7 +430,7 @@ export default function HomeScreen() {
|
||||
return () => {
|
||||
stale = true
|
||||
}
|
||||
}, [])
|
||||
}, [router])
|
||||
)
|
||||
|
||||
const sortedHosts = useMemo(
|
||||
@@ -824,7 +838,6 @@ export default function HomeScreen() {
|
||||
const state = hostStates[item.id] ?? 'connecting'
|
||||
const attempts = hostAttempts[item.id] ?? 0
|
||||
const lastConnectedAt = hostLastConnected[item.id] ?? null
|
||||
const connected = state === 'connected'
|
||||
const info = worktreeInfo[item.id]
|
||||
const verdict = classifyConnection({
|
||||
state,
|
||||
@@ -832,45 +845,21 @@ export default function HomeScreen() {
|
||||
lastConnectedAt,
|
||||
endpoint: item.endpoint
|
||||
})
|
||||
const isError =
|
||||
verdict.kind === 'warning' ||
|
||||
verdict.kind === 'unreachable' ||
|
||||
verdict.kind === 'auth-failed'
|
||||
return (
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.hostCard, pressed && styles.hostCardPressed]}
|
||||
<MobileHostCard
|
||||
host={item}
|
||||
state={state}
|
||||
verdict={verdict}
|
||||
path={hostPaths[item.id] ?? 'lan'}
|
||||
worktreeCounts={
|
||||
info ? { total: info.totalWorktrees, active: info.activeCount } : undefined
|
||||
}
|
||||
onPress={() => router.push(`/h/${item.id}`)}
|
||||
onLongPress={() => {
|
||||
triggerMediumImpact()
|
||||
setActionTarget(item)
|
||||
}}
|
||||
delayLongPress={400}
|
||||
>
|
||||
<View style={styles.hostIcon}>
|
||||
<Monitor
|
||||
size={20}
|
||||
color={connected ? colors.textPrimary : colors.textSecondary}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.hostMain}>
|
||||
<Text
|
||||
style={[styles.hostName, !connected && { color: colors.textSecondary }]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{item.name}
|
||||
</Text>
|
||||
<View style={styles.hostMeta}>
|
||||
<StatusDot state={state} verdict={verdict} />
|
||||
<Text style={[styles.hostMetaItem, isError && { color: colors.statusRed }]}>
|
||||
{verdictDisplayLabel(verdict)}
|
||||
{connected && info
|
||||
? ` · ${info.totalWorktrees} worktree${info.totalWorktrees !== 1 ? 's' : ''}${info.activeCount > 0 ? ` · ${info.activeCount} active` : ''}`
|
||||
: ''}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<ChevronRight size={16} color={colors.textMuted} />
|
||||
</Pressable>
|
||||
/>
|
||||
)
|
||||
}}
|
||||
ListFooterComponent={
|
||||
@@ -1081,16 +1070,16 @@ export default function HomeScreen() {
|
||||
items.push({
|
||||
label: 'Rename',
|
||||
icon: Edit3,
|
||||
closeBeforePress: true,
|
||||
onPress: () => {
|
||||
setActionTarget(null)
|
||||
setRenameTarget(host)
|
||||
}
|
||||
})
|
||||
items.push({
|
||||
label: 'Remove',
|
||||
destructive: true,
|
||||
closeBeforePress: true,
|
||||
onPress: () => {
|
||||
setActionTarget(null)
|
||||
setConfirmRemove(host)
|
||||
}
|
||||
})
|
||||
@@ -1244,63 +1233,9 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
|
||||
/* ─── Host cards ─── */
|
||||
hostCard: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingLeft: spacing.md,
|
||||
paddingRight: spacing.md,
|
||||
paddingVertical: 12,
|
||||
borderRadius: radii.card,
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle
|
||||
},
|
||||
hostCardPressed: {
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
hostIcon: {
|
||||
width: 46,
|
||||
height: 46,
|
||||
borderRadius: 13,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.bgRaised,
|
||||
marginRight: 14,
|
||||
position: 'relative'
|
||||
},
|
||||
hostMain: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
marginRight: spacing.sm
|
||||
},
|
||||
hostName: {
|
||||
color: colors.textPrimary,
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
lineHeight: 20
|
||||
},
|
||||
hostMeta: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
marginTop: 3
|
||||
},
|
||||
hostMetaItem: {
|
||||
fontSize: 12,
|
||||
color: colors.textSecondary
|
||||
},
|
||||
hostMetaDot: {
|
||||
width: 3,
|
||||
height: 3,
|
||||
borderRadius: 1.5,
|
||||
backgroundColor: colors.textMuted,
|
||||
marginHorizontal: 8
|
||||
},
|
||||
statusDot: {
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: 3.5
|
||||
},
|
||||
|
||||
/* ─── Resume card ─── */
|
||||
resumeCard: {
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import {
|
||||
ActivityIndicator,
|
||||
BackHandler,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View
|
||||
} from 'react-native'
|
||||
import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router'
|
||||
import { SafeAreaView } from 'react-native-safe-area-context'
|
||||
import { BellRing } from 'lucide-react-native'
|
||||
import { OrcaLogo } from '../src/components/OrcaLogo'
|
||||
import { ensureNotificationPermissions } from '../src/notifications/mobile-notifications'
|
||||
import { savePushNotificationsEnabled } from '../src/storage/preferences'
|
||||
import { colors, radii, spacing, typography } from '../src/theme/mobile-theme'
|
||||
|
||||
export default function NotificationOptInScreen() {
|
||||
const router = useRouter()
|
||||
const params = useLocalSearchParams<{ hostId?: string | string[] }>()
|
||||
const hostId = Array.isArray(params.hostId) ? params.hostId[0] : params.hostId
|
||||
const [busyChoice, setBusyChoice] = useState<'enable' | 'skip' | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Why: this one-time screen requires an explicit Enable or Not now choice;
|
||||
// disabling back gestures alone would still leave Android hardware back open.
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
const subscription = BackHandler.addEventListener('hardwareBackPress', () => true)
|
||||
return () => subscription.remove()
|
||||
}, [])
|
||||
)
|
||||
|
||||
const continueToApp = useCallback(() => {
|
||||
router.replace(hostId ? `/h/${hostId}` : '/')
|
||||
}, [hostId, router])
|
||||
|
||||
const choose = useCallback(
|
||||
async (choice: 'enable' | 'skip') => {
|
||||
if (busyChoice) {
|
||||
return
|
||||
}
|
||||
setBusyChoice(choice)
|
||||
setError(null)
|
||||
try {
|
||||
const enabled = choice === 'enable' ? await ensureNotificationPermissions() : false
|
||||
await savePushNotificationsEnabled(enabled)
|
||||
continueToApp()
|
||||
} catch {
|
||||
setError('Notification settings could not be updated. Try again.')
|
||||
setBusyChoice(null)
|
||||
}
|
||||
},
|
||||
[busyChoice, continueToApp]
|
||||
)
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<ScrollView contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}>
|
||||
<View style={styles.brandRow}>
|
||||
<OrcaLogo size={22} />
|
||||
<Text style={styles.brandName}>Orca</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.content}>
|
||||
<View style={styles.iconSurface}>
|
||||
<BellRing size={30} color={colors.textPrimary} />
|
||||
</View>
|
||||
<Text style={styles.eyebrow}>Notifications</Text>
|
||||
<Text style={styles.title}>Stay updated while away</Text>
|
||||
<Text style={styles.body}>
|
||||
Get notified on this device when an agent needs your input or finishes a task.
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.footer}>
|
||||
{error ? (
|
||||
<Text style={styles.error} accessibilityRole="alert">
|
||||
{error}
|
||||
</Text>
|
||||
) : null}
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Enable agent notifications"
|
||||
disabled={busyChoice !== null}
|
||||
style={({ pressed }) => [
|
||||
styles.primaryButton,
|
||||
pressed && styles.buttonPressed,
|
||||
busyChoice !== null && styles.buttonDisabled
|
||||
]}
|
||||
onPress={() => void choose('enable')}
|
||||
>
|
||||
{busyChoice === 'enable' ? (
|
||||
<ActivityIndicator color={colors.bgBase} />
|
||||
) : (
|
||||
<Text style={styles.primaryButtonText}>Enable notifications</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
disabled={busyChoice !== null}
|
||||
style={({ pressed }) => [
|
||||
styles.secondaryButton,
|
||||
pressed && styles.buttonPressed,
|
||||
busyChoice !== null && styles.buttonDisabled
|
||||
]}
|
||||
onPress={() => void choose('skip')}
|
||||
>
|
||||
{busyChoice === 'skip' ? (
|
||||
<ActivityIndicator color={colors.textSecondary} />
|
||||
) : (
|
||||
<Text style={styles.secondaryButtonText}>Not now</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
<Text style={styles.footerNote}>You can change this any time in Settings.</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bgBase,
|
||||
paddingHorizontal: spacing.xl
|
||||
},
|
||||
// Why: this decision screen cannot be dismissed with Back, so every action
|
||||
// must remain reachable in landscape and with accessibility text scaling.
|
||||
scrollContent: {
|
||||
flexGrow: 1
|
||||
},
|
||||
brandRow: {
|
||||
minHeight: 52,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm
|
||||
},
|
||||
brandName: {
|
||||
color: colors.textPrimary,
|
||||
fontSize: 17,
|
||||
fontWeight: '700'
|
||||
},
|
||||
content: {
|
||||
flexGrow: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: spacing.xl
|
||||
},
|
||||
iconSurface: {
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: radii.card,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.bgRaised,
|
||||
marginBottom: spacing.xl
|
||||
},
|
||||
eyebrow: {
|
||||
color: colors.textMuted,
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
letterSpacing: 0.55,
|
||||
textTransform: 'uppercase',
|
||||
marginBottom: spacing.sm
|
||||
},
|
||||
title: {
|
||||
maxWidth: 420,
|
||||
color: colors.textPrimary,
|
||||
fontSize: 26,
|
||||
fontWeight: '700',
|
||||
letterSpacing: -0.3,
|
||||
textAlign: 'center'
|
||||
},
|
||||
body: {
|
||||
maxWidth: 420,
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.bodySize,
|
||||
lineHeight: 21,
|
||||
textAlign: 'center',
|
||||
marginTop: spacing.md
|
||||
},
|
||||
footer: {
|
||||
width: '100%',
|
||||
maxWidth: 420,
|
||||
alignSelf: 'center',
|
||||
paddingBottom: spacing.lg
|
||||
},
|
||||
primaryButton: {
|
||||
minHeight: 44,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: radii.button,
|
||||
backgroundColor: colors.surfaceBright,
|
||||
paddingVertical: spacing.sm
|
||||
},
|
||||
primaryButtonText: {
|
||||
color: colors.bgBase,
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '600'
|
||||
},
|
||||
secondaryButton: {
|
||||
minHeight: 44,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: radii.button,
|
||||
marginTop: spacing.xs,
|
||||
paddingVertical: spacing.sm
|
||||
},
|
||||
secondaryButtonText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '500'
|
||||
},
|
||||
buttonPressed: {
|
||||
opacity: 0.72
|
||||
},
|
||||
buttonDisabled: {
|
||||
opacity: 0.58
|
||||
},
|
||||
footerNote: {
|
||||
color: colors.textMuted,
|
||||
fontSize: typography.metaSize,
|
||||
lineHeight: 18,
|
||||
textAlign: 'center',
|
||||
marginTop: spacing.sm
|
||||
},
|
||||
error: {
|
||||
color: colors.statusRed,
|
||||
fontSize: typography.metaSize,
|
||||
lineHeight: 18,
|
||||
textAlign: 'center',
|
||||
marginBottom: spacing.sm
|
||||
}
|
||||
})
|
||||
@@ -17,7 +17,8 @@ import {
|
||||
const DEFAULT_PERMISSION_STATE: NotificationPermissionState = {
|
||||
granted: false,
|
||||
status: 'undetermined',
|
||||
canAskAgain: true
|
||||
canAskAgain: true,
|
||||
authorizationReflectsUserChoice: false
|
||||
}
|
||||
|
||||
export default function NotificationsScreen() {
|
||||
@@ -69,7 +70,7 @@ export default function NotificationsScreen() {
|
||||
const notificationsBlocked = permissionState.status === 'denied'
|
||||
const hint = notificationsBlocked
|
||||
? 'Notifications are disabled in system settings.'
|
||||
: 'Receive notifications when an agent task completes on your desktop.'
|
||||
: 'Get notified on this device when an agent needs your input or finishes a task.'
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
|
||||
@@ -82,7 +83,7 @@ export default function NotificationsScreen() {
|
||||
|
||||
<View style={styles.section}>
|
||||
<View style={styles.row}>
|
||||
<Text style={styles.rowLabel}>Push Notifications</Text>
|
||||
<Text style={styles.rowLabel}>Agent notifications</Text>
|
||||
<Switch
|
||||
value={switchEnabled}
|
||||
disabled={notificationsBlocked}
|
||||
|
||||
+33
-61
@@ -5,14 +5,14 @@ import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router'
|
||||
import { ChevronLeft } from 'lucide-react-native'
|
||||
import { resolvePairConfirmRouteState } from '../src/transport/pair-confirm-state'
|
||||
import {
|
||||
startPairingConnectionAttempt,
|
||||
type PairingConnectionAttempt
|
||||
} from '../src/transport/pairing-connection-attempt'
|
||||
import { connect } from '../src/transport/rpc-client'
|
||||
import { saveHost, getNextHostName } from '../src/transport/host-store'
|
||||
import type { ConnectionLogEntry, RpcResponse } from '../src/transport/types'
|
||||
startPreProfilePairing,
|
||||
type PreProfilePairingAttempt
|
||||
} from '../src/transport/pre-profile-pairing-coordinator'
|
||||
import type { ConnectionLogEntry } from '../src/transport/types'
|
||||
import { useCloseHost } from '../src/transport/client-context'
|
||||
import { colors, spacing, radii, typography } from '../src/theme/mobile-theme'
|
||||
import { ConnectionLog } from '../src/components/ConnectionLog'
|
||||
import { shouldPresentNotificationOptIn } from '../src/notifications/notification-opt-in-gate'
|
||||
|
||||
type Status = 'awaiting-confirm' | 'connecting' | 'error'
|
||||
|
||||
@@ -25,6 +25,7 @@ const PAIRING_OVERALL_TIMEOUT_MS = 25_000
|
||||
|
||||
export default function PairConfirmScreen() {
|
||||
const router = useRouter()
|
||||
const closeHost = useCloseHost()
|
||||
const insets = useSafeAreaInsets()
|
||||
const params = useLocalSearchParams<{ code?: string }>()
|
||||
const [status, setStatus] = useState<Status>('awaiting-confirm')
|
||||
@@ -35,7 +36,7 @@ export default function PairConfirmScreen() {
|
||||
// batch fewer setState calls when entries arrive in bursts.
|
||||
const logsRef = useRef<ConnectionLogEntry[]>([])
|
||||
const mountedRef = useRef(true)
|
||||
const activePairingAttemptRef = useRef<PairingConnectionAttempt | null>(null)
|
||||
const activePairingAttemptRef = useRef<PreProfilePairingAttempt | null>(null)
|
||||
|
||||
const routeState = resolvePairConfirmRouteState(params.code)
|
||||
const offer = routeState.offer
|
||||
@@ -79,20 +80,12 @@ export default function PairConfirmScreen() {
|
||||
setStatus('connecting')
|
||||
logsRef.current = []
|
||||
setLogs([])
|
||||
let client: ReturnType<typeof connect> | null = null
|
||||
activePairingAttemptRef.current?.dispose()
|
||||
|
||||
// Why: split the try/catch around the network call vs the local save
|
||||
// so a Keychain or AsyncStorage failure doesn't masquerade as a
|
||||
// "Cannot connect" error.
|
||||
let response: RpcResponse
|
||||
const attempt = startPairingConnectionAttempt({
|
||||
const attempt = startPreProfilePairing({
|
||||
offer,
|
||||
timeoutMs: PAIRING_OVERALL_TIMEOUT_MS,
|
||||
closeClient: () => client?.close()
|
||||
})
|
||||
activePairingAttemptRef.current = attempt
|
||||
try {
|
||||
client = connect(offer.endpoint, offer.deviceToken, offer.publicKeyB64, {
|
||||
connectOptions: {
|
||||
onLog: (entry) => {
|
||||
if (!mountedRef.current || activePairingAttemptRef.current !== attempt) {
|
||||
return
|
||||
@@ -100,8 +93,11 @@ export default function PairConfirmScreen() {
|
||||
logsRef.current = [...logsRef.current, entry]
|
||||
setLogs(logsRef.current)
|
||||
}
|
||||
})
|
||||
response = await client.sendRequest('status.get')
|
||||
}
|
||||
})
|
||||
activePairingAttemptRef.current = attempt
|
||||
try {
|
||||
const { hostId } = await attempt.result
|
||||
const attemptIsCurrent = activePairingAttemptRef.current === attempt
|
||||
attempt.dispose()
|
||||
if (activePairingAttemptRef.current === attempt) {
|
||||
@@ -110,6 +106,22 @@ export default function PairConfirmScreen() {
|
||||
if (!mountedRef.current || !attemptIsCurrent) {
|
||||
return
|
||||
}
|
||||
// Why: re-pairing the same desktop now reuses its existing host id
|
||||
// (STA-1840 dedup), so a client cached under that id from an earlier
|
||||
// pairing would keep the stale endpoint/relay. Close it so the
|
||||
// destination screen opens a fresh client with the newly-paired
|
||||
// profile — the removeHost() path already refreshes on re-pair, and a
|
||||
// brand-new host has no cached entry so this is a no-op.
|
||||
closeHost(hostId)
|
||||
const showNotificationOptIn = await shouldPresentNotificationOptIn()
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
}
|
||||
router.replace(
|
||||
showNotificationOptIn
|
||||
? { pathname: '/notification-opt-in', params: { hostId } }
|
||||
: `/h/${hostId}`
|
||||
)
|
||||
} catch (err) {
|
||||
const timedOut = attempt.timedOut
|
||||
const attemptIsCurrent = activePairingAttemptRef.current === attempt
|
||||
@@ -125,47 +137,7 @@ export default function PairConfirmScreen() {
|
||||
setErrorMessage(
|
||||
timedOut
|
||||
? `Couldn't connect within ${PAIRING_OVERALL_TIMEOUT_MS / 1000}s — see log below for where it stalled`
|
||||
: 'Cannot connect — check that your computer is on the same network'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
}
|
||||
setStatus('error')
|
||||
setErrorMessage(
|
||||
response.error.code === 'unauthorized'
|
||||
? 'Authentication failed — token may be expired'
|
||||
: `Server error: ${response.error.message}`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const hostId = `host-${Date.now()}`
|
||||
const hostName = await getNextHostName()
|
||||
await saveHost({
|
||||
id: hostId,
|
||||
name: hostName,
|
||||
endpoint: offer.endpoint,
|
||||
deviceToken: offer.deviceToken,
|
||||
publicKeyB64: offer.publicKeyB64,
|
||||
lastConnected: Date.now()
|
||||
})
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
}
|
||||
router.replace(`/h/${hostId}`)
|
||||
} catch (err) {
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
}
|
||||
console.warn('[pair-confirm] save failed', err)
|
||||
setStatus('error')
|
||||
setErrorMessage(
|
||||
`Pairing succeeded but couldn't save the host: ${err instanceof Error ? err.message : String(err)}`
|
||||
: `Pairing failed: ${err instanceof Error ? err.message : String(err)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+33
-66
@@ -14,15 +14,15 @@ import { useRouter } from 'expo-router'
|
||||
import { ChevronLeft, Clipboard as ClipboardIcon, QrCode } from 'lucide-react-native'
|
||||
import { decodePairingUrl, parsePairingCode } from '../src/transport/pairing'
|
||||
import {
|
||||
startPairingConnectionAttempt,
|
||||
type PairingConnectionAttempt
|
||||
} from '../src/transport/pairing-connection-attempt'
|
||||
import { connect } from '../src/transport/rpc-client'
|
||||
import { saveHost, getNextHostName } from '../src/transport/host-store'
|
||||
import type { ConnectionLogEntry, PairingOffer, RpcResponse } from '../src/transport/types'
|
||||
startPreProfilePairing,
|
||||
type PreProfilePairingAttempt
|
||||
} from '../src/transport/pre-profile-pairing-coordinator'
|
||||
import type { ConnectionLogEntry, PairingOffer } from '../src/transport/types'
|
||||
import { useCloseHost } from '../src/transport/client-context'
|
||||
import { colors, spacing, radii, typography } from '../src/theme/mobile-theme'
|
||||
import { TextInputModal } from '../src/components/TextInputModal'
|
||||
import { ConnectionLog } from '../src/components/ConnectionLog'
|
||||
import { shouldPresentNotificationOptIn } from '../src/notifications/notification-opt-in-gate'
|
||||
|
||||
// Why: see pair-confirm.tsx — cap initial-pair "Connecting…" so a broken
|
||||
// route surfaces as a real error with the log visible instead of a
|
||||
@@ -44,6 +44,7 @@ function Step({ number, text }: { number: number; text: string }) {
|
||||
|
||||
export default function PairScanScreen() {
|
||||
const router = useRouter()
|
||||
const closeHost = useCloseHost()
|
||||
const insets = useSafeAreaInsets()
|
||||
const [permission, requestPermission] = useCameraPermissions()
|
||||
const [status, setStatus] = useState<'scanning' | 'connecting' | 'error'>('scanning')
|
||||
@@ -54,7 +55,7 @@ export default function PairScanScreen() {
|
||||
const logsRef = useRef<ConnectionLogEntry[]>([])
|
||||
const processingRef = useRef(false)
|
||||
const mountedRef = useRef(true)
|
||||
const activePairingAttemptRef = useRef<PairingConnectionAttempt | null>(null)
|
||||
const activePairingAttemptRef = useRef<PreProfilePairingAttempt | null>(null)
|
||||
|
||||
const setPairScanRootRef = useCallback((node: View | null): void => {
|
||||
if (node !== null) {
|
||||
@@ -123,21 +124,12 @@ export default function PairScanScreen() {
|
||||
setStatus('connecting')
|
||||
logsRef.current = []
|
||||
setLogs([])
|
||||
let client: ReturnType<typeof connect> | null = null
|
||||
activePairingAttemptRef.current?.dispose()
|
||||
|
||||
// Why: split the try/catch around the network call vs the local save
|
||||
// so a Keychain or AsyncStorage failure doesn't masquerade as a
|
||||
// "Cannot connect — same network?" error. Pairing reached the
|
||||
// desktop fine; the failure is local persistence.
|
||||
let response: RpcResponse
|
||||
const attempt = startPairingConnectionAttempt({
|
||||
const attempt = startPreProfilePairing({
|
||||
offer,
|
||||
timeoutMs: PAIRING_OVERALL_TIMEOUT_MS,
|
||||
closeClient: () => client?.close()
|
||||
})
|
||||
activePairingAttemptRef.current = attempt
|
||||
try {
|
||||
client = connect(offer.endpoint, offer.deviceToken, offer.publicKeyB64, {
|
||||
connectOptions: {
|
||||
onLog: (entry) => {
|
||||
if (!mountedRef.current || activePairingAttemptRef.current !== attempt) {
|
||||
return
|
||||
@@ -145,8 +137,11 @@ export default function PairScanScreen() {
|
||||
logsRef.current = [...logsRef.current, entry]
|
||||
setLogs(logsRef.current)
|
||||
}
|
||||
})
|
||||
response = await client.sendRequest('status.get')
|
||||
}
|
||||
})
|
||||
activePairingAttemptRef.current = attempt
|
||||
try {
|
||||
const { hostId } = await attempt.result
|
||||
const attemptIsCurrent = activePairingAttemptRef.current === attempt
|
||||
attempt.dispose()
|
||||
if (activePairingAttemptRef.current === attempt) {
|
||||
@@ -155,6 +150,22 @@ export default function PairScanScreen() {
|
||||
if (!mountedRef.current || !attemptIsCurrent) {
|
||||
return
|
||||
}
|
||||
// Why: re-pairing the same desktop now reuses its existing host id
|
||||
// (STA-1840 dedup), so a client cached under that id from an earlier
|
||||
// pairing would keep the stale endpoint/relay. Close it so the
|
||||
// destination screen opens a fresh client with the newly-paired
|
||||
// profile — the removeHost() path already refreshes on re-pair, and a
|
||||
// brand-new host has no cached entry so this is a no-op.
|
||||
closeHost(hostId)
|
||||
const showNotificationOptIn = await shouldPresentNotificationOptIn()
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
}
|
||||
router.replace(
|
||||
showNotificationOptIn
|
||||
? { pathname: '/notification-opt-in', params: { hostId } }
|
||||
: `/h/${hostId}`
|
||||
)
|
||||
} catch (err) {
|
||||
const timedOut = attempt.timedOut
|
||||
const attemptIsCurrent = activePairingAttemptRef.current === attempt
|
||||
@@ -170,51 +181,7 @@ export default function PairScanScreen() {
|
||||
setErrorMessage(
|
||||
timedOut
|
||||
? `Couldn't connect within ${PAIRING_OVERALL_TIMEOUT_MS / 1000}s — see log below for where it stalled`
|
||||
: 'Cannot connect — check that your computer is on the same network'
|
||||
)
|
||||
processingRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
}
|
||||
if (response.error.code === 'unauthorized') {
|
||||
setStatus('error')
|
||||
setErrorMessage('Authentication failed — token may be expired')
|
||||
processingRef.current = false
|
||||
return
|
||||
}
|
||||
setStatus('error')
|
||||
setErrorMessage(`Server error: ${response.error.message}`)
|
||||
processingRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const hostId = `host-${Date.now()}`
|
||||
const hostName = await getNextHostName()
|
||||
await saveHost({
|
||||
id: hostId,
|
||||
name: hostName,
|
||||
endpoint: offer.endpoint,
|
||||
deviceToken: offer.deviceToken,
|
||||
publicKeyB64: offer.publicKeyB64,
|
||||
lastConnected: Date.now()
|
||||
})
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
}
|
||||
router.replace(`/h/${hostId}`)
|
||||
} catch (err) {
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
}
|
||||
console.warn('[pair] save failed', err)
|
||||
setStatus('error')
|
||||
setErrorMessage(
|
||||
`Pairing succeeded but couldn't save the host: ${err instanceof Error ? err.message : String(err)}`
|
||||
: `Pairing failed: ${err instanceof Error ? err.message : String(err)}`
|
||||
)
|
||||
processingRef.current = false
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"ios": "expo run:ios",
|
||||
"postinstall": "node scripts/build-terminal-webview-engine.mjs",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "oxlint",
|
||||
"format": "oxfmt --write .",
|
||||
"format:check": "oxfmt --check .",
|
||||
@@ -17,6 +18,7 @@
|
||||
"start:emulator": "node scripts/start-emulator.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@noble/hashes": "1.8.0",
|
||||
"@orca/expo-two-way-audio": "file:./packages/expo-two-way-audio",
|
||||
"@react-native-async-storage/async-storage": "^2.2.0",
|
||||
"@xterm/addon-unicode11": "0.10.0-beta.285",
|
||||
|
||||
Generated
+106
-39
@@ -11,6 +11,9 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@noble/hashes':
|
||||
specifier: 1.8.0
|
||||
version: 1.8.0
|
||||
'@orca/expo-two-way-audio':
|
||||
specifier: file:./packages/expo-two-way-audio
|
||||
version: file:packages/expo-two-way-audio(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
|
||||
@@ -1878,6 +1881,10 @@ packages:
|
||||
'@nicolo-ribaudo/chokidar-2@2.1.8-no-fsevents.3':
|
||||
resolution: {integrity: sha512-s88O1aVtXftvp5bCPB7WnmXc5IwOZZ7YPuwNPt+GtOOXpPvad1LfbmjYv+qII7zP6RU2QGnqve27dnLycEnyEQ==}
|
||||
|
||||
'@noble/hashes@1.8.0':
|
||||
resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
|
||||
engines: {node: ^14.21.3 || >=16}
|
||||
|
||||
'@orca/expo-two-way-audio@file:packages/expo-two-way-audio':
|
||||
resolution: {directory: packages/expo-two-way-audio, type: directory}
|
||||
peerDependencies:
|
||||
@@ -1935,48 +1942,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 +2064,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 +2575,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 +4910,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 +7058,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 +7083,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 +7118,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 +7138,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 +8637,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 +8804,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
|
||||
|
||||
@@ -8809,6 +8842,8 @@ snapshots:
|
||||
'@nicolo-ribaudo/chokidar-2@2.1.8-no-fsevents.3':
|
||||
optional: true
|
||||
|
||||
'@noble/hashes@1.8.0': {}
|
||||
|
||||
'@orca/expo-two-way-audio@file:packages/expo-two-way-audio(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)':
|
||||
dependencies:
|
||||
expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@6.0.3)
|
||||
@@ -9529,24 +9564,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 +9596,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 +9674,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 +9694,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 +9711,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 +9729,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 +9762,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 +10035,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 +10336,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 +10345,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 +10741,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 +10839,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 +10867,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 +10890,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 +10901,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 +11578,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 +12150,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 +12305,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 +12350,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 +14071,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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { useRef, type ReactNode } from 'react'
|
||||
import { ActivityIndicator, View, Text, Pressable, StyleSheet } from 'react-native'
|
||||
import { Edit3, Trash2, type LucideIcon } from 'lucide-react-native'
|
||||
import { colors, spacing, typography } from '../theme/mobile-theme'
|
||||
@@ -13,6 +13,7 @@ export type ActionSheetAction = {
|
||||
hint?: string
|
||||
loading?: boolean
|
||||
skipAutoClose?: boolean
|
||||
closeBeforePress?: boolean
|
||||
onPress: () => void
|
||||
}
|
||||
|
||||
@@ -107,9 +108,37 @@ export function ActionSheetContent({ title, message, actions, onClose }: Content
|
||||
}
|
||||
|
||||
export function ActionSheetModal({ visible, title, message, actions, onClose }: Props) {
|
||||
const pendingActionRef = useRef<(() => void) | null>(null)
|
||||
const sequencedActions = actions.map((action) =>
|
||||
action.closeBeforePress
|
||||
? {
|
||||
...action,
|
||||
onPress: () => {
|
||||
pendingActionRef.current = action.onPress
|
||||
}
|
||||
}
|
||||
: action
|
||||
)
|
||||
|
||||
return (
|
||||
<BottomDrawer visible={visible} onClose={onClose} dragContentToDismiss>
|
||||
<ActionSheetContent title={title} message={message} actions={actions} onClose={onClose} />
|
||||
<BottomDrawer
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
onAfterClose={() => {
|
||||
// Why: iOS cannot present a second native modal until the action
|
||||
// sheet's native window has fully unmounted.
|
||||
const pendingAction = pendingActionRef.current
|
||||
pendingActionRef.current = null
|
||||
pendingAction?.()
|
||||
}}
|
||||
dragContentToDismiss
|
||||
>
|
||||
<ActionSheetContent
|
||||
title={title}
|
||||
message={message}
|
||||
actions={sequencedActions}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</BottomDrawer>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -39,6 +40,7 @@ const TOP_SCROLL_EPSILON = 1
|
||||
type Props = {
|
||||
visible: boolean
|
||||
onClose: () => void
|
||||
onAfterClose?: () => void
|
||||
children: ReactNode
|
||||
dragContentToDismiss?: boolean
|
||||
contentScrollable?: boolean
|
||||
@@ -48,6 +50,7 @@ type Props = {
|
||||
export function BottomDrawer({
|
||||
visible,
|
||||
onClose,
|
||||
onAfterClose,
|
||||
children,
|
||||
dragContentToDismiss = true,
|
||||
contentScrollable = true,
|
||||
@@ -72,7 +75,10 @@ export function BottomDrawer({
|
||||
<MountedBottomDrawer
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
onHidden={() => setMounted(false)}
|
||||
onHidden={() => {
|
||||
setMounted(false)
|
||||
onAfterClose?.()
|
||||
}}
|
||||
dragContentToDismiss={dragContentToDismiss}
|
||||
contentScrollable={contentScrollable}
|
||||
zIndex={zIndex}
|
||||
@@ -107,6 +113,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 +272,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} />
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { ChevronRight, Monitor } from 'lucide-react-native'
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native'
|
||||
import type { ConnectionVerdict } from '../transport/connection-health'
|
||||
import { verdictDisplayLabel } from '../transport/connection-health'
|
||||
import { mobileConnectionPathLabel } from '../transport/mobile-connection-path-label'
|
||||
import type { MobileConnectionPath } from '../transport/stable-logical-rpc-client'
|
||||
import type { ConnectionState, HostProfile } from '../transport/types'
|
||||
import { colors, radii, spacing } from '../theme/mobile-theme'
|
||||
import { StatusDot } from './StatusDot'
|
||||
|
||||
export function MobileHostCard(props: {
|
||||
host: HostProfile
|
||||
state: ConnectionState
|
||||
verdict: ConnectionVerdict
|
||||
path: MobileConnectionPath
|
||||
worktreeCounts?: { total: number; active: number }
|
||||
onPress: () => void
|
||||
onLongPress: () => void
|
||||
}) {
|
||||
const connected = props.state === 'connected'
|
||||
const isError = ['warning', 'unreachable', 'auth-failed'].includes(props.verdict.kind)
|
||||
const worktreeSummary = props.worktreeCounts
|
||||
? `${props.worktreeCounts.total} worktree${props.worktreeCounts.total === 1 ? '' : 's'}${props.worktreeCounts.active > 0 ? ` · ${props.worktreeCounts.active} active` : ''}`
|
||||
: null
|
||||
return (
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.card, pressed && styles.cardPressed]}
|
||||
onPress={props.onPress}
|
||||
onLongPress={props.onLongPress}
|
||||
delayLongPress={400}
|
||||
>
|
||||
<View style={styles.icon}>
|
||||
<Monitor size={20} color={connected ? colors.textPrimary : colors.textSecondary} />
|
||||
</View>
|
||||
<View style={styles.main}>
|
||||
<Text
|
||||
style={[styles.name, !connected && { color: colors.textSecondary }]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{props.host.name}
|
||||
</Text>
|
||||
<View style={styles.meta}>
|
||||
<StatusDot state={props.state} verdict={props.verdict} />
|
||||
<Text style={[styles.metaText, isError && { color: colors.statusRed }]} numberOfLines={1}>
|
||||
{verdictDisplayLabel(props.verdict)}
|
||||
{connected ? ` · ${mobileConnectionPathLabel(props.path)}` : ''}
|
||||
</Text>
|
||||
</View>
|
||||
{connected && worktreeSummary ? (
|
||||
<Text style={styles.worktreeMetaText} numberOfLines={1}>
|
||||
{worktreeSummary}
|
||||
</Text>
|
||||
) : null}
|
||||
{props.verdict.kind === 'unreachable' && !props.host.relay ? (
|
||||
<Text style={styles.discoveryHint} numberOfLines={2}>
|
||||
Update desktop Orca and sign in to connect from anywhere
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<ChevronRight size={16} color={colors.textMuted} />
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: 12,
|
||||
borderRadius: radii.card,
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle
|
||||
},
|
||||
cardPressed: { backgroundColor: colors.bgRaised },
|
||||
icon: {
|
||||
width: 46,
|
||||
height: 46,
|
||||
borderRadius: 13,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.bgRaised,
|
||||
marginRight: 14
|
||||
},
|
||||
main: { flex: 1, minWidth: 0, marginRight: spacing.sm },
|
||||
name: { color: colors.textPrimary, fontSize: 15, fontWeight: '600', lineHeight: 20 },
|
||||
meta: { flexDirection: 'row', alignItems: 'center', gap: 6, marginTop: 3, minWidth: 0 },
|
||||
metaText: { flex: 1, fontSize: 12, color: colors.textSecondary },
|
||||
worktreeMetaText: {
|
||||
marginTop: 2,
|
||||
marginLeft: spacing.xl,
|
||||
fontSize: 12,
|
||||
color: colors.textMuted
|
||||
},
|
||||
discoveryHint: {
|
||||
marginTop: spacing.xs,
|
||||
fontSize: 11,
|
||||
lineHeight: 15,
|
||||
color: colors.textMuted
|
||||
}
|
||||
})
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcFailure, RpcResponse, RpcSuccess } from '../transport/types'
|
||||
import {
|
||||
@@ -67,6 +68,26 @@ describe('rpc wrappers', () => {
|
||||
expect(client.calls[0]).toEqual({ method: 'speech.models.list', params: null })
|
||||
})
|
||||
|
||||
it('retries the idempotent setup read once after logical-client cutover', async () => {
|
||||
const setup: MobileSpeechSetup = { enabled: false, selectedModelId: '', models: [] }
|
||||
const sendRequest = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new LogicalClientCutoverError())
|
||||
.mockResolvedValueOnce(ok(setup))
|
||||
|
||||
await expect(fetchDictationSetup({ sendRequest })).resolves.toEqual(setup)
|
||||
expect(sendRequest).toHaveBeenCalledTimes(2)
|
||||
expect(sendRequest).toHaveBeenNthCalledWith(1, 'speech.models.list', null)
|
||||
expect(sendRequest).toHaveBeenNthCalledWith(2, 'speech.models.list', null)
|
||||
})
|
||||
|
||||
it('does not retry unrelated setup-read failures', async () => {
|
||||
const sendRequest = vi.fn().mockRejectedValue(new Error('offline'))
|
||||
|
||||
await expect(fetchDictationSetup({ sendRequest })).rejects.toThrow('offline')
|
||||
expect(sendRequest).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('starts a download', async () => {
|
||||
const client = clientWith([ok({ started: true })])
|
||||
await downloadDictationModel(client, 'm1')
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { RuntimeSpeechSetupState } from '../../../src/shared/runtime-types'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
|
||||
import type { RpcSuccess } from '../transport/types'
|
||||
|
||||
export type MobileSpeechSetup = RuntimeSpeechSetupState
|
||||
@@ -31,7 +32,7 @@ export function isDictationSetupRequiredError(message: string): boolean {
|
||||
export async function fetchDictationSetup(
|
||||
client: Pick<RpcClient, 'sendRequest'>
|
||||
): Promise<MobileSpeechSetup> {
|
||||
const response = await client.sendRequest('speech.models.list', null)
|
||||
const response = await fetchDictationSetupResponse(client)
|
||||
if (!response.ok) {
|
||||
if (isLegacyDesktopSpeechSetupError(response.error)) {
|
||||
throw new Error(LEGACY_DESKTOP_SPEECH_SETUP_MESSAGE)
|
||||
@@ -41,6 +42,19 @@ export async function fetchDictationSetup(
|
||||
return (response as RpcSuccess).result as MobileSpeechSetup
|
||||
}
|
||||
|
||||
async function fetchDictationSetupResponse(client: Pick<RpcClient, 'sendRequest'>) {
|
||||
try {
|
||||
return await client.sendRequest('speech.models.list', null)
|
||||
} catch (error) {
|
||||
if (!(error instanceof LogicalClientCutoverError)) {
|
||||
throw error
|
||||
}
|
||||
// Why: this read can safely repeat on the authenticated replacement; mutation
|
||||
// RPCs must still surface cutover so callers never replay unknown commits.
|
||||
return client.sendRequest('speech.models.list', null)
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadDictationModel(
|
||||
client: Pick<RpcClient, 'sendRequest'>,
|
||||
modelId: string
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import * as Notifications from 'expo-notifications'
|
||||
import { Platform } from 'react-native'
|
||||
import {
|
||||
getNotificationPermissionState,
|
||||
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'
|
||||
|
||||
@@ -17,13 +20,49 @@ vi.mock('expo-notifications', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
Platform: { OS: 'ios' }
|
||||
Platform: { OS: 'ios', Version: 18 }
|
||||
}))
|
||||
|
||||
// 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()
|
||||
}))
|
||||
|
||||
beforeEach(() => {
|
||||
Object.assign(Platform, { OS: 'ios', Version: 18 })
|
||||
})
|
||||
|
||||
describe('getNotificationPermissionState', () => {
|
||||
it.each([
|
||||
{ os: 'android', version: 32, expected: false },
|
||||
{ os: 'android', version: 33, expected: true },
|
||||
{ os: 'ios', version: 18, expected: true }
|
||||
])(
|
||||
'reports whether a granted $os $version authorization reflects user choice',
|
||||
async ({ os, version, expected }) => {
|
||||
Object.assign(Platform, { OS: os, Version: version })
|
||||
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
|
||||
status: 'granted',
|
||||
canAskAgain: true
|
||||
} as never)
|
||||
|
||||
await expect(getNotificationPermissionState()).resolves.toMatchObject({
|
||||
granted: true,
|
||||
authorizationReflectsUserChoice: expected
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('subscribeToDesktopNotifications', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -316,3 +355,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 = {
|
||||
@@ -71,6 +82,7 @@ export type NotificationPermissionState = {
|
||||
granted: boolean
|
||||
status: string
|
||||
canAskAgain: boolean
|
||||
authorizationReflectsUserChoice: boolean
|
||||
}
|
||||
|
||||
export async function getNotificationPermissionState(): Promise<NotificationPermissionState> {
|
||||
@@ -78,7 +90,11 @@ export async function getNotificationPermissionState(): Promise<NotificationPerm
|
||||
return {
|
||||
granted: status === 'granted',
|
||||
status,
|
||||
canAskAgain
|
||||
canAskAgain,
|
||||
// Why: Android before API 33 has no runtime notification permission, so
|
||||
// Expo's default "granted" state is capability evidence, not user consent.
|
||||
authorizationReflectsUserChoice:
|
||||
status === 'granted' && (Platform.OS !== 'android' || Number(Platform.Version) >= 33)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,18 +239,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 +346,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 +371,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,92 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
readPushNotificationsPreference,
|
||||
savePushNotificationsEnabled
|
||||
} from '../storage/preferences'
|
||||
import { getNotificationPermissionState } from './mobile-notifications'
|
||||
import { shouldPresentNotificationOptIn } from './notification-opt-in-gate'
|
||||
|
||||
vi.mock('../storage/preferences', () => ({
|
||||
readPushNotificationsPreference: vi.fn(),
|
||||
savePushNotificationsEnabled: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./mobile-notifications', () => ({
|
||||
getNotificationPermissionState: vi.fn()
|
||||
}))
|
||||
|
||||
describe('notification opt-in gate', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(readPushNotificationsPreference).mockReset()
|
||||
vi.mocked(savePushNotificationsEnabled).mockReset()
|
||||
vi.mocked(getNotificationPermissionState).mockReset()
|
||||
})
|
||||
|
||||
it('presents only when the local preference and system decision are both unset', async () => {
|
||||
vi.mocked(readPushNotificationsPreference).mockResolvedValue({ value: null, loaded: true })
|
||||
vi.mocked(getNotificationPermissionState).mockResolvedValue({
|
||||
granted: false,
|
||||
status: 'undetermined',
|
||||
canAskAgain: true,
|
||||
authorizationReflectsUserChoice: false
|
||||
})
|
||||
|
||||
await expect(shouldPresentNotificationOptIn()).resolves.toBe(true)
|
||||
expect(savePushNotificationsEnabled).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([true, false])('preserves an existing %s mobile preference', async (value) => {
|
||||
vi.mocked(readPushNotificationsPreference).mockResolvedValue({ value, loaded: true })
|
||||
|
||||
await expect(shouldPresentNotificationOptIn()).resolves.toBe(false)
|
||||
expect(getNotificationPermissionState).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('adopts existing system authorization without prompting', async () => {
|
||||
vi.mocked(readPushNotificationsPreference).mockResolvedValue({ value: null, loaded: true })
|
||||
vi.mocked(getNotificationPermissionState).mockResolvedValue({
|
||||
granted: true,
|
||||
status: 'granted',
|
||||
canAskAgain: true,
|
||||
authorizationReflectsUserChoice: true
|
||||
})
|
||||
|
||||
await expect(shouldPresentNotificationOptIn()).resolves.toBe(false)
|
||||
expect(savePushNotificationsEnabled).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('still presents when a pre-Android 13 default grant is not an opt-in decision', async () => {
|
||||
vi.mocked(readPushNotificationsPreference).mockResolvedValue({ value: null, loaded: true })
|
||||
vi.mocked(getNotificationPermissionState).mockResolvedValue({
|
||||
granted: true,
|
||||
status: 'granted',
|
||||
canAskAgain: true,
|
||||
authorizationReflectsUserChoice: false
|
||||
})
|
||||
|
||||
await expect(shouldPresentNotificationOptIn()).resolves.toBe(true)
|
||||
expect(savePushNotificationsEnabled).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips the gate when iOS has already denied permission', async () => {
|
||||
vi.mocked(readPushNotificationsPreference).mockResolvedValue({ value: null, loaded: true })
|
||||
vi.mocked(getNotificationPermissionState).mockResolvedValue({
|
||||
granted: false,
|
||||
status: 'denied',
|
||||
canAskAgain: false,
|
||||
authorizationReflectsUserChoice: false
|
||||
})
|
||||
|
||||
await expect(shouldPresentNotificationOptIn()).resolves.toBe(false)
|
||||
expect(savePushNotificationsEnabled).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('does not block startup when storage or permission checks fail', async () => {
|
||||
vi.mocked(readPushNotificationsPreference).mockResolvedValue({ value: null, loaded: false })
|
||||
await expect(shouldPresentNotificationOptIn()).resolves.toBe(false)
|
||||
|
||||
vi.mocked(readPushNotificationsPreference).mockResolvedValue({ value: null, loaded: true })
|
||||
vi.mocked(getNotificationPermissionState).mockRejectedValue(new Error('unavailable'))
|
||||
await expect(shouldPresentNotificationOptIn()).resolves.toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
readPushNotificationsPreference,
|
||||
savePushNotificationsEnabled
|
||||
} from '../storage/preferences'
|
||||
import { getNotificationPermissionState } from './mobile-notifications'
|
||||
|
||||
export async function shouldPresentNotificationOptIn(): Promise<boolean> {
|
||||
const preference = await readPushNotificationsPreference()
|
||||
if (!preference.loaded || preference.value !== null) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const permission = await getNotificationPermissionState()
|
||||
if (permission.granted) {
|
||||
if (!permission.authorizationReflectsUserChoice) {
|
||||
return true
|
||||
}
|
||||
// Why: an already-authorized device should inherit the useful default
|
||||
// without seeing an onboarding decision it has effectively made.
|
||||
await savePushNotificationsEnabled(true)
|
||||
return false
|
||||
}
|
||||
if (permission.status === 'denied' || !permission.canAskAgain) {
|
||||
// Why: iOS cannot show its authorization prompt again, so a blocking
|
||||
// onboarding screen would be a dead end; Settings remains the recovery.
|
||||
await savePushNotificationsEnabled(false)
|
||||
return false
|
||||
}
|
||||
return permission.status === 'undetermined'
|
||||
} catch {
|
||||
// Why: permission or persistence failures must not trap startup behind a
|
||||
// decision screen whose result cannot be applied reliably.
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -33,3 +33,31 @@ export function isGestureMouseTrackingMode(
|
||||
): boolean {
|
||||
return mode === 'x10' || mode === 'vt200' || mode === 'drag' || mode === 'any'
|
||||
}
|
||||
|
||||
export function getActiveTabIdForHandle(
|
||||
tabs: ReadonlyArray<{ id: string; type: string; terminal?: string | null }>,
|
||||
terminalHandle: string | null
|
||||
): string | null {
|
||||
if (!terminalHandle) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
tabs.find((tab) => tab.type === 'terminal' && tab.terminal === terminalHandle)?.id ??
|
||||
terminalHandle
|
||||
)
|
||||
}
|
||||
|
||||
export function updateTerminalCwdFromStreamEvent(
|
||||
handle: string,
|
||||
data: Readonly<Record<string, unknown>>,
|
||||
terminalCwd: Map<string, string>
|
||||
): void {
|
||||
if (!('cwd' in data)) {
|
||||
return
|
||||
}
|
||||
if (typeof data.cwd === 'string' && data.cwd.trim().length > 0) {
|
||||
terminalCwd.set(handle, data.cwd)
|
||||
return
|
||||
}
|
||||
terminalCwd.delete(handle)
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ describe('mobile session startup', () => {
|
||||
expect(pendingActivationEffect).toContain(
|
||||
'pendingTerminalActivationAttemptRef.current === activationKey'
|
||||
)
|
||||
expect(pendingActivationEffect).toContain("sendRequest('session.tabs.activate'")
|
||||
expect(pendingActivationEffect).toContain('activateMobileSessionTab(client,')
|
||||
expect(pendingActivationEffect).toContain('tabId: activePendingTerminalTab.id')
|
||||
expect(pendingActivationEffect).toContain('leafId: activePendingTerminalTab.leafId')
|
||||
expect(pendingActivationEffect).toContain('notifyClients: false')
|
||||
@@ -68,8 +68,19 @@ describe('mobile session startup', () => {
|
||||
expect(pendingActivationEffect).toContain('scheduleDelayedAction(() => void fetchSessionTabs()')
|
||||
})
|
||||
|
||||
it('keeps mobile session tab activation local to the phone', () => {
|
||||
const activationRequests = source.split("sendRequest('session.tabs.activate'").slice(1)
|
||||
it('mirrors ready terminal taps while persisting them for headless hosts', () => {
|
||||
const readyTerminalSwitch = sliceBetween(
|
||||
'const switchTab = useCallback(',
|
||||
'const switchSessionTab = useCallback('
|
||||
)
|
||||
|
||||
expect(readyTerminalSwitch).toContain('focusMobileTerminal(client, handle)')
|
||||
expect(readyTerminalSwitch).toContain('activateMobileSessionTab(client,')
|
||||
expect(readyTerminalSwitch).toContain('notifyClients: false')
|
||||
})
|
||||
|
||||
it('keeps background and pending session-tab activation local to the phone', () => {
|
||||
const activationRequests = source.split('activateMobileSessionTab(client,').slice(1)
|
||||
|
||||
expect(activationRequests).toHaveLength(4)
|
||||
for (const request of activationRequests) {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcResponse } from '../transport/types'
|
||||
import { activateMobileSessionTab, focusMobileTerminal } from './mobile-session-tab-activation'
|
||||
|
||||
function success(): RpcResponse {
|
||||
return { id: 'rpc-1', ok: true, result: {}, _meta: { runtimeId: 'runtime-1' } }
|
||||
}
|
||||
|
||||
function clientWith(sendRequest: RpcClient['sendRequest']): Pick<RpcClient, 'sendRequest'> {
|
||||
return { sendRequest }
|
||||
}
|
||||
|
||||
describe('mobile session tab activation', () => {
|
||||
it('retries terminal focus once on the authenticated replacement after cutover', async () => {
|
||||
const sendRequest = vi
|
||||
.fn<RpcClient['sendRequest']>()
|
||||
.mockRejectedValueOnce(new LogicalClientCutoverError())
|
||||
.mockResolvedValueOnce(success())
|
||||
|
||||
await expect(focusMobileTerminal(clientWith(sendRequest), 'terminal-1')).resolves.toMatchObject(
|
||||
{
|
||||
ok: true
|
||||
}
|
||||
)
|
||||
expect(sendRequest).toHaveBeenCalledTimes(2)
|
||||
expect(sendRequest).toHaveBeenNthCalledWith(1, 'terminal.focus', { terminal: 'terminal-1' })
|
||||
expect(sendRequest).toHaveBeenNthCalledWith(2, 'terminal.focus', { terminal: 'terminal-1' })
|
||||
})
|
||||
|
||||
it('retries session-tab activation with the same target after cutover', async () => {
|
||||
const sendRequest = vi
|
||||
.fn<RpcClient['sendRequest']>()
|
||||
.mockRejectedValueOnce(new LogicalClientCutoverError())
|
||||
.mockResolvedValueOnce(success())
|
||||
const params = {
|
||||
worktree: 'id:worktree-1',
|
||||
tabId: 'tab-1',
|
||||
leafId: 'leaf-1',
|
||||
notifyClients: false as const
|
||||
}
|
||||
|
||||
await expect(activateMobileSessionTab(clientWith(sendRequest), params)).resolves.toMatchObject({
|
||||
ok: true
|
||||
})
|
||||
expect(sendRequest).toHaveBeenCalledTimes(2)
|
||||
expect(sendRequest).toHaveBeenNthCalledWith(1, 'session.tabs.activate', params)
|
||||
expect(sendRequest).toHaveBeenNthCalledWith(2, 'session.tabs.activate', params)
|
||||
})
|
||||
|
||||
it('does not retry unrelated transport failures', async () => {
|
||||
const sendRequest = vi.fn<RpcClient['sendRequest']>().mockRejectedValue(new Error('offline'))
|
||||
|
||||
await expect(focusMobileTerminal(clientWith(sendRequest), 'terminal-1')).rejects.toThrow(
|
||||
'offline'
|
||||
)
|
||||
expect(sendRequest).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('retries at most once when consecutive cutovers interrupt activation', async () => {
|
||||
const sendRequest = vi
|
||||
.fn<RpcClient['sendRequest']>()
|
||||
.mockRejectedValue(new LogicalClientCutoverError())
|
||||
|
||||
await expect(
|
||||
activateMobileSessionTab(clientWith(sendRequest), {
|
||||
worktree: 'id:worktree-1',
|
||||
tabId: 'tab-1',
|
||||
notifyClients: false
|
||||
})
|
||||
).rejects.toBeInstanceOf(LogicalClientCutoverError)
|
||||
expect(sendRequest).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
|
||||
import type { RpcResponse } from '../transport/types'
|
||||
import {
|
||||
getMobileTerminalDiagnosticErrorName,
|
||||
logMobileTerminalDiagnostic,
|
||||
shortenMobileTerminalDiagnosticId
|
||||
} from './mobile-terminal-diagnostics'
|
||||
|
||||
type ActivationClient = Pick<RpcClient, 'sendRequest'>
|
||||
|
||||
type MobileSessionTabActivationParams = {
|
||||
worktree: string
|
||||
tabId: string
|
||||
leafId?: string
|
||||
notifyClients: false
|
||||
}
|
||||
|
||||
async function retryIdempotentActivationAfterCutover(
|
||||
request: () => Promise<RpcResponse>,
|
||||
operation: 'terminal.focus' | 'session.tabs.activate',
|
||||
target: string
|
||||
): Promise<RpcResponse> {
|
||||
const diagnosticTarget = shortenMobileTerminalDiagnosticId(target)
|
||||
logMobileTerminalDiagnostic('activation-request', { operation, target: diagnosticTarget })
|
||||
try {
|
||||
const response = await request()
|
||||
logMobileTerminalDiagnostic('activation-result', {
|
||||
operation,
|
||||
target: diagnosticTarget,
|
||||
ok: response.ok,
|
||||
rpcCode: response.ok ? null : response.error.code
|
||||
})
|
||||
return response
|
||||
} catch (error) {
|
||||
if (!(error instanceof LogicalClientCutoverError)) {
|
||||
logMobileTerminalDiagnostic('activation-error', {
|
||||
operation,
|
||||
target: diagnosticTarget,
|
||||
errorName: getMobileTerminalDiagnosticErrorName(error)
|
||||
})
|
||||
throw error
|
||||
}
|
||||
logMobileTerminalDiagnostic('activation-cutover-retry', {
|
||||
operation,
|
||||
target: diagnosticTarget
|
||||
})
|
||||
// Why: cutover rejects ambiguous in-flight work after the replacement is
|
||||
// active; these state-setting requests are idempotent and safe to repeat once.
|
||||
try {
|
||||
const response = await request()
|
||||
logMobileTerminalDiagnostic('activation-result', {
|
||||
operation,
|
||||
target: diagnosticTarget,
|
||||
ok: response.ok,
|
||||
rpcCode: response.ok ? null : response.error.code
|
||||
})
|
||||
return response
|
||||
} catch (retryError) {
|
||||
logMobileTerminalDiagnostic('activation-error', {
|
||||
operation,
|
||||
target: diagnosticTarget,
|
||||
errorName: getMobileTerminalDiagnosticErrorName(retryError)
|
||||
})
|
||||
throw retryError
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function focusMobileTerminal(
|
||||
client: ActivationClient,
|
||||
terminal: string
|
||||
): Promise<RpcResponse> {
|
||||
return retryIdempotentActivationAfterCutover(
|
||||
() => client.sendRequest('terminal.focus', { terminal }),
|
||||
'terminal.focus',
|
||||
terminal
|
||||
)
|
||||
}
|
||||
|
||||
export function activateMobileSessionTab(
|
||||
client: ActivationClient,
|
||||
params: MobileSessionTabActivationParams
|
||||
): Promise<RpcResponse> {
|
||||
return retryIdempotentActivationAfterCutover(
|
||||
() => client.sendRequest('session.tabs.activate', params),
|
||||
'session.tabs.activate',
|
||||
params.tabId
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
getMobileTerminalDiagnosticErrorName,
|
||||
logMobileTerminalDiagnostic,
|
||||
MobileTerminalDiagnostics,
|
||||
shortenMobileTerminalDiagnosticId
|
||||
} from './mobile-terminal-diagnostics'
|
||||
|
||||
describe('mobile terminal diagnostics', () => {
|
||||
it('keeps only the correlatable suffix of identifiers', () => {
|
||||
expect(shortenMobileTerminalDiagnosticId('terminal-secret-prefix-12345678')).toBe('12345678')
|
||||
expect(shortenMobileTerminalDiagnosticId('short')).toBe('short')
|
||||
expect(shortenMobileTerminalDiagnosticId(null)).toBeNull()
|
||||
})
|
||||
|
||||
it('reports thrown error types without copying potentially sensitive messages', () => {
|
||||
expect(getMobileTerminalDiagnosticErrorName(new TypeError('/private/worktree failed'))).toBe(
|
||||
'TypeError'
|
||||
)
|
||||
expect(getMobileTerminalDiagnosticErrorName('raw failure')).toBe('string')
|
||||
})
|
||||
|
||||
it('uses one filterable structured log tag', () => {
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
logMobileTerminalDiagnostic('stream-armed', { handle: '12345678', seq: 2 })
|
||||
|
||||
expect(log).toHaveBeenCalledWith('[terminal-diagnostic]', 'stream-armed', {
|
||||
handle: '12345678',
|
||||
seq: 2
|
||||
})
|
||||
log.mockRestore()
|
||||
})
|
||||
|
||||
it('forgets first-event state when a terminal unsubscribes', () => {
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
const diagnostics = new MobileTerminalDiagnostics()
|
||||
|
||||
diagnostics.firstStreamEvent('terminal-1', 1, 'subscribed')
|
||||
diagnostics.terminalUnsubscribed('terminal-1')
|
||||
diagnostics.firstStreamEvent('terminal-1', 1, 'subscribed')
|
||||
|
||||
expect(log).toHaveBeenCalledTimes(2)
|
||||
log.mockRestore()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,291 @@
|
||||
const MOBILE_TERMINAL_DIAGNOSTIC_TAG = '[terminal-diagnostic]'
|
||||
|
||||
type MobileTerminalDiagnosticValue = string | number | boolean | null | undefined
|
||||
|
||||
export type MobileTerminalDiagnosticDetails = Readonly<
|
||||
Record<string, MobileTerminalDiagnosticValue>
|
||||
>
|
||||
|
||||
type DiagnosticTab = {
|
||||
readonly id: string
|
||||
readonly type: string
|
||||
readonly isActive: boolean
|
||||
readonly terminal?: string | null
|
||||
}
|
||||
|
||||
type DiagnosticTabsSnapshot = {
|
||||
readonly publicationEpoch?: string
|
||||
readonly snapshotVersion: number
|
||||
readonly tabs: readonly DiagnosticTab[]
|
||||
}
|
||||
|
||||
type DiagnosticDimensions = { readonly cols: number; readonly rows: number } | null | undefined
|
||||
|
||||
// Why: full runtime identifiers make shared logs unnecessarily sensitive; the
|
||||
// suffix is enough to correlate lifecycle events within one reproduction.
|
||||
export function shortenMobileTerminalDiagnosticId(value: string | null | undefined): string | null {
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
return value.slice(-8)
|
||||
}
|
||||
|
||||
export function getMobileTerminalDiagnosticErrorName(error: unknown): string {
|
||||
if (error instanceof Error && error.name) {
|
||||
return error.name
|
||||
}
|
||||
return typeof error
|
||||
}
|
||||
|
||||
export function logMobileTerminalDiagnostic(
|
||||
event: string,
|
||||
details: MobileTerminalDiagnosticDetails = {}
|
||||
): void {
|
||||
// Why: lifecycle diagnostics are intentionally available for HMR repros,
|
||||
// but high-frequency WebView events must not add production log overhead.
|
||||
if (typeof __DEV__ !== 'undefined' && !__DEV__) {
|
||||
return
|
||||
}
|
||||
// Keep this structured and content-free so users can safely share a filtered log.
|
||||
console.log(MOBILE_TERMINAL_DIAGNOSTIC_TAG, event, details)
|
||||
}
|
||||
|
||||
export class MobileTerminalDiagnostics {
|
||||
private readonly streamGateByHandle = new Map<string, string>()
|
||||
private readonly firstStreamEventSeqByHandle = new Map<string, number>()
|
||||
private lastFetchedTabsSignature: string | null = null
|
||||
private lastAppliedTabsSignature: string | null = null
|
||||
private lastTabsFetchStartAt = 0
|
||||
private tabsFetchSkipLogged = false
|
||||
|
||||
clearTerminalCache(): void {
|
||||
this.streamGateByHandle.clear()
|
||||
this.firstStreamEventSeqByHandle.clear()
|
||||
}
|
||||
|
||||
resetRoute(): void {
|
||||
this.clearTerminalCache()
|
||||
this.lastFetchedTabsSignature = null
|
||||
this.lastAppliedTabsSignature = null
|
||||
this.lastTabsFetchStartAt = 0
|
||||
this.tabsFetchSkipLogged = false
|
||||
}
|
||||
|
||||
terminalUnsubscribed(handle: string): void {
|
||||
this.streamGateByHandle.delete(handle)
|
||||
this.firstStreamEventSeqByHandle.delete(handle)
|
||||
}
|
||||
|
||||
viewportMeasured(handle: string, dims: DiagnosticDimensions, frameHeight: number): void {
|
||||
logMobileTerminalDiagnostic('viewport-measure', {
|
||||
handle: shortenMobileTerminalDiagnosticId(handle),
|
||||
ok: dims != null,
|
||||
cols: dims?.cols,
|
||||
rows: dims?.rows,
|
||||
frameHeight: Math.round(frameHeight)
|
||||
})
|
||||
}
|
||||
|
||||
streamSkipped(handle: string, reason: string, isActive: boolean): void {
|
||||
if (this.streamGateByHandle.get(handle) === reason) {
|
||||
return
|
||||
}
|
||||
this.streamGateByHandle.set(handle, reason)
|
||||
logMobileTerminalDiagnostic('stream-skipped', {
|
||||
handle: shortenMobileTerminalDiagnosticId(handle),
|
||||
reason,
|
||||
isActive
|
||||
})
|
||||
}
|
||||
|
||||
streamArmed(handle: string, seq: number, viewport: DiagnosticDimensions): void {
|
||||
this.streamGateByHandle.delete(handle)
|
||||
logMobileTerminalDiagnostic('stream-armed', {
|
||||
handle: shortenMobileTerminalDiagnosticId(handle),
|
||||
seq,
|
||||
hasViewport: viewport != null,
|
||||
viewportCols: viewport?.cols,
|
||||
viewportRows: viewport?.rows
|
||||
})
|
||||
}
|
||||
|
||||
firstStreamEvent(handle: string, seq: number, type: unknown): void {
|
||||
if (this.firstStreamEventSeqByHandle.get(handle) === seq) {
|
||||
return
|
||||
}
|
||||
this.firstStreamEventSeqByHandle.set(handle, seq)
|
||||
logMobileTerminalDiagnostic('stream-first-event', {
|
||||
handle: shortenMobileTerminalDiagnosticId(handle),
|
||||
seq,
|
||||
type: typeof type === 'string' ? type : 'unknown'
|
||||
})
|
||||
}
|
||||
|
||||
streamScrollback(
|
||||
handle: string,
|
||||
seq: number,
|
||||
eventSeq: number | null,
|
||||
data: Readonly<Record<string, unknown>>
|
||||
): void {
|
||||
logMobileTerminalDiagnostic('stream-scrollback', {
|
||||
handle: shortenMobileTerminalDiagnosticId(handle),
|
||||
seq,
|
||||
eventSeq,
|
||||
cols: typeof data.cols === 'number' ? data.cols : null,
|
||||
rows: typeof data.rows === 'number' ? data.rows : null,
|
||||
serializedLength: typeof data.serialized === 'string' ? data.serialized.length : 0,
|
||||
displayMode: typeof data.displayMode === 'string' ? data.displayMode : null,
|
||||
source: typeof data.source === 'string' ? data.source : null,
|
||||
scrollbackRows: typeof data.scrollbackRows === 'number' ? data.scrollbackRows : null,
|
||||
truncated: data.truncated === true || data.truncatedByByteBudget === true
|
||||
})
|
||||
}
|
||||
|
||||
streamResubscribing(handle: string, seq: number, dims: { cols: number; rows: number }): void {
|
||||
logMobileTerminalDiagnostic('stream-resubscribe-for-viewport', {
|
||||
handle: shortenMobileTerminalDiagnosticId(handle),
|
||||
seq,
|
||||
cols: dims.cols,
|
||||
rows: dims.rows
|
||||
})
|
||||
}
|
||||
|
||||
streamResized(
|
||||
handle: string,
|
||||
seq: number,
|
||||
eventSeq: number | null,
|
||||
data: Readonly<Record<string, unknown>>,
|
||||
hasRef: boolean
|
||||
): void {
|
||||
logMobileTerminalDiagnostic('stream-resized', {
|
||||
handle: shortenMobileTerminalDiagnosticId(handle),
|
||||
seq,
|
||||
eventSeq,
|
||||
cols: typeof data.cols === 'number' ? data.cols : null,
|
||||
rows: typeof data.rows === 'number' ? data.rows : null,
|
||||
serializedLength: typeof data.serialized === 'string' ? data.serialized.length : 0,
|
||||
hasRef
|
||||
})
|
||||
}
|
||||
|
||||
tabsApplied(
|
||||
snapshot: DiagnosticTabsSnapshot,
|
||||
tabs: readonly DiagnosticTab[],
|
||||
activeTab: DiagnosticTab | null,
|
||||
selectionSource: string
|
||||
): void {
|
||||
const activeHandle =
|
||||
activeTab?.type === 'terminal' && typeof activeTab.terminal === 'string'
|
||||
? activeTab.terminal
|
||||
: null
|
||||
const appliedSnapshot = { ...snapshot, tabs }
|
||||
const signature = [
|
||||
appliedSnapshot.publicationEpoch ?? '',
|
||||
appliedSnapshot.snapshotVersion,
|
||||
activeTab?.id ?? '',
|
||||
activeHandle ?? '',
|
||||
selectionSource
|
||||
].join(':')
|
||||
if (this.lastAppliedTabsSignature === signature) {
|
||||
return
|
||||
}
|
||||
this.lastAppliedTabsSignature = signature
|
||||
this.logTabs('tabs-applied', appliedSnapshot, activeTab, activeHandle, { selectionSource })
|
||||
}
|
||||
|
||||
tabsFetchSkipped(reason: string): void {
|
||||
if (reason === 'already-in-flight' && this.tabsFetchSkipLogged) {
|
||||
return
|
||||
}
|
||||
this.tabsFetchSkipLogged = reason === 'already-in-flight'
|
||||
logMobileTerminalDiagnostic('tabs-fetch-skipped', { reason })
|
||||
}
|
||||
|
||||
tabsFetchStarted(worktreeId: string): void {
|
||||
this.tabsFetchSkipLogged = false
|
||||
const now = Date.now()
|
||||
if (this.lastFetchedTabsSignature != null && now - this.lastTabsFetchStartAt < 10_000) {
|
||||
return
|
||||
}
|
||||
this.lastTabsFetchStartAt = now
|
||||
logMobileTerminalDiagnostic('tabs-fetch-start', {
|
||||
worktree: shortenMobileTerminalDiagnosticId(worktreeId)
|
||||
})
|
||||
}
|
||||
|
||||
tabsFetchFailed(rpcCode: string): void {
|
||||
logMobileTerminalDiagnostic('tabs-fetch-rpc-failure', { rpcCode })
|
||||
}
|
||||
|
||||
tabsFetchErrored(error: unknown): void {
|
||||
logMobileTerminalDiagnostic('tabs-fetch-error', {
|
||||
errorName: getMobileTerminalDiagnosticErrorName(error)
|
||||
})
|
||||
}
|
||||
|
||||
tabsFetchSucceeded(snapshot: DiagnosticTabsSnapshot): void {
|
||||
const activeTab = snapshot.tabs.find((tab) => tab.isActive) ?? null
|
||||
const signature = [
|
||||
snapshot.publicationEpoch ?? '',
|
||||
snapshot.snapshotVersion,
|
||||
snapshot.tabs.length,
|
||||
activeTab?.id ?? '',
|
||||
activeTab?.type ?? ''
|
||||
].join(':')
|
||||
if (this.lastFetchedTabsSignature === signature) {
|
||||
return
|
||||
}
|
||||
this.lastFetchedTabsSignature = signature
|
||||
const activeHandle =
|
||||
activeTab?.type === 'terminal' && typeof activeTab.terminal === 'string'
|
||||
? activeTab.terminal
|
||||
: null
|
||||
this.logTabs('tabs-fetch-success', snapshot, activeTab, activeHandle)
|
||||
}
|
||||
|
||||
tabSwitch(tabType: string, tabId: string, pending: boolean, handle?: string): void {
|
||||
logMobileTerminalDiagnostic('tab-switch', {
|
||||
tabType,
|
||||
tab: shortenMobileTerminalDiagnosticId(tabId),
|
||||
handle: shortenMobileTerminalDiagnosticId(handle),
|
||||
pending
|
||||
})
|
||||
}
|
||||
|
||||
webViewRef(handle: string, attached: boolean): void {
|
||||
logMobileTerminalDiagnostic('webview-ref', {
|
||||
handle: shortenMobileTerminalDiagnosticId(handle),
|
||||
attached
|
||||
})
|
||||
}
|
||||
|
||||
webViewReady(handle: string, reload: boolean, isActive: boolean): void {
|
||||
logMobileTerminalDiagnostic('webview-ready', {
|
||||
handle: shortenMobileTerminalDiagnosticId(handle),
|
||||
reload,
|
||||
isActive
|
||||
})
|
||||
}
|
||||
|
||||
private logTabs(
|
||||
event: 'tabs-applied' | 'tabs-fetch-success',
|
||||
snapshot: DiagnosticTabsSnapshot,
|
||||
activeTab: DiagnosticTab | null,
|
||||
activeHandle: string | null,
|
||||
extra: MobileTerminalDiagnosticDetails = {}
|
||||
): void {
|
||||
logMobileTerminalDiagnostic(event, {
|
||||
publication: shortenMobileTerminalDiagnosticId(snapshot.publicationEpoch),
|
||||
snapshotVersion: snapshot.snapshotVersion,
|
||||
tabCount: snapshot.tabs.length,
|
||||
terminalTabCount: snapshot.tabs.filter((tab) => tab.type === 'terminal').length,
|
||||
pendingTerminalCount: snapshot.tabs.filter(
|
||||
(tab) => tab.type === 'terminal' && typeof tab.terminal !== 'string'
|
||||
).length,
|
||||
activeTab: shortenMobileTerminalDiagnosticId(activeTab?.id),
|
||||
activeType: activeTab?.type ?? null,
|
||||
activeHandle: shortenMobileTerminalDiagnosticId(activeHandle),
|
||||
...extra
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
acceptSessionSnapshot,
|
||||
applyClosedTabTombstones,
|
||||
confirmsMirroredTabSelection,
|
||||
type AppliedSnapshotMarker
|
||||
} from './session-tab-snapshot-gate'
|
||||
|
||||
@@ -50,6 +51,18 @@ describe('acceptSessionSnapshot', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('confirmsMirroredTabSelection', () => {
|
||||
it('does not treat phone-local persistence as desktop focus confirmation', () => {
|
||||
expect(confirmsMirroredTabSelection('mobile-local:abc')).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts renderer, headless, and legacy publications as confirmation', () => {
|
||||
expect(confirmsMirroredTabSelection('renderer:abc')).toBe(true)
|
||||
expect(confirmsMirroredTabSelection('headless:abc')).toBe(true)
|
||||
expect(confirmsMirroredTabSelection()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyClosedTabTombstones', () => {
|
||||
const tab = (id: string): { id: string } => ({ id })
|
||||
|
||||
|
||||
@@ -33,6 +33,12 @@ export function acceptSessionSnapshot(
|
||||
return true
|
||||
}
|
||||
|
||||
export function confirmsMirroredTabSelection(publicationEpoch?: string): boolean {
|
||||
// Why: phone-local persistence acknowledges the mutation, not host focus;
|
||||
// keep the pending guard until any host publication confirms the selection.
|
||||
return !publicationEpoch?.startsWith('mobile-local:')
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops tabs the user just closed locally (tombstoned) until the publisher's
|
||||
* snapshot also drops them or the tombstone expires. Mutates `tombstones`,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user