Merge branch 'main' of https://github.com/stablyai/orca into faster-kanban-view-open

This commit is contained in:
Jinjing
2026-07-30 16:14:52 -07:00
1595 changed files with 147930 additions and 18808 deletions
+5
View File
@@ -0,0 +1,5 @@
# Product source remains owned by its normal reviewers; localization inputs need focused review.
/src/renderer/src/i18n/locales/ @brennanb2025
/config/scripts/*localization*.mjs @brennanb2025
/config/scripts/*locale*.mjs @brennanb2025
/i18next.config.ts @brennanb2025
@@ -6,6 +6,10 @@ inputs:
description: Native runtime to prepare after the script-free install (none, node, or electron).
required: false
default: none
node-version:
description: Node.js version override; defaults to the version declared in package.json.
required: false
default: ''
runs:
using: composite
@@ -17,11 +21,19 @@ runs:
run_install: false
- name: Setup Node.js
if: inputs.node-version == ''
uses: actions/setup-node@v6
with:
node-version-file: package.json
cache: pnpm
- name: Setup requested Node.js
if: inputs.node-version != ''
uses: actions/setup-node@v6
with:
node-version: ${{ inputs.node-version }}
cache: pnpm
- name: Validate native runtime
shell: bash
env:
+2
View File
@@ -9,6 +9,7 @@ on:
- '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-modifier-safety.test.mjs'
- 'config/scripts/computer-use-skill-guidance.test.mjs'
- 'config/scripts/computer-use-smoke.mjs'
- 'config/scripts/computer-use-smoke.test.mjs'
@@ -84,6 +85,7 @@ jobs:
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-modifier-safety.test.mjs
config/scripts/computer-use-skill-guidance.test.mjs
config/scripts/computer-use-smoke.test.mjs
src/main/computer/computer-provider-lifecycle.test.ts
+10 -1
View File
@@ -184,7 +184,9 @@ jobs:
name: ssh docker watcher isolation
needs: build
runs-on: ubuntu-latest
timeout-minutes: 20
# Why 35: the terminal parking + retention specs below add two more
# docker-rig tests capped at 240s each on top of the watcher isolation pair.
timeout-minutes: 35
steps:
- name: Checkout
@@ -227,6 +229,13 @@ jobs:
- 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
# Why always(): these specs gate the C1 terminal parking/retention budget
# over a real SSH host and run in no other lane, so a watcher-isolation
# failure above must not silently skip them.
- name: Run Docker SSH terminal parking + retention E2E
if: always()
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker-terminal-parking
- name: Upload watcher isolation traces
if: failure()
uses: actions/upload-artifact@v7
+10 -2
View File
@@ -62,6 +62,11 @@ jobs:
- name: Verify localization catalog
run: pnpm run verify:localization-catalog
# Why: extraction writes sorted evidence to an isolated temporary path,
# so feature PRs need one normalized AST pass rather than a three-OS matrix.
- name: Verify localization extraction
run: pnpm run verify:localization-extraction
- name: Verify localization coverage
run: pnpm run verify:localization-coverage
@@ -179,12 +184,14 @@ jobs:
src/shared/posix-command-path-lookup.test.ts
test:
name: tests ${{ matrix.shard }}/${{ strategy.job-total }}
name: tests node ${{ matrix.node }} ${{ matrix.shard }}/${{ matrix.shard_total }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node: ['24', '26']
shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]
shard_total: [16]
steps:
- name: Checkout
@@ -195,6 +202,7 @@ jobs:
- uses: ./.github/actions/install-node-dependencies
with:
native-runtime: node
node-version: ${{ matrix.node }}
- name: Install Electron package binary for tests
run: node config/scripts/install-electron-package-binary.mjs
@@ -208,7 +216,7 @@ jobs:
--exclude=src/main/providers/__tests__/shell-ready-framework-example.test.ts \
--exclude=src/main/pty/omp-shell-wrapper.node-pty.test.ts \
--exclude=src/shared/posix-command-path-lookup.test.ts \
--shard=${{ matrix.shard }}/${{ strategy.job-total }}
--shard=${{ matrix.shard }}/${{ matrix.shard_total }}
package:
name: package
+99
View File
@@ -0,0 +1,99 @@
name: Terminal IME E2E
on:
pull_request:
paths:
- '.github/workflows/terminal-ime-e2e.yml'
- 'config/patches/@xterm__xterm@6.1.0-beta.287.patch'
- 'config/scripts/run-terminal-ibus-hangul-e2e.mjs'
- 'config/scripts/terminal-ime-e2e-workflow.test.mjs'
- 'package.json'
- 'pnpm-lock.yaml'
- 'src/renderer/src/components/terminal-pane/keyboard-handlers.ts'
- 'src/renderer/src/components/terminal-pane/keyboard-handlers-ime.test.tsx'
- 'src/renderer/src/components/terminal-pane/pty-connection.ts'
- 'src/renderer/src/components/terminal-pane/pty-connection.test.ts'
- 'src/renderer/src/components/terminal-pane/terminal-ime-*'
- 'src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts'
- 'src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts'
- 'src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts'
- 'tests/e2e/chinese-ime-chat-input-repro.spec.ts'
- 'tests/e2e/korean-ime-terminal-shift-enter-commit.spec.ts'
- 'tests/e2e/terminal-ibus-hangul-native.spec.ts'
- 'tests/e2e/terminal-ime-*.ts'
workflow_dispatch:
schedule:
- cron: '30 9 * * *'
permissions:
contents: read
jobs:
linux-x11:
name: Linux X11 terminal IME
runs-on: ubuntu-22.04
timeout-minutes: 25
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Install native build and IME tools
run: >-
sudo apt-get update &&
sudo apt-get install -y
build-essential
dbus-x11
dconf-gsettings-backend
ibus
ibus-hangul
libglib2.0-bin
python3
xdotool
xfwm4
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
- name: Use external node-gyp to avoid pnpm bundled copy
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: Build Electron app for E2E
run: pnpm exec electron-vite build --mode e2e
- name: Run deterministic terminal IME boundary tests
run: >-
xvfb-run --auto-servernum
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1
pnpm run test:e2e --
tests/e2e/terminal-ime-exact-byte.spec.ts
--workers=1
- name: Run native IBus Hangul exact-byte tests
env:
SKIP_BUILD: '1'
run: pnpm run test:e2e:terminal-ime-native
- name: Upload terminal IME evidence
if: always()
uses: actions/upload-artifact@v7
with:
name: terminal-ime-evidence
path: test-results/
retention-days: 7
if-no-files-found: ignore
+6
View File
@@ -44,9 +44,15 @@ package-lock.json
*~
# OS
# Keep these in step with isOsMetadataSkillEntryName (skill-package-identity.ts): both disk
# walkers skip a plain file with one of these names, the git-tree producer does not, and
# ignoring them here is what keeps `git add -A` from committing a stray one.
*.stackdump
.DS_Store
._*
Thumbs.db
ehthumbs.db
desktop.ini
# Lint/cache
.oxlintcache
+2 -2
View File
@@ -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.32](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/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.36](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.36/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.32](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk)
- **Android:** [Download APK 0.0.36](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.36/app-release.apk)
---
+1
View File
@@ -21,6 +21,7 @@ type OutputChunk = Rollup.OutputChunk
const PLAIN_NODE_ENTRY_NAMES = [
'daemon-entry',
'parcel-watcher-process-entry',
'main-thread-hang-watchdog-entry',
'computer-sidecar',
'agent-hooks/managed-agent-hook-controls',
'codex/codex-app-server-grant-entry'
+11 -1
View File
@@ -83,12 +83,17 @@ module.exports = {
'!skill-guides{,/**/*}',
'!skill-stubs{,/**/*}',
'!tests{,/**/*}',
// Why: examples/ is plugin authoring documentation with no runtime consumer —
// bundled plugins ship via extraResources from resources/plugins/launch/. It also
// carries hostile-panel, the adversarial fixture the containment tests point at,
// which must never reach a user's install.
'!examples{,/**/*}',
// Why: pr-evidence/ is a local e2e screenshot output (ORCA_CAPTURE_EVIDENCE);
// it is gitignored, but exclude it defensively so a stray local capture at
// package time never bloats app.asar.
'!pr-evidence{,/**/*}',
'!Casks{,/**/*}',
'!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.md}',
'!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.md,ORCHESTRATION_IMPLEMENTATION_CHECKLIST.md,ORCHESTRATION_STRUCTURED_OUTPUT_DESIGN.md}',
'!out/**/*.test.js',
// Why: Vite's manifest is only used to project the paired web client.
'!out/renderer/.vite{,/**/*}',
@@ -136,18 +141,23 @@ module.exports = {
'out/main/agent-hooks/**',
'out/main/antigravity/**',
'out/main/claude/**',
'out/main/claude-accounts/keychain.js',
'out/main/codex/**',
'out/main/codex-cli/command.js',
'out/main/copilot/**',
'out/main/cursor/**',
'out/main/droid/**',
'out/main/gemini/**',
'out/main/grok/**',
'out/main/hermes/**',
'out/main/ipc/local-agent-install-dir-detection.js',
'out/main/ipc/tui-agent-detection-commands.js',
'out/main/win32-utils.js',
'out/main/daemon-entry.js',
'out/main/plugin-host-entry.js',
'out/main/computer-sidecar.js',
'out/main/parcel-watcher-process-entry.js',
'out/main/main-thread-hang-watchdog-entry.js',
'out/main/chunks/**',
'resources/**',
'node_modules/ws/**',
+23 -7
View File
@@ -52,11 +52,27 @@ Sync catalog keys after adding or removing `translate(...)` calls:
pnpm run sync:localization-catalog
```
The sync command adds missing `en.json` entries from each call's string fallback,
copies untranslated English placeholders into other locale catalogs to keep
parity, removes locale entries whose English key was deleted, and repairs
placeholder mismatches. Run the machine-translation bootstrap commands only when
refreshing real translations, not for ordinary UI copy changes.
The sync command adds missing `en.json` entries from each call's string fallback.
It never edits target catalogs: missing values remain absent and use the existing
runtime English fallback. Existing placeholder mismatches fail validation until
a localization PR fixes or retires the target entry.
Run maintained source extraction without committing a second English catalog:
```sh
pnpm run verify:localization-extraction
```
Extraction fails when a statically extracted key is absent from `en.json` or an
inline default has incompatible placeholders. Existing unreferenced English keys
and wording-only fallback drift are reported as migration debt; the permanent
bilingual translation source will reconcile them without a large disposition
database. Reviewed and stale translation state is likewise deferred to that
source rather than inferred permanently from Git history.
The legacy free-endpoint bootstrap and whole-catalog repair scripts intentionally
have no package-script entry points. Ordinary product and localization work must
not invoke tools that can overwrite an entire target catalog.
The coverage gate compares current candidates against
`config/localization-coverage-allowlist.json`. The committed allowlist is empty:
@@ -97,8 +113,8 @@ Recommended migration order:
The final gate should combine three checks:
1. Scanner coverage: no unclassified localizable candidates remain.
2. Catalog coverage: every supported locale has the same keys as English, with
matching interpolation variables.
2. Catalog correctness: existing translations have matching interpolation
variables; missing target entries are reported rather than rejected.
3. Runtime coverage: pseudo-localization and real locale smoke tests show no
obvious English leftovers or layout clipping in core screens.
-1
View File
@@ -251,7 +251,6 @@ inline src/renderer/src/components/tab-bar/TabBar.tsx
inline src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts
inline src/renderer/src/components/tab-group/useTabDragSplit.ts
inline src/renderer/src/components/tab-group/useTabGroupWorkspaceModel.ts
inline src/renderer/src/components/task-page-cache-selectors.ts
inline src/renderer/src/components/terminal-pane/TerminalPane.tsx
inline src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts
inline src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
@@ -47,6 +47,7 @@ describe('computer-use e2e workflow', () => {
expect(triggerPaths).toEqual(
expect.arrayContaining([
'config/scripts/computer-e2e-workflow.test.mjs',
'config/scripts/computer-use-modifier-safety.test.mjs',
'config/scripts/computer-use-skill-guidance.test.mjs',
'config/scripts/computer-use-smoke.mjs',
'config/scripts/computer-use-smoke.test.mjs',
@@ -72,6 +73,7 @@ describe('computer-use e2e workflow', () => {
const regressionRun = nativeSmokeRuns.find((run) => run.includes('pnpm vitest run'))
const expectedRegressionFiles = [
'config/scripts/computer-e2e-workflow.test.mjs',
'config/scripts/computer-use-modifier-safety.test.mjs',
'config/scripts/computer-use-skill-guidance.test.mjs',
'config/scripts/computer-use-smoke.test.mjs',
'src/main/computer/computer-provider-lifecycle.test.ts',
@@ -0,0 +1,72 @@
import { readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const projectDir = resolve(import.meta.dirname, '../..')
function source(path) {
return readFileSync(join(projectDir, path), 'utf8')
}
function sourceBetween(contents, startMarker, endMarker) {
const start = contents.indexOf(startMarker)
const end = contents.indexOf(endMarker, start + startMarker.length)
if (start < 0 || end < 0) {
throw new Error(`Missing source boundary: ${startMarker} → ${endMarker}`)
}
return contents.slice(start, end)
}
describe('computer-use modifier safety', () => {
it('uses mouse-event flags instead of held modifier keys on macOS', () => {
const macOS = source('native/computer-use-macos/Sources/OrcaComputerUseMacOS/main.swift')
const clickInput = sourceBetween(macOS, 'static func click(', 'static func scroll(')
const mouseInput = sourceBetween(
macOS,
'private static func mouse(',
'private static func keyEvent('
)
expect(mouseInput).toContain('event.flags = flags')
expect(clickInput.match(/flags: flags/gu)).toHaveLength(3)
expect(clickInput).not.toContain('down: true')
})
it('submits each modified Windows click in a closed, timed SendInput batch', () => {
const windows = source('native/computer-use-windows/runtime.ps1')
const modifiedClick = sourceBetween(
windows,
'public static void SendModifiedClick',
'private static INPUT KeyboardInput'
)
const mouseClick = sourceBetween(
windows,
'function Send-OrcaMouseClick',
'function Send-OrcaDrag'
)
expect(modifiedClick).toContain('SendInput((uint)values.Length, values')
expect(modifiedClick).toContain('SendInput((uint)releaseValues.Length, releaseValues')
expect(modifiedClick).toContain('if (sent != (uint)values.Length)')
expect(modifiedClick).toContain('releases.Add(MouseInput(mouseInput, mouseUp))')
expect(modifiedClick).not.toContain('int count')
expect(mouseClick).toMatch(
/for \(\$i = 0; \$i -lt \$clickCount; \$i\+\+\) \{\s+\[OrcaDesktopWin32\]::SendModifiedClick\(/
)
expect(mouseClick).toContain('if ($i + 1 -lt $clickCount) { Start-Sleep -Milliseconds 35 }')
expect(windows).not.toContain('keybd_event')
})
it('keeps Linux modifier release in the xdotool sequence and a fallback', () => {
const linux = source('native/computer-use-linux/runtime.py')
const modifiedClick = sourceBetween(linux, 'def modified_click_at(', 'def scroll_at(')
expect(modifiedClick).toContain('command.extend(["keyup", modifier])')
expect(modifiedClick).toContain('is_wayland')
expect(modifiedClick).toContain('modified clicks require xdotool on an X11 session')
expect(modifiedClick).toContain('finally:')
expect(modifiedClick).toContain('check=False')
expect(modifiedClick).toContain('timeout=5')
expect(modifiedClick).toContain('timeout=2')
})
})
@@ -1,6 +1,7 @@
import { readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { BUNDLED_SKILL_GUIDES } from '../../src/cli/bundled-skill-guides'
const projectDir = resolve(import.meta.dirname, '../..')
// Why: computer-use now ships a hybrid discovery stub, so its version-sensitive command
@@ -8,6 +9,7 @@ const projectDir = resolve(import.meta.dirname, '../..')
// installable stub projection is checked separately below.
const guidePath = join(projectDir, 'skill-guides', 'computer-use.md')
const stubPath = join(projectDir, 'skills', 'computer-use', 'SKILL.md')
const bundledGuide = BUNDLED_SKILL_GUIDES.find((guide) => guide.name === 'computer-use')?.markdown
describe('computer-use skill guidance', () => {
it('keeps web-app targeting on the computer-use surface', () => {
@@ -44,6 +46,15 @@ describe('computer-use skill guidance', () => {
expect(skill).toContain('`result.snapshot.treeText`')
expect(skill).not.toContain('`result.elements`')
})
it('requires atomic modifier-click actions in the source and bundled guide', () => {
expect(bundledGuide).toBeDefined()
for (const skill of [readFileSync(guidePath, 'utf8'), bundledGuide]) {
expect(skill).toContain('click --modifiers <chord>')
expect(skill).toContain('Never synthesize separate modifier-down and modifier-up commands')
}
})
})
describe('computer-use install stub', () => {
@@ -6,6 +6,7 @@ import { describe, expect, it } from 'vitest'
const require = createRequire(import.meta.url)
const electronBuilderConfig = require('../electron-builder.config.cjs')
const { FileMatcher } = require('app-builder-lib/out/fileMatcher')
const electronBuilderNativeRebuild = require('./electron-builder-native-rebuild.cjs')
const {
createPackagedRuntimeNodeModuleResources,
@@ -38,15 +39,39 @@ describe('electron-builder config', () => {
'!skill-stubs{,/**/*}',
'!resources/skills/**',
'!tests{,/**/*}',
'!examples{,/**/*}',
'!pr-evidence{,/**/*}',
'!Casks{,/**/*}',
'!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.md}',
'!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.md,ORCHESTRATION_IMPLEMENTATION_CHECKLIST.md,ORCHESTRATION_STRUCTURED_OUTPUT_DESIGN.md}',
'!out/**/*.test.js',
'!resources/plugins/launch/**'
])
)
})
// Why: `files` is an all-negation list, so electron-builder's default `**/*` packs
// anything without an explicit `!` entry — examples/ landed without one and shipped
// hostile-panel, the adversarial containment fixture, into 1.4.160-rc.3's app.asar.
// Drive the real matcher: pinning the pattern string cannot prove it excludes the tree.
it('keeps plugin authoring examples out of app.asar', () => {
const matcher = new FileMatcher('/app', '/dest', (value) => value, electronBuilderConfig.files)
// copyFiles() prepends this itself once the pattern list is all-negation.
matcher.prependPattern('**/*')
const isPacked = matcher.createFilter()
const packs = (repoPath) => isPacked(join('/app', repoPath), { isDirectory: () => false })
for (const authoringOnly of [
'examples/plugins/hostile-panel/panel.html',
'examples/plugins/hostile-panel/orca-plugin.json',
'examples/plugins/hello-orca/main.mjs',
'examples/plugins/hello-orca/orca-plugin.json'
]) {
expect(packs(authoringOnly)).toBe(false)
}
// The negation stays anchored at the app root, so nested `examples` segments still ship.
expect(packs('out/main/examples/index.js')).toBe(true)
})
it('keeps runtime resources available through extraResources', () => {
const bundledPluginResources = expect.objectContaining({
from: 'resources/plugins/launch',
@@ -129,7 +154,16 @@ describe('electron-builder config', () => {
it('unpacks the compiled CommonJS boundary with CLI runtime files', () => {
expect(electronBuilderConfig.asarUnpack).toEqual(
expect.arrayContaining(['out/package.json', 'out/cli/**', 'out/shared/**'])
expect.arrayContaining([
'out/package.json',
'out/cli/**',
'out/shared/**',
'out/main/claude-accounts/keychain.js',
'out/main/codex-cli/command.js',
'out/main/ipc/local-agent-install-dir-detection.js',
'out/main/ipc/tui-agent-detection-commands.js',
'out/main/win32-utils.js'
])
)
})
@@ -141,6 +175,14 @@ describe('electron-builder config', () => {
)
})
// Why: the watchdog only arms in packaged builds, and its ELECTRON_RUN_AS_NODE
// fork resolves the entry from app.asar.unpacked — inside the asar it never runs.
it('unpacks the forked main-thread hang-watchdog entry', () => {
expect(electronBuilderConfig.asarUnpack).toEqual(
expect.arrayContaining(['out/main/main-thread-hang-watchdog-entry.js'])
)
})
it('uses the multi-size icon source for Linux packages', () => {
expect(electronBuilderConfig.linux.icon).toBe('resources/build/icon.icns')
})
@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'
import { electronViteConfig } from '../../electron.vite.config'
const targetConfig = readFileSync('config/electron-vite-target.config.ts', 'utf8')
const devRunner = readFileSync('config/scripts/run-electron-vite-dev.mjs', 'utf8')
describe('Electron Vite output contract', () => {
it('keeps main-process and plain-Node entries at stable CommonJS paths', () => {
@@ -39,4 +40,12 @@ describe('Electron Vite output contract', () => {
it('rejects prototype properties as build targets', () => {
expect(targetConfig).toContain('Object.prototype.hasOwnProperty.call(configByTarget, target)')
})
it('gives the dev terminal daemon helper the TCC identity watched by Orca', () => {
expect(devRunner).toContain('const helperBundleId = `${bundleId}.helper`')
expect(devRunner).toContain("'Electron Helper.app',")
expect(devRunner).toContain(
"setPlistValue(helperPlistPath, 'CFBundleIdentifier', helperBundleId)"
)
})
})
@@ -124,6 +124,18 @@ function gitTreeSha(entries) {
return hashDirectory(root).toString('hex')
}
// Why: kept in step with isOsMetadataSkillEntryName in src/main/skills/skill-package-identity.ts.
// The scanner ignores these because the OS writes them into a live install; the generator
// ignores them so a stray one in a working tree cannot be committed into the manifest as
// content no user could ever match. Skipped rather than rejected: the file is not the
// developer's doing, so failing the build over it would be hostile.
const OS_METADATA_FILE_NAMES = new Set(['.ds_store', 'thumbs.db', 'ehthumbs.db', 'desktop.ini'])
function isOsMetadataSkillEntryName(name) {
const folded = name.toLocaleLowerCase('en-US')
return OS_METADATA_FILE_NAMES.has(folded) || folded.startsWith('._')
}
async function collectPackageFiles(packageRoot) {
const files = []
const caseFoldedPaths = new Map()
@@ -135,6 +147,14 @@ async function collectPackageFiles(packageRoot) {
entries.sort((left, right) => compareCodeUnits(left.name, right.name))
for (const entry of entries) {
const absolutePath = path.join(directory, entry.name)
const fileStat = await lstat(absolutePath)
// Only a plain file is OS-authored, so the type decides and not the name alone: a
// directory or link wearing the name would otherwise drop its subtree out of the
// manifest and skip the guards below. Decided before the case-fold map so two
// spellings of one sidecar cannot collide.
if (isOsMetadataSkillEntryName(entry.name) && fileStat.isFile()) {
continue
}
const relativePath = path.relative(packageRoot, absolutePath)
assertSafeRelativePath(relativePath)
const manifestPath = relativePath.split(path.sep).join('/')
@@ -144,7 +164,6 @@ async function collectPackageFiles(packageRoot) {
throw new Error(`Case-colliding skill paths: ${collision} and ${manifestPath}`)
}
caseFoldedPaths.set(foldedPath, manifestPath)
const fileStat = await lstat(absolutePath)
if (fileStat.isSymbolicLink()) {
throw new Error(`Symlink is not allowed in a shipped skill: ${manifestPath}`)
}
@@ -14,6 +14,7 @@ import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { parse } from 'yaml'
import { observeSkillPackage } from '../../src/main/skills/skill-package-identity'
import {
appendReleaseRow,
assertReleasedHistoryPreserved,
@@ -436,6 +437,90 @@ describe('skill bundle manifest generator', () => {
)
})
it('ignores OS-authored sidecars a working tree may carry', async () => {
const packageRoot = await createPackage()
await writeFile(path.join(packageRoot, 'SKILL.md'), 'demo skill\n')
await mkdir(path.join(packageRoot, 'references'))
await writeFile(path.join(packageRoot, 'references', 'guide.md'), 'nested\n')
const pristine = await collectPackageFiles(packageRoot)
expect(pristine.map((file) => file.path)).toEqual(['SKILL.md', 'references/guide.md'])
// Finder writes .DS_Store into any browsed folder, and it is gitignored — so without
// this the committed artifacts read as stale and lint fails for that developer, while
// the scanner would have no snapshot a real install could match.
await writeFile(path.join(packageRoot, '.DS_Store'), Buffer.from([0, 1, 2, 3]))
await writeFile(path.join(packageRoot, '._SKILL.md'), Buffer.from([0, 5]))
await writeFile(path.join(packageRoot, 'Thumbs.db'), Buffer.from([9]))
// Nested folders get browsed too, and a sidecar there shifts the same index-aligned list.
await writeFile(path.join(packageRoot, 'references', '.DS_Store'), Buffer.from([7]))
expect(await collectPackageFiles(packageRoot)).toEqual(pristine)
})
it('still records an unexpected file that is not OS metadata', async () => {
const packageRoot = await createPackage()
await writeFile(path.join(packageRoot, 'SKILL.md'), 'demo skill\n')
await writeFile(path.join(packageRoot, 'payload.sh'), 'echo hi\n')
expect((await collectPackageFiles(packageRoot)).map((file) => file.path)).toEqual([
'SKILL.md',
'payload.sh'
])
})
it('keeps guarding a directory or link that only wears an OS metadata name', async () => {
const packageRoot = await createPackage()
await writeFile(path.join(packageRoot, 'SKILL.md'), 'demo skill\n')
// Only plain files are OS-authored, so a subtree behind one of these names is real
// content that must stay in the manifest instead of shipping unrecorded.
await mkdir(path.join(packageRoot, '.DS_Store'))
await writeFile(path.join(packageRoot, '.DS_Store', 'payload.sh'), 'echo hi\n')
expect((await collectPackageFiles(packageRoot)).map((file) => file.path)).toEqual([
'.DS_Store/payload.sh',
'SKILL.md'
])
if (process.platform !== 'win32') {
await rm(path.join(packageRoot, '.DS_Store'), { recursive: true })
await symlink('SKILL.md', path.join(packageRoot, '._SKILL.md'))
await expect(collectPackageFiles(packageRoot)).rejects.toThrow(
'Symlink is not allowed in a shipped skill'
)
}
})
// Why: the predicate is hand-copied from the scanner, and an asymmetric skip is worse than
// no skip — one side would bake in content the other can never observe, leaving every
// install permanently unrecognized. Compared through both walkers so ordering and the
// case-fold map are covered too, not just the name test.
it('skips exactly the names the scanner skips', async () => {
const packageRoot = await createPackage()
for (const name of [
'SKILL.md',
'.DS_Store',
'.ds_store',
'.DS_STORE',
'Thumbs.db',
'THUMBS.DB',
'ehthumbs.db',
'desktop.ini',
'Desktop.INI',
'._SKILL.md',
'._',
// Near misses that both sides must keep.
'.dsstore',
'ds_store.md',
'_SKILL.md',
'.DS_Store.md'
]) {
await writeFile(path.join(packageRoot, name), `${name}\n`)
}
const generated = (await collectPackageFiles(packageRoot)).map((file) => file.path)
expect(generated).toEqual((await observeSkillPackage(packageRoot)).files.map((f) => f.path))
expect(generated).toEqual(['.DS_Store.md', '.dsstore', 'SKILL.md', '_SKILL.md', 'ds_store.md'])
})
it('computes the same Git tree identity as Git', async () => {
const packageRoot = path.resolve('skills', 'orca-cli')
const files = await collectPackageFiles(packageRoot)
@@ -1,5 +1,6 @@
import { CROSS_LOCALE_KEY_OVERRIDES } from './locale-cross-locale-key-overrides.mjs'
import { KO_KEY_OVERRIDES } from './locale-ko-key-overrides.mjs'
import { MACOS_TCC_KEY_OVERRIDES } from './locale-macos-tcc-key-overrides.mjs'
export function mergeLocaleKeyOverrides(base) {
const merged = { ...base }
@@ -10,5 +11,8 @@ export function mergeLocaleKeyOverrides(base) {
// KO split overrides can share keys with zh/ja repairs; merge per locale.
merged[key] = { ...merged[key], ...overrides }
}
for (const [key, overrides] of Object.entries(MACOS_TCC_KEY_OVERRIDES)) {
merged[key] = { ...merged[key], ...overrides }
}
return merged
}
@@ -2231,9 +2231,6 @@
"auto.components.settings.DeveloperPermissionsPane.16381e040a": {
"ko": "마이크"
},
"auto.components.settings.DeveloperPermissionsPane.7ca17b62c8": {
"ko": "프로젝트, 워크트리 또는 심볼릭 링크된 파일이 macOS 보호 폴더에 닿을 때 권장됩니다."
},
"auto.components.settings.DeveloperPermissionsPane.e119f0d66b": {
"ko": "자동화"
},
@@ -0,0 +1,18 @@
export const MACOS_TCC_KEY_OVERRIDES = {
'auto.hooks.useMacosTccPromptNotice.description': {
es: 'macOS atribuye a Orca el acceso a archivos realizado por tus agentes y herramientas de terminal. Conceder acceso total al disco reduce estos avisos.',
ja: 'エージェントやターミナルツールがファイルにアクセスすると、macOS はそのアクセス元を Orca として扱います。フルディスクアクセスを許可すると、これらの確認を減らせます。',
ko: '에이전트와 터미널 도구의 파일 접근은 macOS에서 Orca의 접근으로 표시됩니다. 전체 디스크 접근 권한을 허용하면 이러한 요청을 줄일 수 있습니다.',
zh: 'macOS 会将代理和终端工具的文件访问归因于 Orca。授予“完全磁盘访问权限”可减少此类提示。'
},
'auto.components.settings.DeveloperPermissionsPane.7ca17b62c8': {
es: 'Cuando los agentes que ejecuta Orca leen datos de otras apps, macOS muestra el nombre de Orca porque es el proceso responsable de los comandos de terminal. Concede este permiso a Orca y Orca Helper para reducir esos avisos. Después, cierra Orca, finaliza cualquier proceso de Orca Helper que siga en ejecución y vuelve a abrir Orca.',
ja: 'Orca が実行するエージェントがほかのアプリのデータを読み取ると、ターミナルコマンドの実行元プロセスである Orca の名前が macOS に表示されます。これらの確認を減らすには、Orca と Orca Helper にこの権限を許可してください。その後、Orca を終了し、残っている Orca Helper プロセスもすべて終了してから、Orca を再度開いてください。',
ko: 'Orca가 실행하는 에이전트가 다른 앱의 데이터를 읽으면, macOS는 터미널 명령을 실행하는 프로세스인 Orca를 표시합니다. 이러한 요청을 줄이려면 Orca와 Orca Helper에 이 권한을 허용하세요. 그런 다음 Orca를 종료하고, 남아 있는 Orca Helper 프로세스도 모두 종료한 후 Orca를 다시 여세요.',
zh: '当 Orca 运行的代理读取其他应用的数据时,macOS 会显示 Orca,因为 Orca 是执行终端命令的进程。请为 Orca 和 Orca Helper 授予此权限,以减少此类提示。然后退出 Orca,结束所有仍在运行的 Orca Helper 进程,再重新打开 Orca。'
},
'auto.components.settings.DeveloperPermissionsPane.c566bca278': {
ko: '전체 디스크 접근 권한',
zh: '完全磁盘访问权限'
}
}
@@ -0,0 +1,19 @@
const LOCALIZED_PROSE_TERM_KEYS = new Set([
'auto.hooks.useMacosTccPromptNotice.description',
'auto.components.settings.DeveloperPermissionsPane.7ca17b62c8'
])
const LOCALIZABLE_PROSE_TERMS = new Set([
'Agent',
'Agents',
'agent',
'agents',
'Terminal',
'Terminals',
'terminal',
'terminals'
])
export function isLocalizedProseTermContext(term, key) {
return LOCALIZED_PROSE_TERM_KEYS.has(key) && LOCALIZABLE_PROSE_TERMS.has(term)
}
@@ -1,4 +1,5 @@
import { CJK_LATIN_SPACED_TERMS } from './locale-cjk-latin-spaced-terms.mjs'
import { isLocalizedProseTermContext } from './locale-prose-term-exemptions.mjs'
import { isScreenCursorContext } from './locale-screen-cursor-exemptions.mjs'
import { LOCALE_KEY_OVERRIDES } from './locale-key-overrides.mjs'
import { LOCALE_PHRASE_FIXES } from './locale-phrase-fixes.mjs'
@@ -382,6 +383,9 @@ function applyBrandMistranslationFixes(enValue, localeValue, locale, key = '') {
if (isScreenCursorContext(brand, enValue, key)) {
continue
}
if (isLocalizedProseTermContext(brand, key)) {
continue
}
if (includesPreservedLatinTerm(result, brand)) {
continue
}
@@ -0,0 +1,22 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
describe('localization package scripts', () => {
const scripts = JSON.parse(readFileSync('package.json', 'utf8')).scripts
it('keeps safe catalog and extraction verification available', () => {
expect(scripts['verify:localization-catalog']).toBeDefined()
expect(scripts['sync:localization-catalog']).toBeDefined()
expect(scripts['verify:localization-extraction']).toBeDefined()
})
it('does not expose whole-catalog translation and repair commands', () => {
expect(scripts['bootstrap:locale-catalog']).toBeUndefined()
expect(scripts['bootstrap:zh-catalog']).toBeUndefined()
expect(scripts['bootstrap:ko-catalog']).toBeUndefined()
expect(scripts['bootstrap:ja-catalog']).toBeUndefined()
expect(scripts['bootstrap:es-catalog']).toBeUndefined()
expect(scripts['repair:locale-catalog']).toBeUndefined()
})
})
@@ -0,0 +1,50 @@
import fs from 'node:fs'
import { describe, expect, it } from 'vitest'
import { repairTranslatedValue } from './locale-translation-policy.mjs'
const LOCALES = ['es', 'ja', 'ko', 'zh']
const KEYS = [
'auto.hooks.useMacosTccPromptNotice.title',
'auto.hooks.useMacosTccPromptNotice.description',
'auto.hooks.useMacosTccPromptNotice.openSettings',
'auto.hooks.useMacosTccPromptNotice.dismiss',
'auto.components.settings.DeveloperPermissionsPane.7ca17b62c8',
'auto.components.settings.DeveloperPermissionsPane.c566bca278'
]
function readCatalog(locale) {
return JSON.parse(
fs.readFileSync(
new URL(`../../src/renderer/src/i18n/locales/${locale}.json`, import.meta.url),
'utf8'
)
)
}
function getValue(catalog, key) {
return key.split('.').reduce((value, part) => value[part], catalog)
}
describe('macOS TCC prompt localization', () => {
it('survives the canonical catalog repair policy', () => {
const english = readCatalog('en')
for (const locale of LOCALES) {
const catalog = readCatalog(locale)
for (const key of KEYS) {
const enValue = getValue(english, key)
const localeValue = getValue(catalog, key)
expect(repairTranslatedValue({ key, enValue, localeValue, locale })).toBe(localeValue)
}
}
})
it('uses the macOS Full Disk Access labels in Korean and Chinese', () => {
expect(
getValue(readCatalog('ko'), 'auto.components.settings.DeveloperPermissionsPane.c566bca278')
).toBe('전체 디스크 접근 권한')
expect(
getValue(readCatalog('zh'), 'auto.components.settings.DeveloperPermissionsPane.c566bca278')
).toBe('完全磁盘访问权限')
})
})
@@ -51,30 +51,39 @@ describe('orchestration skill guidance', () => {
)
})
it('teaches the hard cutover without reviving a legacy executor', () => {
it('teaches attested adoption without reviving the retired scheduler', () => {
const skill = readSkill()
const migration = getSection(skill, 'Contract Migration')
expect(migration).toContain('hard cutover')
expect(migration).toContain('effectsApplied')
expect(migration).toContain('skills get orchestration --full')
expect(migration).toContain('Do not retry the rejected command unchanged')
expect(migration).toContain('no longer supervised')
expect(migration).toContain('task-list --run run_legacy_local')
expect(migration).toContain('Read-only inspection never consumes legacy mail')
expect(migration).toContain('does not run a legacy scheduler, translate old writes, or drain')
expect(migration).toContain('does not cancel the prior assignment')
expect(migration).toContain('invalidate its worktree')
expect(migration).toContain('discard filesystem changes')
expect(migration).toContain('leave it as the only editor in that worktree')
expect(migration).toContain('observe it manually, read-only')
expect(migration).toContain('until it reaches a stable handoff point')
expect(migration).toContain('visible activity is a reason to keep observing')
expect(migration).toContain(
'Never launch a replacement editor in the same worktree while the legacy worker may still write there.'
'adopts a live pre-update orchestration assignment into an ordinary Run'
)
expect(migration).toContain('if remaining work needs new lifecycle supervision')
expect(migration).not.toContain('restart the work using Run -> Task -> `worker-start`')
expect(migration).toContain(
'preserves the existing agent process, PTY/session, terminal handle, tab/leaf/pane, worktree or folder workspace, Task, and Dispatch'
)
expect(migration).toContain('never restarts or replaces the worker')
expect(migration).toContain('The retired scheduler is not revived')
expect(migration).toContain('[LEGACY COMPATIBILITY]')
expect(migration).toContain('[LEGACY READ-ONLY]')
expect(migration).toContain(
'Loss of lifecycle authority does not invalidate the existing assignment, process, or filesystem work.'
)
expect(migration).toContain(
'It must not spawn, write, signal, stop, switch, focus, split, or inject a terminal.'
)
expect(migration).not.toContain('task-list --run run_legacy_local')
expect(migration).toContain('run_legacy_local is an empty audit tombstone')
expect(migration).toContain('Recovered orchestration work from a contract update')
expect(migration).toContain('run-show --id <adopted_run_id>')
expect(migration).toContain('task-list --run <adopted_run_id>')
expect(migration).toContain('Legacy inspection remains available without consuming mail')
expect(migration).toContain('run-use --id <adopted_run_id> --takeover-legacy')
expect(migration).toContain('Takeover fences only the old coordinator')
expect(migration).toContain('Live legacy workers keep their original Tasks, Dispatches')
expect(migration).toContain(
'keep the original worker as the only editor until it reaches a stable handoff point'
)
expect(migration).toContain('a conflict-free placement for any remaining work')
})
it('treats long-running worker waits as liveness checkpoints, not failures', () => {
@@ -37,13 +37,19 @@ describe('PR workflow parallelism', () => {
expect(workflow.permissions).toEqual({ contents: 'read' })
})
it('shards the general test suite across sixteen runners', () => {
it('shards the general test suite across Node 24 and Node 26', () => {
expect(workflow.jobs.test.strategy.matrix.node).toEqual(['24', '26'])
expect(workflow.jobs.test.strategy.matrix.shard).toEqual(
Array.from({ length: 16 }, (_, index) => index + 1)
)
expect(workflow.jobs.test.strategy.matrix.shard_total).toEqual([16])
const testStep = workflow.jobs.test.steps.find((step) => step.name === 'Test shard')
const installStep = workflow.jobs.test.steps.find(
(step) => step.uses === './.github/actions/install-node-dependencies'
)
expect(testStep.run).toContain('--shard=${{ matrix.shard }}/${{ strategy.job-total }}')
expect(installStep.with['node-version']).toBe('${{ matrix.node }}')
expect(testStep.run).toContain('--shard=${{ matrix.shard }}/${{ matrix.shard_total }}')
for (const testFile of nativeShellContractFiles) {
expect(testStep.run).toContain(`--exclude=${testFile}`)
}
@@ -95,9 +101,15 @@ describe('PR workflow parallelism', () => {
const steps = dependencyAction.runs.steps
const pnpmIndex = steps.findIndex((step) => step.name === 'Setup pnpm')
const nodeIndex = steps.findIndex((step) => step.name === 'Setup Node.js')
const requestedNodeIndex = steps.findIndex((step) => step.name === 'Setup requested Node.js')
expect(pnpmIndex).toBeLessThan(nodeIndex)
expect(pnpmIndex).toBeLessThan(requestedNodeIndex)
expect(steps[nodeIndex].with.cache).toBe('pnpm')
expect(steps[nodeIndex].if).toBe("inputs.node-version == ''")
expect(steps[requestedNodeIndex].if).toBe("inputs.node-version != ''")
expect(steps[requestedNodeIndex].with['node-version']).toBe('${{ inputs.node-version }}')
expect(steps[requestedNodeIndex].with.cache).toBe('pnpm')
})
it('restores Electron downloads before preparing the package runtime', () => {
+12 -4
View File
@@ -128,10 +128,8 @@ function prepareMacDevElectronApp() {
const title = process.env.ORCA_DEV_DOCK_TITLE || 'Orca: dev'
const identityKey = process.env.ORCA_DEV_INSTANCE_KEY || repoRoot
// v6: bundle the notification-status helper (real permission readout) and
// ad-hoc re-sign after plist edits so Notification Center accepts the
// bundle; bumping forces stale cached copies to be recreated.
const bundleLayoutVersion = 'dock-title-app-preserve-framework-symlinks-v6'
// v7: give the terminal daemon helper an Orca-specific TCC identity.
const bundleLayoutVersion = 'dock-title-app-preserve-framework-symlinks-v7'
const hash = createHash('sha1')
.update(
`${sourceAppPath}\0${electronVersion ?? ''}\0${title}\0${identityKey}\0${bundleLayoutVersion}`
@@ -155,6 +153,7 @@ function prepareMacDevElectronApp() {
// Electron drops clicks for notification ids it didn't create, so the
// click is lost, not misdirected.
const bundleId = 'com.stablyai.orca.dev'
const helperBundleId = `${bundleId}.helper`
process.env.ORCA_DEV_MACOS_BUNDLE_ID = bundleId
const expectedMarker = JSON.stringify(
{ title, appBundleName, bundleId, sourceAppPath, electronVersion, bundleLayoutVersion },
@@ -205,9 +204,18 @@ function prepareMacDevElectronApp() {
restoreElectronFrameworkSymlinks(appPath)
const plistPath = path.join(appPath, 'Contents', 'Info.plist')
const helperPlistPath = path.join(
appPath,
'Contents',
'Frameworks',
'Electron Helper.app',
'Contents',
'Info.plist'
)
setPlistValue(plistPath, 'CFBundleName', title)
setPlistValue(plistPath, 'CFBundleDisplayName', title)
setPlistValue(plistPath, 'CFBundleIdentifier', bundleId)
setPlistValue(helperPlistPath, 'CFBundleIdentifier', helperBundleId)
// Why: the notification-status helper reads the app's real macOS
// notification authorization (UNUserNotificationCenter has no Electron
@@ -0,0 +1,45 @@
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)
}
// Why both specs in one runner: they share the docker SSH rig and the same
// ORCA_E2E_SSH_DOCKER gate — the SSH parking spec proves SSH panes park and
// restore, the retention spec proves the C1 budget bounds what they retain.
const result = spawnSync(
pnpm,
[
'exec',
'playwright',
'test',
'tests/e2e/ssh-terminal-parking.spec.ts',
'tests/e2e/terminal-retention-budget.spec.ts',
'--config',
'tests/playwright.config.ts',
'--project',
'electron-headless',
'--workers=1',
...extraArgs
],
spawnOptions
)
process.exit(result.status ?? 1)
@@ -0,0 +1,306 @@
import { spawn, spawnSync } from 'node:child_process'
import { closeSync, copyFileSync, mkdirSync, mkdtempSync, openSync, writeFileSync } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
const projectDir = path.resolve(import.meta.dirname, '../..')
const scriptPath = import.meta.filename
const insideSessionFlag = '--inside-session'
const processStopTimeoutMs = 5_000
const processKillTimeoutMs = 1_000
function delay(milliseconds) {
return new Promise((resolve) => setTimeout(resolve, milliseconds))
}
function waitForExit(child) {
return new Promise((resolve, reject) => {
child.once('error', reject)
child.once('exit', (code, signal) => resolve(code ?? (signal ? 1 : 0)))
})
}
function processGroupMembers(processGroupId) {
const result = spawnSync('ps', ['-o', 'pid=,ppid=,pgid=,comm=', '-g', String(processGroupId)], {
encoding: 'utf8'
})
if (result.status !== 0) {
return []
}
return result.stdout
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
}
async function stopOwnedProcessGroup(processGroupId) {
let members = processGroupMembers(processGroupId)
if (members.length === 0) {
return []
}
console.error(
`[terminal-ime] stopping owned process group ${processGroupId}: ${members.join('; ')}`
)
try {
process.kill(-processGroupId, 'SIGTERM')
} catch (error) {
if (error?.code !== 'ESRCH') {
throw error
}
}
const deadline = Date.now() + processStopTimeoutMs
while (Date.now() < deadline) {
members = processGroupMembers(processGroupId)
if (members.length === 0) {
return []
}
await delay(100)
}
try {
process.kill(-processGroupId, 'SIGKILL')
} catch (error) {
if (error?.code !== 'ESRCH') {
throw error
}
}
const killDeadline = Date.now() + processKillTimeoutMs
do {
members = processGroupMembers(processGroupId)
if (members.length === 0) {
return []
}
await delay(100)
} while (Date.now() < killDeadline)
return members
}
function commandOutput(command, args) {
const result = spawnSync(command, args, { encoding: 'utf8' })
return result.status === 0 ? result.stdout.trim() : result.stderr.trim()
}
function configureHangulEngine() {
for (const [key, value] of [
['initial-input-mode', 'hangul'],
['hangul-keyboard', '2']
]) {
const result = spawnSync(
'gsettings',
['set', 'org.freedesktop.ibus.engine.hangul', key, value],
{ encoding: 'utf8' }
)
if (result.status !== 0) {
throw new Error(`Failed to configure IBus Hangul ${key}: ${result.stderr.trim()}`)
}
}
}
async function waitForHangulEngine(ibusProcess) {
const deadline = Date.now() + 15_000
while (Date.now() < deadline) {
if (ibusProcess.exitCode !== null) {
throw new Error(`ibus-daemon exited early with code ${ibusProcess.exitCode}`)
}
const result = spawnSync('ibus', ['engine', 'hangul'], { stdio: 'pipe' })
if (result.status === 0) {
return
}
await delay(100)
}
throw new Error('Timed out while selecting the IBus Hangul engine')
}
async function runInsideSession(evidenceDir) {
const ibusLogPath = path.join(evidenceDir, 'ibus-daemon.log')
const ibusLogFd = openSync(ibusLogPath, 'w')
const windowManagerLogPath = path.join(evidenceDir, 'xfwm4.log')
const windowManagerLogFd = openSync(windowManagerLogPath, 'w')
const evidence = {
display: process.env.DISPLAY ?? null,
ibusDaemonPid: null,
ibusGroupBeforeCleanup: [],
ibusGroupAfterCleanup: [],
playwrightPid: null,
windowManagerPid: null,
windowManagerGroupAfterCleanup: []
}
let ibusProcess
let windowManagerProcess
let testExitCode = 1
try {
configureHangulEngine()
windowManagerProcess = spawn('xfwm4', ['--compositor=off'], {
detached: true,
env: process.env,
stdio: ['ignore', windowManagerLogFd, windowManagerLogFd]
})
if (!windowManagerProcess.pid) {
throw new Error('xfwm4 did not return a PID')
}
evidence.windowManagerPid = windowManagerProcess.pid
console.error(`[terminal-ime] started xfwm4 PID ${windowManagerProcess.pid}`)
ibusProcess = spawn(
'ibus-daemon',
['--xim', '--verbose', '--panel=disable', '--emoji-extension=disable'],
{
detached: true,
env: process.env,
stdio: ['ignore', ibusLogFd, ibusLogFd]
}
)
if (!ibusProcess.pid) {
throw new Error('ibus-daemon did not return a PID')
}
evidence.ibusDaemonPid = ibusProcess.pid
console.error(`[terminal-ime] started ibus-daemon PID ${ibusProcess.pid}`)
await waitForHangulEngine(ibusProcess)
console.error(`[terminal-ime] IBus version: ${commandOutput('ibus', ['version'])}`)
console.error(`[terminal-ime] IBus engine: ${commandOutput('ibus', ['engine'])}`)
console.error(
`[terminal-ime] Hangul initial mode: ${commandOutput('gsettings', [
'get',
'org.freedesktop.ibus.engine.hangul',
'initial-input-mode'
])}`
)
console.error(
`[terminal-ime] Hangul keyboard: ${commandOutput('gsettings', [
'get',
'org.freedesktop.ibus.engine.hangul',
'hangul-keyboard'
])}`
)
evidence.ibusGroupBeforeCleanup = processGroupMembers(ibusProcess.pid)
console.error(`[terminal-ime] owned IBus group: ${evidence.ibusGroupBeforeCleanup.join('; ')}`)
const testProcess = spawn(
process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm',
[
'run',
'test:e2e:headful',
'--workers=1',
'--',
'tests/e2e/terminal-ibus-hangul-native.spec.ts'
],
{
cwd: projectDir,
env: {
...process.env,
ORCA_E2E_FORWARD_APP_LOGS: '1',
ORCA_E2E_NATIVE_IBUS_HANGUL: '1'
},
stdio: 'inherit'
}
)
if (!testProcess.pid) {
throw new Error('Playwright did not return a PID')
}
evidence.playwrightPid = testProcess.pid
console.error(`[terminal-ime] started Playwright PID ${testProcess.pid}`)
testExitCode = await waitForExit(testProcess)
} finally {
if (ibusProcess?.pid) {
evidence.ibusGroupBeforeCleanup = processGroupMembers(ibusProcess.pid)
evidence.ibusGroupAfterCleanup = await stopOwnedProcessGroup(ibusProcess.pid)
}
if (windowManagerProcess?.pid) {
evidence.windowManagerGroupAfterCleanup = await stopOwnedProcessGroup(
windowManagerProcess.pid
)
}
closeSync(ibusLogFd)
closeSync(windowManagerLogFd)
mkdirSync(path.join(projectDir, 'test-results'), { recursive: true })
copyFileSync(
ibusLogPath,
path.join(projectDir, 'test-results', 'terminal-ibus-hangul-native-ibus.log')
)
copyFileSync(
windowManagerLogPath,
path.join(projectDir, 'test-results', 'terminal-ibus-hangul-native-xfwm4.log')
)
writeFileSync(
path.join(projectDir, 'test-results', 'terminal-ibus-hangul-native-processes.json'),
`${JSON.stringify(evidence, null, 2)}\n`
)
}
if (evidence.ibusGroupAfterCleanup.length > 0) {
throw new Error(
`Owned IBus processes survived cleanup: ${evidence.ibusGroupAfterCleanup.join('; ')}`
)
}
if (evidence.windowManagerGroupAfterCleanup.length > 0) {
throw new Error(
`Owned window-manager processes survived cleanup: ${evidence.windowManagerGroupAfterCleanup.join('; ')}`
)
}
return testExitCode
}
async function runOuter() {
if (process.platform !== 'linux') {
throw new Error('The native IBus Hangul E2E runner requires Linux/X11')
}
const evidenceDir = mkdtempSync(path.join(os.tmpdir(), 'orca-terminal-ime-e2e-'))
const runtimeDir = path.join(evidenceDir, 'runtime')
mkdirSync(runtimeDir, { mode: 0o700 })
mkdirSync(path.join(evidenceDir, 'config'))
mkdirSync(path.join(evidenceDir, 'cache'))
console.error(`[terminal-ime] evidence directory: ${evidenceDir}`)
const sessionProcess = spawn(
'xvfb-run',
[
'--auto-servernum',
'dbus-run-session',
'--',
process.execPath,
scriptPath,
insideSessionFlag,
evidenceDir
],
{
cwd: projectDir,
detached: true,
env: {
...process.env,
GTK_IM_MODULE: 'ibus',
IBUS_ENABLE_SYNC_MODE: '1',
LANG: process.env.LANG || 'C.UTF-8',
QT_IM_MODULE: 'ibus',
XDG_CACHE_HOME: path.join(evidenceDir, 'cache'),
XDG_CONFIG_HOME: path.join(evidenceDir, 'config'),
XDG_RUNTIME_DIR: runtimeDir,
XMODIFIERS: '@im=ibus'
},
stdio: 'inherit'
}
)
if (!sessionProcess.pid) {
throw new Error('xvfb-run did not return a PID')
}
console.error(`[terminal-ime] started isolated X11 session PID ${sessionProcess.pid}`)
const exitCode = await waitForExit(sessionProcess)
const remaining = await stopOwnedProcessGroup(sessionProcess.pid)
if (remaining.length > 0) {
throw new Error(`Owned X11 session processes survived cleanup: ${remaining.join('; ')}`)
}
return exitCode
}
const insideSession = process.argv[2] === insideSessionFlag
try {
if (insideSession && !process.argv[3]) {
throw new Error(`${insideSessionFlag} requires an evidence directory argument`)
}
process.exitCode = insideSession ? await runInsideSession(process.argv[3]) : await runOuter()
} catch (error) {
console.error(`[terminal-ime] ${error instanceof Error ? error.message : String(error)}`)
process.exitCode = 1
}
@@ -0,0 +1,93 @@
import { readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { parse } from 'yaml'
const projectDir = resolve(import.meta.dirname, '../..')
describe('terminal IME e2e workflow', () => {
const workflow = parse(
readFileSync(join(projectDir, '.github/workflows/terminal-ime-e2e.yml'), 'utf8')
)
it('runs for xterm patch and terminal IME regression changes', () => {
expect(workflow.on.pull_request.paths).toEqual(
expect.arrayContaining([
'config/patches/@xterm__xterm@6.1.0-beta.287.patch',
'config/scripts/run-terminal-ibus-hangul-e2e.mjs',
'src/renderer/src/components/terminal-pane/keyboard-handlers.ts',
'src/renderer/src/components/terminal-pane/keyboard-handlers-ime.test.tsx',
'src/renderer/src/components/terminal-pane/pty-connection.ts',
'src/renderer/src/components/terminal-pane/pty-connection.test.ts',
'src/renderer/src/components/terminal-pane/terminal-ime-*',
'src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts',
'src/renderer/src/components/terminal-pane/xterm-bypass-policy.ts',
'src/renderer/src/components/terminal-pane/xterm-bypass-policy.test.ts',
'tests/e2e/korean-ime-terminal-shift-enter-commit.spec.ts',
'tests/e2e/terminal-ibus-hangul-native.spec.ts',
'tests/e2e/terminal-ime-*.ts'
])
)
})
it('installs native IBus Hangul and X11 input tools', () => {
const runs = workflow.jobs['linux-x11'].steps
.map((step) => step.run)
.filter((run) => typeof run === 'string')
const installRun = runs.find((run) => run.includes('apt-get install'))
expect(installRun).toBeDefined()
expect(installRun).toContain('ibus-hangul')
expect(installRun).toContain('xdotool')
expect(installRun).toContain('xfwm4')
expect(installRun).toContain('xvfb')
expect(installRun).toContain('dbus-x11')
expect(installRun).toContain('dconf-gsettings-backend')
expect(installRun).toContain('libglib2.0-bin')
})
it('runs deterministic boundaries before the real IBus suite', () => {
const runs = workflow.jobs['linux-x11'].steps
.map((step) => step.run)
.filter((run) => typeof run === 'string')
const deterministicIndex = runs.findIndex((run) =>
run.includes('terminal-ime-exact-byte.spec.ts')
)
const nativeIndex = runs.findIndex((run) => run.includes('test:e2e:terminal-ime-native'))
expect(deterministicIndex).toBeGreaterThanOrEqual(0)
expect(nativeIndex).toBeGreaterThan(deterministicIndex)
})
it('keeps IBus lifecycle scoped to owned processes', () => {
const runner = readFileSync(
join(projectDir, 'config/scripts/run-terminal-ibus-hangul-e2e.mjs'),
'utf8'
)
expect(runner).toContain(
"['--xim', '--verbose', '--panel=disable', '--emoji-extension=disable']"
)
expect(runner).toContain("spawn('xfwm4', ['--compositor=off']")
expect(runner).toContain("['initial-input-mode', 'hangul']")
expect(runner).toContain("['hangul-keyboard', '2']")
expect(runner).toContain("process.kill(-processGroupId, 'SIGTERM')")
expect(runner).toContain("process.kill(-processGroupId, 'SIGKILL')")
expect(runner).toContain('const killDeadline = Date.now() + processKillTimeoutMs')
expect(runner).toMatch(
/'test:e2e:headful',\s*'--workers=1',\s*'--',\s*'tests\/e2e\/terminal-ibus-hangul-native\.spec\.ts'/
)
expect(runner).not.toContain("'--replace'")
expect(runner).not.toContain('killall')
expect(runner).not.toContain('pkill')
})
it('bounds blocking native input commands', () => {
const nativeSpec = readFileSync(
join(projectDir, 'tests/e2e/terminal-ibus-hangul-native.spec.ts'),
'utf8'
)
expect(nativeSpec.match(/timeout: NATIVE_COMMAND_TIMEOUT_MS/g)).toHaveLength(3)
})
})
+14 -90
View File
@@ -227,44 +227,6 @@ function setCatalogEntry(catalog, key, value) {
cursor[parts.at(-1)] = value
}
function deleteCatalogEntry(catalog, key) {
const parts = key.split('.')
const stack = []
let cursor = catalog
for (const part of parts.slice(0, -1)) {
if (
typeof cursor?.[part] !== 'object' ||
cursor[part] === null ||
Array.isArray(cursor[part])
) {
return false
}
stack.push([cursor, part])
cursor = cursor[part]
}
const leafKey = parts.at(-1)
if (!Object.hasOwn(cursor, leafKey)) {
return false
}
delete cursor[leafKey]
for (let index = stack.length - 1; index >= 0; index -= 1) {
const [parent, part] = stack[index]
const child = parent[part]
if (
typeof child === 'object' &&
child !== null &&
!Array.isArray(child) &&
Object.keys(child).length === 0
) {
delete parent[part]
}
}
return true
}
function collectLocaleParityIssues(enCatalog, localeCatalog) {
const enEntries = flattenCatalogEntries(enCatalog)
const localeEntries = flattenCatalogEntries(localeCatalog)
@@ -286,30 +248,6 @@ function collectLocaleParityIssues(enCatalog, localeCatalog) {
return { enEntries, localeEntries, missingInLocale, extraInLocale, interpolationMismatches }
}
function repairLocaleParity(enCatalog, localeCatalog) {
const { enEntries, missingInLocale, extraInLocale, interpolationMismatches } =
collectLocaleParityIssues(enCatalog, localeCatalog)
let changed = 0
for (const key of missingInLocale) {
setCatalogEntry(localeCatalog, key, enEntries.get(key))
changed += 1
}
for (const key of extraInLocale) {
if (deleteCatalogEntry(localeCatalog, key)) {
changed += 1
}
}
for (const key of interpolationMismatches) {
setCatalogEntry(localeCatalog, key, enEntries.get(key))
changed += 1
}
return changed
}
function referencesMissingFallbacks(missing) {
return missing.filter((reference) => typeof reference.fallback !== 'string')
}
@@ -344,23 +282,18 @@ function applyMissingEnglishEntries(catalog, missing) {
return changed
}
function verifyLocaleParity(enCatalog, localeName, localeCatalog) {
const { localeEntries, missingInLocale, extraInLocale, interpolationMismatches } =
function verifyLocaleCatalog(enCatalog, localeName, localeCatalog) {
const { enEntries, localeEntries, missingInLocale, extraInLocale, interpolationMismatches } =
collectLocaleParityIssues(enCatalog, localeCatalog)
if (
missingInLocale.length > 0 ||
extraInLocale.length > 0 ||
interpolationMismatches.length > 0
) {
console.error(`Locale catalog parity failed for ${localeName}.json.`)
if (missingInLocale.length > 0) {
console.error('')
console.error(formatMissingKeys('missing', missingInLocale.slice(0, 20)))
if (missingInLocale.length > 20) {
console.error(`...and ${missingInLocale.length - 20} more missing keys`)
}
}
// Why: feature PRs own English declarations; absent target leaves deliberately
// use i18next's existing English fallback until a localization PR supplies them.
console.log(
`${localeName}.json coverage: ${enEntries.size - missingInLocale.length}/${enEntries.size} translated, ${missingInLocale.length} missing.`
)
if (extraInLocale.length > 0 || interpolationMismatches.length > 0) {
console.error(`Locale catalog validation failed for ${localeName}.json.`)
if (extraInLocale.length > 0) {
console.error('')
console.error(formatMissingKeys('extra', extraInLocale.slice(0, 20)))
@@ -380,7 +313,7 @@ function verifyLocaleParity(enCatalog, localeName, localeCatalog) {
return 1
}
console.log(`Verified locale parity for ${localeName}.json (${localeEntries.size} keys).`)
console.log(`Verified ${localeEntries.size} existing ${localeName}.json entries.`)
return 0
}
@@ -527,19 +460,10 @@ export async function main(root = process.cwd(), options = parseArgs(process.arg
const localeName = fileName.replace(/\.json$/, '')
const localeCatalogPath = path.join(localesDir, fileName)
const localeCatalog = JSON.parse(await fs.readFile(localeCatalogPath, 'utf8'))
if (options.fix) {
const repaired = repairLocaleParity(catalog, localeCatalog)
if (repaired > 0) {
await fs.writeFile(localeCatalogPath, `${JSON.stringify(localeCatalog, null, 2)}\n`, 'utf8')
console.log(`Repaired ${fileName} parity (${repaired} key update(s)).`)
}
}
const exitCode = verifyLocaleParity(catalog, localeName, localeCatalog)
const exitCode = verifyLocaleCatalog(catalog, localeName, localeCatalog)
if (exitCode !== 0) {
if (!options.fix) {
console.error('')
console.error('Run `pnpm run sync:localization-catalog` to repair locale parity.')
}
console.error('')
console.error('Fix or retire the existing target entry in a localization PR.')
return exitCode
}
}
@@ -33,7 +33,7 @@ function makeProject({ sourceText, enCatalog = {}, esCatalog = {} }) {
}
describe('verify-localization-catalog', () => {
it('bootstraps missing catalog entries from string fallbacks', async () => {
it('bootstraps English entries without fabricating target translations', async () => {
const { root, localesDir } = makeProject({
sourceText:
"import { translate } from '@/i18n/i18n'\nexport const label = translate('auto.example.greeting', 'Hello {{name}}', { name: 'Orca' })\n"
@@ -45,12 +45,10 @@ describe('verify-localization-catalog', () => {
expect(readJson(path.join(localesDir, 'en.json'))).toEqual({
auto: { example: { greeting: 'Hello {{name}}' } }
})
expect(readJson(path.join(localesDir, 'es.json'))).toEqual({
auto: { example: { greeting: 'Hello {{name}}' } }
})
expect(readJson(path.join(localesDir, 'es.json'))).toEqual({})
})
it('repairs stale locale keys and interpolation mismatches', async () => {
it('never overwrites mismatched translations or removes target-only entries', async () => {
const { root, localesDir } = makeProject({
sourceText:
"import { translate } from '@/i18n/i18n'\nexport const label = translate('auto.example.greeting', 'Hello {{name}}', { name: 'Orca' })\n",
@@ -63,13 +61,29 @@ describe('verify-localization-catalog', () => {
}
})
await expect(verifyLocalizationCatalog(root, { fix: true })).resolves.toBe(0)
await expect(verifyLocalizationCatalog(root, { fix: true })).resolves.toBe(1)
expect(readJson(path.join(localesDir, 'es.json'))).toEqual({
auto: { example: { greeting: 'Hello {{name}}' } }
auto: {
example: { greeting: 'Hola' },
stale: { removed: 'Viejo' }
}
})
})
it('accepts sparse target catalogs when existing placeholders match', async () => {
const { root } = makeProject({
sourceText:
"import { translate } from '@/i18n/i18n'\nexport const label = translate('auto.example.greeting', 'Hello {{name}}', { name: 'Orca' })\n",
enCatalog: {
auto: { example: { greeting: 'Hello {{name}}', untranslated: 'English only' } }
},
esCatalog: { auto: { example: { greeting: 'Hola {{name}}' } } }
})
await expect(verifyLocalizationCatalog(root, { fix: false })).resolves.toBe(0)
})
it('does not invent values for keys without string fallbacks', async () => {
const { root, localesDir } = makeProject({
sourceText:
@@ -0,0 +1,123 @@
import { execFile } from 'node:child_process'
import fs from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import process from 'node:process'
import { promisify } from 'node:util'
import { pathToFileURL } from 'node:url'
const execFileAsync = promisify(execFile)
const EN_CATALOG_PATH = path.join('src', 'renderer', 'src', 'i18n', 'locales', 'en.json')
const PLACEHOLDER_RE = /\{\{[^}]+\}\}/g
function flattenCatalog(value, prefix = '', entries = new Map()) {
if (typeof value === 'string') {
entries.set(prefix, value)
return entries
}
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return entries
}
for (const [key, child] of Object.entries(value)) {
flattenCatalog(child, prefix ? `${prefix}.${key}` : key, entries)
}
return entries
}
function placeholders(value) {
return [...(value.match(PLACEHOLDER_RE) ?? [])].sort().join('|')
}
export function compareExtraction(extractedCatalog, englishCatalog) {
const extracted = flattenCatalog(extractedCatalog)
const english = flattenCatalog(englishCatalog)
const dynamicDefaults = [...extracted.entries()]
.filter(([, value]) => value.length === 0)
.map(([key]) => key)
const missingFromEnglish = [...extracted.keys()].filter((key) => !english.has(key))
const orphans = [...english.keys()].filter((key) => !extracted.has(key))
const fallbackDrift = []
const placeholderMismatches = []
for (const [key, extractedValue] of extracted) {
const englishValue = english.get(key)
if (
extractedValue.length === 0 ||
englishValue === undefined ||
englishValue === extractedValue
) {
continue
}
fallbackDrift.push(key)
if (placeholders(extractedValue) !== placeholders(englishValue)) {
placeholderMismatches.push(key)
}
}
return {
extracted,
dynamicDefaults,
missingFromEnglish,
orphans,
fallbackDrift,
placeholderMismatches
}
}
function printKeys(label, keys) {
if (keys.length === 0) {
return
}
console.error(`${label}:`)
for (const key of keys.slice(0, 20)) {
console.error(` ${key}`)
}
if (keys.length > 20) {
console.error(` ...and ${keys.length - 20} more`)
}
}
async function extractToTemporaryCatalog(root, tempDir) {
const cliPath = path.join(root, 'node_modules', 'i18next-cli', 'dist', 'esm', 'cli.js')
const outputPattern = path.join(tempDir, '{{language}}.json')
// Why: extraction output is evidence for this check, not another committed
// catalog that feature authors must keep synchronized.
await execFileAsync(process.execPath, [cliPath, 'extract', '--sync-primary', '--quiet'], {
cwd: root,
env: {
...process.env,
ORCA_I18N_EXTRACTION_OUTPUT: outputPattern.split(path.sep).join('/')
}
})
return JSON.parse(await fs.readFile(path.join(tempDir, 'en.json'), 'utf8'))
}
export async function main(root = process.cwd()) {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'orca-i18next-extraction-'))
try {
const [extractedCatalog, englishCatalog] = await Promise.all([
extractToTemporaryCatalog(root, tempDir),
fs.readFile(path.join(root, EN_CATALOG_PATH), 'utf8').then(JSON.parse)
])
const result = compareExtraction(extractedCatalog, englishCatalog)
console.log(
`Extracted ${result.extracted.size} keys; ${result.dynamicDefaults.length} dynamic defaults are report-only, ${result.orphans.length} existing English entries are not statically referenced, and ${result.fallbackDrift.length} inline defaults differ.`
)
if (result.missingFromEnglish.length > 0 || result.placeholderMismatches.length > 0) {
printKeys('Extracted keys missing from en.json', result.missingFromEnglish)
printKeys('Extracted defaults with incompatible placeholders', result.placeholderMismatches)
return 1
}
return 0
} finally {
await fs.rm(tempDir, { recursive: true, force: true })
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
process.exit(await main())
}
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest'
import { compareExtraction } from './verify-localization-extraction.mjs'
describe('verify-localization-extraction', () => {
it('reports legacy drift without requiring a committed disposition database', () => {
const result = compareExtraction(
{ menu: { open: 'Open now {{name}}' } },
{ menu: { open: 'Open {{name}}', legacy: 'Legacy copy' } }
)
expect(result.orphans).toEqual(['menu.legacy'])
expect(result.fallbackDrift).toEqual(['menu.open'])
expect(result.placeholderMismatches).toEqual([])
})
it('reports dynamic defaults without treating empty extractor values as catalog copy', () => {
const result = compareExtraction(
{ menu: { dynamic: '' } },
{ menu: { dynamic: '{{count}} items' } }
)
expect(result.dynamicDefaults).toEqual(['menu.dynamic'])
expect(result.fallbackDrift).toEqual([])
expect(result.placeholderMismatches).toEqual([])
})
it('rejects undeclared keys even when their defaults are dynamic', () => {
const result = compareExtraction({ menu: { dynamic: '' } }, {})
expect(result.missingFromEnglish).toEqual(['menu.dynamic'])
})
it('rejects missing English declarations and incompatible placeholders', () => {
const result = compareExtraction(
{
menu: {
missing: 'Missing',
open: 'Open {{name}}'
}
},
{ menu: { open: 'Open {{path}}' } }
)
expect(result.missingFromEnglish).toEqual(['menu.missing'])
expect(result.placeholderMismatches).toEqual(['menu.open'])
})
})
+6
View File
@@ -9,12 +9,17 @@
"../src/main/agent-hooks/hooks-json-read.ts",
"../src/main/agent-hooks/installer-utils.ts",
"../src/main/agent-hooks/installer-utils-remote.ts",
"../src/main/agent-hooks/local-agent-cli-presence.ts",
"../src/main/agent-hooks/managed-agent-hook-controls.ts",
"../src/main/agent-hooks/managed-agent-hook-registry.ts",
"../src/main/ipc/local-agent-install-dir-detection.ts",
"../src/main/ipc/tui-agent-detection-commands.ts",
"../src/main/amp/hook-service.ts",
"../src/main/antigravity/hook-service.ts",
"../src/main/claude/hook-settings.ts",
"../src/main/claude/hook-service.ts",
"../src/main/claude/statusline-script.ts",
"../src/main/claude-accounts/keychain.ts",
"../src/main/codex/codex-app-server-capability-cache.ts",
"../src/main/codex/codex-app-server-capability-signal.ts",
"../src/main/codex/codex-app-server-client.ts",
@@ -68,6 +73,7 @@
"../src/main/kimi/kimi-hook-config-toml.ts",
"../src/main/openclaude/hook-service.ts",
"../src/main/rolling-file-backup.ts",
"../src/main/startup/hydrate-shell-path.ts",
"../src/main/runtime/runtime-metadata.ts",
"../src/main/win32-utils.ts"
],
+1
View File
@@ -4,6 +4,7 @@
"../electron.vite.config.*",
"../build-plugins/**/*",
"../src/main/**/*",
"../src/renderer/src/lib/skill-freshness-display-status.ts",
"../src/preload/**/*",
"../src/shared/**/*",
"../src/relay/**/*",
+2
View File
@@ -15,6 +15,8 @@ export default defineConfig({
},
test: {
environment: 'node',
// Why: Node 26's undefined Web Storage globals prevent Vitest from installing happy-dom's.
execArgv: ['--no-experimental-webstorage'],
include: [
'src/**/*.test.ts',
'src/**/*.test.tsx',
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 11m">
<title>downloads: 11m</title>
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 12m">
<title>downloads: 12m</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="90" y="15" fill="#010101" fill-opacity=".3">11m</text>
<text x="90" y="14">11m</text>
<text x="90" y="15" fill="#010101" fill-opacity=".3">12m</text>
<text x="90" y="14">12m</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 935 B

After

Width:  |  Height:  |  Size: 935 B

File diff suppressed because it is too large Load Diff
+63
View File
@@ -729,6 +729,69 @@ deliberately. The post-upgrade binary and version record are retained in
artifacts and remove them according to your retention policy after the rollback
is resolved.
## Installing Agent Skills Without A Desktop
Orca's agent skills (CLI usage, orchestration, computer use, etc.) are normally
installed from Orca Settings, which pre-fills an `npx skills add ... --global`
command in a terminal for you to run. A headless host has no Settings UI, so
use `orca skills install` instead:
```bash
orca skills install # list installable skills
orca skills install --skill orca-cli --skill orchestration # install globally (default)
orca skills install --skill orca-cli --local # install into the current project only
orca skills install --all # install every bundled skill
orca skills install --all --dry-run # print the npx command without running it
```
This resolves the same `npx skills add <repo> --skill <name> ...` command
Settings would show you (adding `--global` unless `--local` is passed), then
runs it and forwards its output and exit code. It requires `node`/`npx` on the
host; it does not need a running Orca runtime.
Unlike the command Settings shows, the spawned one adds `npx --yes` and `-y`.
Without them the `skills` CLI opens an interactive agent picker and blocks
forever on any allocated TTY — which includes a normal `ssh` session. Use
`--dry-run` to see the exact command that will run.
Settings keeps that picker deliberately, because choosing which agents get a
skill is a real decision. A headless run cannot answer it, so instead of dropping
the choice Orca makes it explicitly: it passes an `--agent` list built from the
coding agents it detects on the host, plus the shared `.agents/skills` directory
it reads itself. Left to decide on its own with no agent detected, the `skills`
CLI installs into all ~75 agents it knows and leaves a config directory for each.
Override the targets yourself, or narrow to the shared directory alone:
```bash
orca skills install --skill orca-cli --agent claude-code,codex
orca skills install --skill orca-cli --agent universal
```
If Orca detects no agent at all, `orca skills install` stops and asks for
`--agent` rather than guessing.
To refresh already-installed skills, `orca skills update` mirrors the same
selection flags (`--skill`, `--all`, `--local`, `--dry-run`) and resolves to
`npx skills update <names...>` with a matching scope flag — `--global`, or
`--project` when you pass `--local`:
```bash
orca skills update --all # update every bundled skill globally
orca skills update --skill orca-cli --dry-run # print the npx command without running it
```
`orca skills update` only refreshes skills that are already installed — it exits
0 without doing anything for a skill that is missing, so install it first. More
generally, a 0 exit means the `skills` CLI ran without erroring, not that it
wrote anything; read its output to confirm what changed.
`--json` covers the skill listing and `--dry-run`. A real run streams the
`skills` CLI's own non-JSON output and rejects `--json`.
Both commands install onto the machine that runs them. In an Orca SSH workspace
or the WSL bridge the `orca` shim forwards commands to the Orca host, so they
refuse to run there and print the command to run on the machine you want.
## Troubleshooting
- `dlopen(): error loading libfuse.so.2`: install `libfuse2`.
@@ -1,213 +0,0 @@
# STA-2694 — garbled terminal after switching away from an AI workspace
Handoff snapshot: 2026-07-27, branch
`neil/sta-2694-fix-ui-rendering-artifacts-when-switching-away-from-ai`.
## The report
> When I use an AI tool—such as OpenCode—and switch away to the desktop or
> another task, the page appears garbled or distorted upon returning; I have to
> resize the window to restore the display.
Reporter hints: related to terminal parking / workspace switching; also happens
with Claude Code and probably grok.
## Status
**One defect, root-caused and fixed, with a mechanism-level test that fails
without the fix.** The garble window is *unbounded*, not the ~1s I originally
claimed — see [the correction](#correction-the-window-is-unbounded).
A second hypothesis was implemented, then **refuted by measurement and
reverted**. That refutation is the other half of the value here: it rules out a
whole class of "just force a fuller repaint" fixes, and it is pinned by a test so
it cannot be reintroduced.
| | |
|---|---|
| Fix | `ed1eaf55f1` — release an abandoned synchronized-output frame on reveal |
| Refuted + reverted | `0f7ec4458d`, reverted in `8d5eacecb4` |
| Oracle + scope correction | `3ccffc17ec` |
| Unit tests | 8 passing across 2 files |
| e2e | 5 draw-command oracle + 10 headless convergence + 1 headful |
| Teeth-verified | Yes — removing the fix fails the stranded-latch test |
## The defect
Alt-screen agent TUIs (OpenCode/OpenTUI, Codex, grok) bracket every repaint in
DEC 2026 synchronized output — `\x1b[?2026h … \x1b[?2026l` — many times a second.
Hide a pane mid-bracket (a worktree switch, a cold park, or an OS-level occlusion
lands there routinely) and xterm keeps
`decPrivateModes.synchronizedOutput` latched `true`.
`RenderService.refreshRows` checks that latch **before** rendering
(`RenderService.ts:162`), so while it holds, every repaint Orca owns is a no-op:
the forced render-pause repaint, the plain `refresh()` fallback, and the shared
glyph-atlas rebuild all render zero rows against a perfectly correct buffer.
The fix (`terminal-synchronized-output-release.ts`) clears the latch and flushes
the handler's buffered row range at both reveal repaint entry points, before the
repaint. Both reveal paths reach it:
- `schedulePaneRevealPresent` (plain refocus / desktop return)
- `resetWebglTextureAtlas` (worktree switch, cold park, tab reveal — also reached
from `resetAndRefreshAllTerminalWebglAtlases` via the light tab-resume path)
### Correction: the window is unbounded
`ed1eaf55f1`'s commit message says this "closes a bounded window rather than the
whole STA-2694 report", because xterm arms a 1s watchdog that clears the latch.
**That was wrong.** The watchdog is armed only inside
`SynchronizedOutputHandler.bufferRows`, and `refreshRows` returns at its
`_isPaused` check *first*:
```ts
public refreshRows(start, end, sync = false, isRedrawOnly = false): void {
if (this._isPaused) { this._needsFullRefresh = true; return } // ← occluded pane stops here
if (this._coreService.decPrivateModes.synchronizedOutput) {
this._syncOutputHandler.bufferRows(start, end) // ← only place the watchdog arms
return
}
...
```
While a pane is occluded its `IntersectionObserver` sets `_isPaused`, so nothing
ever reaches `bufferRows` and **no timer is ever pending**. A pane hidden
mid-frame holds the latch with no watchdog behind it, indefinitely — which is the
indefinite garble the report describes. `terminal-reveal-draw-command-probe.spec.ts`
asserts exactly this: latch set, `_timeout === undefined`, and a repaint drawing
0 instances.
This also explains the specific workaround: resizing the window makes the TUI
repaint via SIGWINCH, and that repaint's closing `?2026l` clears the latch.
## The refuted hypothesis (do not reimplement)
The idea: xterm's renderers are diff-based, so a reveal `refresh()` skips cells
whose model entry still matches the buffer, leaving an occluded canvas showing
pre-hide pixels until a resize rebuilds the model. It is a *plausible* reading —
`WebglRenderer._updateModel` really does early-continue per unchanged cell.
It is wrong, measured on a live pane:
| Refresh | `updateCell` calls | instances drawn |
|---|---|---|
| diff-skipped (model matches buffer) | 0 | 562 |
| model cleared first | 561 | 562 |
`GlyphRenderer.render` copies vertices for **every** row up to `lineLengths[y]`
into the active buffer and issues **one** full-viewport `drawElementsInstanced`.
The diff only decides how much *vertex data* is rewritten, never how much is
*drawn*. The DOM renderer likewise `replaceChildren()`s every row
unconditionally. So clearing the model cannot change what reaches the screen.
Worse, it has a cost: `_clearModel(true)` zeroes every glyph vertex, and
`RenderService.clear()` fires no repaint of its own — so any paint landing between
the clear and the repopulating refresh draws an **empty** viewport (0 instances,
also asserted in the spec). On a reveal that refresh is debounced through a RAF
and can itself be swallowed, which would turn a merely-stale pane into a blank
one. Reverted in `8d5eacecb4`.
The first test in the oracle spec pins the refutation so this cannot come back as
a "fix".
## Why pixel oracles failed, and what replaced them
The field defect is "the buffer is correct but the compositor shows pre-hide
pixels". Two pixel oracles were built and **both were proven blind** by injecting
that exact defect (freeze `RenderService.refreshRows`, then write new content):
1. **Canvas-vs-buffer ink sampling.** `drawImage` on a
non-`preserveDrawingBuffer` WebGL canvas hands back a *re-rendered* copy — it
reported 0 missing cells against 5263 cells of text the canvas had never
drawn.
2. **Screenshot vs. a forced repaint.** Playwright's screenshot drives a fresh
compositor frame, which *heals* the stale paint before capture; and the
"repair" ran the same code the reveal already ran, so a shared defect cancels
out.
An earlier resize-referenced oracle was also unsound: resizing shifts alt-screen
rows, so its 4.4% pixel diff measured legitimate reflow.
**Pixels are the wrong layer** — anything that reads them can trigger the repaint
that hides the bug. `tests/e2e/terminal-reveal-draw-command-probe.spec.ts`
instead wraps `GlyphRenderer.updateCell` and `gl.drawElementsInstanced` on the
live pane and counts draw commands. A draw command cannot be healed after the
fact, so "did the reveal repaint?" is directly observable — and falsifiable,
which is how the second hypothesis got refuted.
Use that spec for **paint** questions;
`terminal-opencode-altscreen-reveal-artifacts.spec.ts` for **buffer, geometry and
PTY-size convergence** (worktree switch, cold park, idle agent, headful desktop
hide). The idle-agent case matters: every earlier test kept the TUI streaming
across the reveal, so live frames repainted whatever the reveal got wrong and the
defect healed itself before any assertion ran.
## Leads closed by measurement
Both were live suspects in the previous handoff; both are now ruled out.
- **Dimension staleness.** Every dimension-rebuilding path in `WebglRenderer` is
behind an unchanged/zero-sized early exit that a hidden pane trips, and
`performSafeFit` early-returns at unchanged geometry — so nothing on the reveal
path re-runs `handleResize()`. Measured across a real `window.hide()` /
`show()`: canvas backing store `1272×1104` matched the renderer's computed
dimensions exactly, char metrics stayed `8.65×16`, `_isAttached` stayed true,
the screen element stayed connected. No staleness.
- **Lagging atlas page bindings.** Right after a reveal the bound texture version
*does* lag the atlas page (e.g. 1297 vs 1366) — but bindings are lazy and
`GlyphRenderer.render` rebinds any page whose version moved, so one repaint
brings them level (1366/1366). Normal, not a defect. Judge bindings only
*after* a draw.
## Residual risk
The oracle proves the repaint is *issued*; it does not photograph the user's
screen. If a report survives this fix, the remaining suspects are below the draw
call — GPU-process/compositor-level content loss — where the in-app sentinel on
real hardware is the tool:
```js
localStorage.setItem('orca:render-desync-sentinel', '1') // then reload
```
Reproduce, then **⌘-click** (Ctrl-click off Mac) in the garbled pane. That starts
a 10s burst at 250ms intervals; a trip needs the same cells missing across 2
consecutive samples, ≥200 text cells, ≥8% missing. It writes `corrupt.png` +
`corrupt.json` to `<userData>/terminal-render-desync-evidence/<captureId>/`, then
runs atlas recovery and captures the healed frame. Armed at import when the flag
is set (`terminal-freeze-breadcrumbs.ts:45`).
## Verification commands
```bash
npx vitest run --config config/vitest.config.ts src/renderer/src/lib/pane-manager/
npx tsc --noEmit -p config/tsconfig.tc.web.json
npx playwright test tests/e2e/terminal-reveal-draw-command-probe.spec.ts \
tests/e2e/terminal-opencode-altscreen-reveal-artifacts.spec.ts \
tests/e2e/terminal-inline-tui-reveal-convergence.spec.ts \
--config tests/playwright.config.ts --project=electron-headless --workers=1
SKIP_BUILD=1 npx playwright test tests/e2e/terminal-opencode-altscreen-reveal-artifacts.spec.ts \
--config tests/playwright.config.ts --project=electron-headful --workers=1
```
To confirm the fix still has teeth, delete the
`releaseAbandonedSynchronizedOutput(pane.terminal)` line from
`schedulePaneRevealPresent` and re-run the oracle: the stranded-latch test fails
with "the reveal repaint did not release the stranded latch, so the pane stays
frozen".
## Files
Production:
- `src/renderer/src/lib/pane-manager/terminal-synchronized-output-release.ts` (the fix)
- `src/renderer/src/lib/pane-manager/pane-reveal-repaint.ts` (`schedulePaneRevealPresent`)
- `src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts` (`resetWebglTextureAtlas`)
Tests:
- `terminal-synchronized-output-release.test.ts`, `reveal-repaint-synchronized-output.test.ts`
- `tests/e2e/terminal-reveal-draw-command-probe.spec.ts` (the oracle)
- `tests/e2e/terminal-opencode-altscreen-reveal-artifacts.spec.ts`,
`tests/e2e/fixtures/opencode-altscreen-live-fixture.cjs`
@@ -0,0 +1,576 @@
# Preserve active orchestration work across contract updates
Status: implementation validation
Date: 2026-07-28
## Decision
Do not block updates, restart active workers, or revive the retired scheduler.
On first launch after the contract update, atomically adopt the durable
pre-Run graph into one ordinary lightweight Run. Preserve every Task,
Dispatch, message, gate, terminal, PTY, process, worktree, and prompt
identifier. Mark only the pre-update Dispatch attempts as legacy-contract
attempts and accept the exact command shapes already pasted into those
workers through a narrow adapter.
This is state migration plus a per-Dispatch protocol adapter. It is not a
second scheduler:
- the migrated Run owns the preserved graph and coordinator inbox;
- legacy attempts keep their original protocol until they settle;
- every new attempt uses the current protocol, even on a preserved Task; and
- no placement, polling, concurrency, or deadline loop is resurrected.
## User-visible outcome
- The update installs immediately.
- Existing coordinator and worker terminals keep the same process, PTY,
worktree, prompt, Task ID, Dispatch ID, and history.
- Orca does not inject text, focus a tab, or start a replacement editor.
- The legacy graph appears in an automatically adopted, normal Run instead of
being stranded in `run_legacy_local`.
- An original live worker with retained launch proof can keep using the
`heartbeat`, `worker_done`, `escalation`, `ask`, and `check` commands in its
existing prompt.
- An original live coordinator can keep listing and settling preserved Tasks,
dispatch remaining work with current commands, check direct legacy mail, and
use the pre-cutover `reply` CLI guidance.
- Unprovable, stale, ambiguous, or already-settled rows remain inspectable and
unmistakably read-only. They never advertise Reply or Ack actions.
- A pre-update WSL process whose older launcher did not propagate the hidden
token remains lifecycle read-only. Its assignment, process, terminal, and
filesystem work remain valid and inspectable.
The retired `orchestration run`, `coordinator-start`, and `coordinator-stop`
scheduler commands remain retired. Their in-memory loop cannot be reconstructed
truthfully. The coordinator agent and all durable work remain accessible; it
explicitly dispatches any ready work that was never assigned before the update.
## Evidence and constraints
- Schema v7 preserved old `messages`, `tasks`, `dispatch_contexts`, and
`decision_gates` by assigning them to `run_legacy_local`.
- The exact prompt at `8b154d686` used old `send`, `ask`, and consuming
`check` shapes. It omitted `worker_done --outcome`; a subject equal to
`Failed` or beginning `Failed:` represented failure. Its escalation carried
`taskId` but no `dispatchId`.
- `reply` existed in the old CLI and message formatter guidance, not in that
pinned worker prompt.
- Restored `OrcaRuntime` PTY records currently set `launchToken: null`, and
provider `listProcesses()` does not expose the launch token. Handles and
pane IDs alone therefore cannot authorize compatibility.
- Managed hooks source the owner-only endpoint file on every invocation, so an
old process reaches the new hook server after restart. The hook cache retains
the prior authenticated pane/launch-token association and distinguishes
hydrated rows from events observed by the current runtime.
- Older builds did not propagate `ORCA_AGENT_LAUNCH_TOKEN` through `WSLENV`, and
a running process cannot acquire it retroactively.
- The CLI transport mutation receipt is keyed by rotating runtime
authentication. It cannot deduplicate a legacy mutation across restart.
- Current invalid-capability lifecycle sends deliberately remain as auditable
rejected messages. Compatibility must not silently change that behavior.
- PR #11142 fixes background-tab materialization for newly dispatched workers.
It does not recover a pre-fix worker whose PTY survived an update after its
renderer tab binding was lost; workspace activation can otherwise mistake
that worker for a sleeping provider session and launch `codex resume`.
- The packaged Windows launcher keeps its Electron-as-Node child alive for the
lifetime of a blocking CLI command. A seamless design cannot assume that a
bounded RPC wait releases the installed executable.
## Non-negotiable invariants
1. Adoption never spawns, writes, signals, stops, focuses, or injects a
terminal and never mutates the assigned filesystem. It may restore one
persisted background tab/leaf binding for the exact already-live PTY.
2. Every durable row and identifier survives adoption; only its owning Run and
explicit protocol metadata change.
3. Compatibility is write-once per Dispatch. Migration never classifies a
current retry as legacy.
4. A legacy mutation requires one uniquely resolved legacy attempt or
coordinator principal plus current live proof of its original process tree.
5. Caller-supplied handles, pane keys, Task IDs, Dispatch IDs, or remote
attachments never create authority.
6. Current Dispatch behavior remains unchanged, including auditable rejected
lifecycle messages, capabilities, process incarnation, Run Delivery, and
question semantics.
7. Legacy direct mail and current Run Delivery are disjoint, even when their
Dispatches share one migrated Task and Run.
8. A B-era disconnect may replay accepted work; it may not lose mail or create
a second question, reply, completion, or settlement. A cut after an A-era
ask was already answered is explicitly ambiguous because A persisted no
invocation identity; recovery replays its answer through legacy check before
a same-text question is asked again.
9. Different reply bodies for the same source and principal conflict instead
of creating multiple replies.
10. Folder workspaces, SSH, WSL, remote runtimes, macOS, Linux, and Windows
follow the same proof rules. Missing proof degrades to inspection without
invalidating the retained assignment or filesystem work.
## Storage and atomic adoption
Raise the schema version and add:
```text
dispatch_contexts.contract_version INTEGER NOT NULL
dispatch_contexts.launch_token_hash TEXT
messages.delivery_contract TEXT NOT NULL
CHECK(delivery_contract IN ('legacy_direct', 'current_delivery', 'audit_only'))
legacy_adoptions(
source_run_id PRIMARY KEY,
adopted_run_id UNIQUE NOT NULL,
scheduler_state_lost INTEGER NOT NULL,
adopted_at
)
legacy_compatibility_principals(
id PRIMARY KEY,
run_id NOT NULL,
dispatch_id,
role CHECK(role IN ('worker', 'coordinator')),
host_scope NOT NULL,
terminal_handle NOT NULL,
pane_key NOT NULL,
launch_token_hash NOT NULL,
process_incarnation,
status CHECK(status IN ('committed', 'settled', 'revoked')),
UNIQUE(role, run_id, dispatch_id)
)
legacy_operation_receipts(
principal_id,
operation_key,
method,
payload_hash,
effect_id,
response_json,
completed_at,
PRIMARY KEY(principal_id, operation_key)
)
legacy_mail_receipts(
principal_id,
message_id,
acknowledged_at,
PRIMARY KEY(principal_id, message_id)
)
```
The schema migration runs in one `BEGIN IMMEDIATE` transaction:
1. create a persisted random Run ID and `legacy_adoptions` marker if one does
not already exist;
2. create an ordinary `legacy = 0` Run with a recovery objective;
3. move all `run_legacy_local` Tasks, Dispatches, decision gates, messages,
and question threads to it without changing their IDs, sequence, status, or
content;
4. mark pre-existing direct mail `legacy_direct` and hard-cutover rejection
rows `audit_only`;
5. mark a Dispatch legacy only when it predates the contract column and lacks
a current dispatch capability; and
6. fence any impossible outstanding legacy Delivery, move retained Delivery
history to the adopted Run, assert every Task/Dispatch/gate/message/question/
Delivery Run ID agrees, and leave `run_legacy_local` as an empty read-only
audit tombstone.
Every new Dispatch and message writes the current contract explicitly.
Repeated startup, a partially initialized fixture, and WAL recovery reuse the
persisted adoption marker. Reset and fresh-database paths cannot synthesize
legacy authority.
## Durable principals and live authority
After the hook server and PTY inventory are ready, a one-shot adoption service
may commit compatibility principals. It does not mutate terminal processes or
input.
Before renderer hydration, legacy Dispatch evidence also fences automatic
provider resume for the exact sleeping pane. Once the relevant local daemon or
SSH relay has authoritative inventory, Orca intersects the durable handle,
pane, PTY ID, PTY incarnation, workspace, and host owner. A unique live match
is adopted into its original tab/leaf as inactive background state while the
coordinator tab remains active. Missing, stale, reused, cross-host, or
ambiguous evidence stays fenced and inspectable. Ordinary provider resume is
unblocked only when authoritative inventory proves that exact PTY exited.
At hook-server startup, copy the validated hydrated status rows into an
immutable main-process-only startup snapshot before the server listens or
binds the new listener. Later live events update a separate current-runtime
observation map and cannot overwrite the pre-update commitment source. A
current event is preferred but is not required before the worker's first
post-restart lifecycle command when controller inventory proves the exact
original PTY and process incarnation are live.
For each candidate it resolves the union of:
- durable Dispatch assignee and pane;
- equivalent-pane aliases;
- remote attachment and host ownership;
- coordinator candidates from active legacy coordinator rows, Task creator
handles, Dispatch coordinator/parent provenance, and direct-message
sender/recipient history;
- the restored terminal inventory and process incarnation; and
- the hydrated, previously authenticated hook pane/launch-token association.
The candidate commits only when that union identifies exactly one role and
row. The orchestration database stores a SHA-256 token commitment, never the
raw token. The existing owner-only hook status cache is an input to the
one-time commitment, but a hydrated status is not enough to mutate.
Every compatibility call then requires:
- a hidden launch token and pane inherited by the calling process;
- the immutable pre-update hook commitment, plus either a matching
current-runtime hook event or exact current controller proof of the original
PTY and process incarnation;
- a live PTY with the committed handle, host scope, and process incarnation;
- the committed principal and exact active legacy Dispatch or adopted Run; and
- an operation-specific unique match.
The current hook event or exact controller inventory proves that the process
survived the restart; equality with the pre-update commitment proves it is the
same launch. If the hook cache was absent, the provider cannot report the
proof, the token changed, or any candidate is ambiguous, the row remains
read-only.
For new Dispatches, persist the launch-token hash at dispatch time so later
contract updates do not depend on hook-cache recovery. Never return or log raw
tokens. Add explicit RPC-envelope, structured-log, error, and CLI JSON
redaction tests.
Host scope is runtime-owned:
- local: stable local execution-host identity;
- WSL: stable native execution-host identity plus distro;
- SSH: saved-host authority plus remote pane/process identity; and
- federated runtime: authenticated peer fingerprint plus remote attachment.
`pickRemoteCliEnv`, SSH relay forwarding, WSLENV, and the legacy in-process SSH
fallback must carry the hidden evidence without exposing it in command text or
logs. A local runtime never accepts a raw remote token as local authority.
Pre-update WSL processes without the token cannot use the lifecycle adapter.
For SSH, the host passthrough and in-process fallback stamp the saved target,
`SshRelaySession` connection incarnation, and attachment provenance from the
authenticated runtime channel; caller environment cannot choose them. WSL
stamps the runtime-owned native host and distro in both the full bridge and
fallback bridge. The verifier intersects those stamps with the committed host
scope before considering token or pane evidence.
## Mixed-contract Run and mail routing
The adopted Run is a normal current Run. Tasks belong to it, so a current retry
of a preserved Task naturally routes to the same Run without a cross-Run Task
reference.
Message delivery is selected by `messages.delivery_contract`, not by Run ID:
- old and adapter-created direct messages are `legacy_direct`;
- current sends are `current_delivery`; and
- migration rejections and non-actionable history are `audit_only`.
Current Run Delivery creation, filtering, waiting, and acknowledgment select
only `current_delivery`. Legacy `check` selects only the exact principal's
`legacy_direct` address. History and inbox may show all three with explicit
labels but never attach actions to `audit_only`. Mixed legacy/current attempts
under one Task are covered across send, ask, check, reply, and settlement.
The original coordinator is automatically associated with the adopted Run
after its principal proves live. `run-use` may bind that same principal without
converting legacy Dispatches. A different coordinator cannot silently take
over while a legacy attempt is active; an explicit recovery takeover is a
separate current-contract action and revokes legacy coordinator mutation
authority before rebinding.
When that coordinator uses old direct `check`, the adapter also performs a
non-consuming existence check for `current_delivery` mail. It never mixes the
messages into legacy output; it prints the exact current Run check command and,
after that command creates a Delivery, the exact acknowledgment form. This
keeps an unchanged coordinator informed about current retries without
weakening current Delivery semantics.
## Exact legacy adapter
The CLI keeps accepting the old flags. It sends a hidden compatibility
envelope; the runtime, not the CLI, decides whether legacy behavior applies.
Version B generates a random hidden `compatibilityInvocationId` once per CLI
process and reuses it for every transport reconnect by that invocation.
`legacy_operation_receipts.operation_key` is this invocation ID for B calls.
The method and canonical payload hash must also match.
Attested compatibility calls bypass the rotating-auth generic mutation ledger
after the contract fence routes them to the adapter. Their only dedupe boundary
is the compatibility-principal receipt transaction described here; current
calls continue through the existing generic ledger unchanged.
Version A had no invocation ID. Cross-cutover reconstruction is therefore
limited to effects with a durable semantic identity: final settlement per
Dispatch, still-pending ask per normalized question/options/recipient, reply
per source/principal/body, and read recovery per message. Heartbeat and
escalation remain at-least-once across an A-to-B manual retry because
collapsing identical commands could suppress a legitimate later event. Tests
and guidance do not promise cross-cutover exactly-once behavior for those
repeatable signals.
An A-era ask that was already answered before its output was lost is also
ambiguous. B does not guess whether the same text means retry or a new
question. Its answer is in the durable recovery cohort and the error tells the
agent to run the exact legacy check command first. After that answer is printed
and acknowledged, a new B invocation with identical text creates a new
question. If multiple identical A threads exist, ask fails ambiguous and
recovery check drains them by message sequence; no arbitrary thread is chosen.
### Lifecycle `send`
- `heartbeat` and `worker_done`: resolve Task + Dispatch from the payload, then
intersect them with the verified worker principal.
- `escalation`: resolve Task + verified worker principal to exactly one active
legacy Dispatch because the old prompt supplied no Dispatch ID.
- Preserve the old direct coordinator recipient after verifying it belongs to
the adopted Run principal.
- Infer missing `worker_done` outcome as `failed` only when the trimmed subject
is `Failed` or begins `Failed:`; otherwise infer `succeeded`.
- Normalize to the current internal settlement model.
Compatibility validation happens before message insertion. Current lifecycle
handling is not refactored through this prevalidation: its existing
invalid-capability audit message behavior remains locked by regression tests.
The DB commits the direct message, lifecycle effect, and compatibility receipt
in one transaction. If a pre-update `worker_done` already settled the Dispatch,
the adapter reconstructs the matching semantic receipt from the existing
message and settlement. The persisted A settlement is authoritative: the
pinned A reconciler recorded even a `Failed:` subject as completed, so B
returns that original completed result rather than retroactively changing Task
or dependency state. Only a not-yet-accepted B call applies the corrected
failure inference. A conflicting retry fails.
### `check`
Legacy check is at-least-once:
1. a read RPC returns the exact unacknowledged `legacy_direct` message IDs
without changing them;
2. the CLI prints the complete response; and
3. a hidden ack atomically records receipts and marks those exact rows read.
A cut after read and before ack replays the same IDs. `--peek` and `--all`
remain read-only.
Version A could mark a message read before losing its response. On adoption,
every eligible read direct message connected to a still-active legacy attempt
and lacking a post-adoption receipt is inserted into a durable recovery cohort
using `legacy_mail_receipts` with `acknowledged_at = NULL`. Legacy check drains
that stable cohort in bounded sequence-ordered pages before ordinary unread
mail. Each page is labeled
`[LEGACY RECOVERY REPLAY — MAY HAVE BEEN SEEN]`, replays until its post-print
ack, and then advances. The cohort never grows after adoption, so every
eligible row is covered without an unbounded response. Settled-at-cutover and
unrelated historical mail do not enter it.
### `ask`
Legacy ask is an idempotent mutation followed by a resumable read:
1. create or find the direct legacy gate/question by verified Dispatch,
normalized question, options, and recipient;
2. atomically commit its message/thread plus operation receipt and return the
stable question ID;
3. wait in bounded read-only slices using that ID; and
4. after printing the answer, hidden-ack that exact answer.
On first adoption, existing pending decision-gate threads are indexed into the
same semantic identity. A retry resumes them instead of creating another gate.
### `reply`
Legacy reply requires the source thread, the verified coordinator principal,
and a unique question owned by the adopted Run. One transaction inserts the
reply, resolves the exact gate, marks the exact source, and completes the
receipt.
An existing pre-update reply with the same source, principal, and body is
reconstructed as success. A second body for the same source/principal is a
conflict, not another reply.
### Coordinator and Task commands
After automatic binding, unchanged Task listing, Task settlement, dispatch,
and gate inspection use the adopted Run. A new attempt receives a current
preamble, capability, launch-token commitment, and current message contract.
No unrelated Task can be created until the migrated coordinator binding is
proven or explicitly taken over.
## Windows update behavior
No compatibility mutation holds a DB transaction while waiting. Windows
legacy `ask` commits the question, prints its stable ID and exact resume
command, then exits before a long wait whenever the installed launcher cannot
prove that the executable is replaceable.
The native packaged launcher sets an internal
`ORCA_WINDOWS_PACKAGED_CLI_LAUNCHER=1` marker for its Electron-as-Node child.
After the DB commit, legacy `ask` writes and flushes the stable question ID and
the exact resume command, then exits with a documented `75` resume-required
status. The command name comes only from the validated inherited
`ORCA_CLI_COMMAND` packaged enum: `orca` for native packaged terminals or
`orca-ide` for packaged WSL. Arbitrary environment text is rejected rather
than rendered. The launcher propagates status 75. No long wait starts on this
path.
This compatibility path makes the accepted question recoverable without
claiming Windows updater or uninstaller process ownership. Generic executable
drain, replacement, and uninstall behavior remains separate updater
reliability work and requires its own Windows artifact proof.
## Rendering and authoritative guidance
Formatting is authority-aware:
- `[LEGACY COMPATIBILITY]`: live and attested; show only supported commands;
- `[LEGACY RECOVERY REPLAY — MAY HAVE BEEN SEEN]`: one bounded cutover replay;
- `[LEGACY READ-ONLY]`: retained but unactionable; no Reply or Ack hint; and
- current messages: unchanged.
Update `skill-guides/orchestration.md` and regenerate its bundled source. The
guide explains automatic adoption, exact continuity, the retired scheduler
boundary, proof failures, current retries, and the Windows ask resume fallback.
It must not tell agents to restart a worker whose Dispatch was adopted.
Formatting cannot use database provenance alone; current liveness and
attestation are runtime facts. Recovery may reveal the exact already-live PTY
once as an inactive background tab. It never creates a PTY or process, writes
input, switches workspaces, focuses, splits, or injects a terminal.
## Deterministic verification
### Schema and authority
- Migrate exact pre-v7, hard-cutover, current, partial, WAL, reset, and fresh
fixtures; preserve IDs and row counts and reuse one adopted Run.
- Assert Task, Dispatch, decision-gate, message, question-thread, and retained
Delivery Run ownership agrees; outstanding legacy Deliveries are fenced.
- Prove current retries in the adopted Run remain current across repeated
migrations.
- Test unique worker/coordinator import from prior hook commitment plus current
live hook, and every forged, stale, wrong-host, wrong-pane, wrong-process,
duplicate-token, duplicate-role, equivalent-pane, and remote-attachment
ambiguity.
- Race a current-runtime hook before adoption and prove the immutable startup
commitment is neither overwritten nor confused with live proof.
- Cover manual coordinators with no `coordinator_runs` row and reject
conflicting candidates from Task, Dispatch, and message provenance.
- Assert all legacy authority failures occur before message/receipt/gate/state
effects while current invalid-capability rejection remains unchanged.
- Assert tokens are absent from RPC logs, errors, CLI output, and orchestration
storage.
### Exact old behavior and replay
- Replay the pinned prompt's heartbeat, successful and `Failed:`
`worker_done`, task-only escalation, ask, and consuming check.
- Replay the old formatter's reply command separately.
- Cover every operation once after B and once accepted by A immediately before
the cutover where a durable semantic identity exists, including the bounded
recovery replay for an A check and preservation of A's persisted settlement.
- Prove one B invocation reuses its invocation ID across reconnect, while
separate identical heartbeat/escalation invocations remain distinct.
- Inject real transport cuts after check read, ask commit, reply commit, and
completion commit; prove stable IDs and one receipt/effect.
- Page a recovery cohort larger than one response limit across repeated
disconnects and prove every eligible row appears until ack, then never again.
- Test mixed legacy/current attempts for every send/check/ask/reply route and
prove current Delivery cannot consume legacy direct mail and old coordinator
check only advertises the exact non-consuming current Delivery command.
### Live two-launch E2E
The focused renderer/daemon restart regression reconstructs the rc.3-era
orphan artifact in a shared candidate profile: a durable legacy Dispatch,
sleeping provider record, and live daemon PTY with its visual binding removed.
It proves startup restores the exact PID, PTY/incarnation, handle, tab/leaf,
Task, and Dispatch; emits no provider-resume spawn or interruption; preserves
coordinator focus; and remains inert after switching away and back. This
candidate/candidate fixture covers the renderer regression deterministically
but does not replace the distinct installed-A/candidate-B packaged cutover
journey below. It does not claim packaged Windows updater behavior.
Use distinct installed A and B artifacts plus an external append-only fake
agent spawn ledger:
1. launch A with coordinator and worker terminals and record the replaceable A
desktop/main PID, runtime ID, version, and artifact hash separately from the
durable daemon, coordinator, and worker identities;
2. accept one command with durable semantic identity on A—pending ask, reply,
completion, or check—cut its response, close A without stopping the daemon
or fake agent, install B, and prove B owns the relaunched runtime;
3. assert A's desktop/main process and runtime are gone, B has a different
desktop/main PID, runtime ID, version, and artifact hash, while the daemon
plus coordinator/worker PID and start time, PTY/session, terminal handle,
tab/leaf/pane, worktree, Task, and Dispatch remain byte-identical;
4. prove that exact pending ask, reply, completion, or check resumes or
deduplicates on B; do not use heartbeat, escalation, or an already-answered
A ask as an exactly-once oracle;
5. run the remaining exact old lifecycle commands and settle the same
Dispatch;
6. switch away and back and assert no second spawn/tab and no
`Conversation interrupted`; and
7. create a current attempt on preserved state and prove current grammar.
The append-only spawn ledger and an append-only interruption ledger, daemon
inventory, runtime terminal inventory, and every viewer DOM independently
prove one launch, process, handle, tab, and no `Conversation interrupted`.
Transport fault injection cuts the real CLI connection after check output but
before ack, and after ask, reply, and completion commit. Re-run the byte-exact A
argv under B and assert stable IDs plus exactly one message, gate, reply,
settlement, and receipt.
Retain #11142's local visible-inactive test and extend its oracle beyond DOM
counting with the ledgers and host inventory.
Run separate remote journeys:
- headed paired server A-to-B with a surviving external client;
- headless `orca serve` A-to-B with the same surviving-client oracle;
- both prove the remote PID/PTy continuity, both viewer states, zero
client-local handles/tabs, and wrong-host evidence with zero effects;
- Docker SSH restart/reconnect with a remote proof file binding the process,
pane, and token commitment;
- physical Windows WSL coverage proving the correct distro and rejecting a
wrong-distro token; and
- a git-independent folder workspace parameter through the same adoption
journey.
Packaged Windows updater continuity is outside this migration gate. A separate
updater change must prove candidate-built A/B install, executable release,
resume, and normal uninstall behavior on Windows before making that guarantee.
## Implementation slices
1. Schema, atomic Run adoption, per-row contract provenance, and mixed mailbox.
2. Hook-commitment import, live principal verifier, host scopes, and redaction.
3. Atomic compatibility receipts plus lifecycle send/escalation.
4. Check read/ack and cutover recovery replay.
5. Ask create/resume, reply conflict semantics, and coordinator binding.
6. CLI hidden envelope, SSH/WSL forwarding, Windows resume fallback.
7. Authority-aware formatting and the version-matched orchestration guide.
8. Two-launch, remote, platform, and #11142 regression coverage.
9. Focused unit/type/lint/E2E checks and final adversarial review.
## Definition of done
- Updates are never blocked.
- Every durable legacy Task is owned by an ordinary visible Run.
- Every provably original live legacy Dispatch can finish through its existing
prompt without changing process or worktree.
- New attempts use the current contract and mailbox in that same Run.
- No authority comes from IDs or handles alone.
- No adoption step starts, stops, writes, signals, focuses, splits, or injects
a terminal. Exact live-PTY recovery may restore one inactive background
renderer tab/leaf binding.
- B-era disconnects replay or deduplicate instead of losing or duplicating
work; A-era repeatable signals and already-answered asks follow the explicit
ambiguity recovery rules.
- Lost scheduler memory is reported precisely without stranding durable state.
- Local/background, remote, SSH, WSL, folder, and Windows guarantees have
deterministic proof.
- The PR contains this plan, focused implementation, and no unrelated changes.
+18 -7
View File
@@ -87,11 +87,21 @@ allocate one live operation per entry.
Every portable entry is admitted through `WorkspaceSpaceScanBudget` before retention:
- maximum entries held at once per worktree traversal: 100,000;
- maximum entries in any one directory listing: 100,000;
- maximum estimated live scan state per worktree traversal: 64 MiB.
The retained-byte estimate includes parent path and entry name UTF-16 storage plus conservative
per-entry object overhead. These are admission limits, not post-allocation observations.
The entry cap is per listing rather than per traversal because only a single directory's width is
fixed by directory shape. A traversal-wide entry counter is charged by every worker holding a
listing at once, so its verdict scales with the configured concurrency: at 48 workers, 48 × 2,100
files (100,848 entries) was rejected while 100 × 1,500 (150,100 entries, 50% more) was admitted.
Aggregate live retention stays bounded by the 64 MiB byte cap, which all concurrent listings share.
The retained-byte estimate includes entry name UTF-16 storage plus conservative per-entry object
overhead, and each listing's parent path once. The parent path is charged per listing rather than
per entry because a listing's entries all share a single parent-path string; charging it per entry
multiplied it by the directory's width, so the 64 MiB cap tracked checkout depth instead of live
heap and rejected the layouts above once the worktree path passed ~58 characters. These are
admission limits, not post-allocation observations.
The budget measures what the traversal is holding **right now**, not what it has ever seen. A
directory listing is charged when admitted and released once every one of its entries has been
@@ -100,9 +110,10 @@ distinction decides real workspaces: a cumulative counter charged an ordinary 76
worktree 61.2 MiB of its 64 MiB cap — 4% headroom, and tipping over purely because a longer branch
name lengthens every absolute path. The same worktree peaks between 4 MiB and 8 MiB of live state.
Because the estimate scales with absolute path length, a cumulative cap would make success depend on
where a user checks out their worktrees. A live cap depends only on directory shape, so it rejects
genuinely pathological layouts (one directory holding six figures of entries) and nothing else.
Neither cap may depend on where a user checks out their worktrees, so the estimate charges each
absolute path once per listing rather than once per entry. A live cap depends only on directory
shape, so it rejects genuinely pathological layouts (one directory holding six figures of entries)
and nothing else.
Top-level directory enumeration for the `du` path uses the same budget. A capacity error must not
fall through into the portable walker, because that would repeat an already rejected traversal.
@@ -169,7 +180,7 @@ The deterministic regression measurements are:
- peak simultaneous local `du` calls across repositories: exactly one;
- peak simultaneous desktop-side SSH fallback traversals across repositories: at most two;
- portable traversal live entry jobs: no more than its configured worker count;
- portable entries held at once: at most 100,000 per worktree;
- portable entries in any one directory listing: at most 100,000;
- estimated live portable state: at most 64 MiB per worktree; an ordinary 76,788-entry worktree
peaks between 4 MiB and 8 MiB;
- progress and final row counts remain unchanged for scans below the limits.
+17 -1
View File
@@ -208,6 +208,12 @@ export const electronViteConfig: UserConfig = {
// 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'),
// Why: forked with ELECTRON_RUN_AS_NODE so it survives a deadlocked
// main thread (macOS 26 AppKit scene-update deadlock) and can record
// the stall for the next launch to report.
'main-thread-hang-watchdog-entry': resolve(
'src/main/hang-watchdog/main-thread-hang-watchdog-entry.ts'
),
// Why: run under ELECTRON_RUN_AS_NODE while the caller blocks on
// spawnSync — codex app-server trust grants need a live event loop
// but must finish before a Codex pane launch proceeds.
@@ -218,7 +224,17 @@ export const electronViteConfig: UserConfig = {
// this path for `orca agent hooks ...`, so it must survive rebuilds.
'agent-hooks/managed-agent-hook-controls': resolve(
'src/main/agent-hooks/managed-agent-hook-controls.ts'
)
),
'ipc/local-agent-install-dir-detection': resolve(
'src/main/ipc/local-agent-install-dir-detection.ts'
),
'ipc/tui-agent-detection-commands': resolve(
'src/main/ipc/tui-agent-detection-commands.ts'
),
// Why: same rule — `orca account add` / `account list` import these.
'claude-accounts/keychain': resolve('src/main/claude-accounts/keychain.ts'),
'codex-cli/command': resolve('src/main/codex-cli/command.ts'),
'win32-utils': resolve('src/main/win32-utils.ts')
},
// Why: Rolldown's SSR default is ESM, but Electron and sidecar launchers
// consume these stable CommonJS paths.
+25
View File
@@ -0,0 +1,25 @@
import { defineConfig } from 'i18next-cli'
const output =
process.env.ORCA_I18N_EXTRACTION_OUTPUT ?? 'tmp/localization-extraction/{{language}}.json'
export default defineConfig({
locales: ['en'],
extract: {
input: ['src/**/*.{js,jsx,ts,tsx,mts,cts}'],
ignore: [
'**/*.test.*',
'**/*.spec.*',
'**/__tests__/**',
'**/__snapshots__/**',
'**/assets/**'
],
output,
defaultNS: false,
functions: ['t', '*.t', 'translate', 'translateMain'],
useTranslationNames: ['useTranslation'],
sort: true,
disablePlurals: true,
removeUnusedKeys: true
}
})
+18 -25
View File
@@ -15,12 +15,8 @@ import { useLocalSearchParams, useRouter } from 'expo-router'
import { ChevronLeft } from 'lucide-react-native'
import { colors, radii, spacing, typography } from '../../../src/theme/mobile-theme'
import { loadHosts, updateHostNameAndEndpoint } from '../../../src/transport/host-store'
import {
displayHostEndpoint,
endpointPort,
endpointScheme,
normalizeHostEndpoint
} from '../../../src/transport/host-endpoint'
import { displayHostEndpoint } from '../../../src/transport/host-endpoint'
import { resolveHostEndpointEdit } from '../../../src/transport/host-endpoint-edit'
import { useForceReconnect, usePrimeHosts } from '../../../src/transport/client-context'
import type { HostProfile } from '../../../src/transport/types'
@@ -68,27 +64,24 @@ export default function EditHostScreen() {
void load()
}, [load])
const fallbackPort = host ? endpointPort(host.endpoint) : undefined
const fallbackScheme = host ? endpointScheme(host.endpoint) : 'ws'
const normalizedEndpoint = useMemo(
() => normalizeHostEndpoint(address, { fallbackPort, fallbackScheme }),
[address, fallbackPort, fallbackScheme]
const endpointEdit = useMemo(
() => (host ? resolveHostEndpointEdit(host.endpoint, address) : null),
[address, host]
)
const nameTrimmed = name.trim()
const nameChanged = host != null && nameTrimmed.length > 0 && nameTrimmed !== host.name
const endpointChanged =
host != null && normalizedEndpoint.ok && normalizedEndpoint.endpoint !== host.endpoint
const endpointChanged = endpointEdit?.kind === 'changed'
const canSave =
host != null &&
endpointEdit != null &&
nameTrimmed.length > 0 &&
normalizedEndpoint.ok &&
endpointEdit.kind !== 'invalid' &&
(nameChanged || endpointChanged) &&
!saving
async function handleSave() {
if (!host || !hostId || savingRef.current) {
if (!host || !hostId || !endpointEdit || savingRef.current) {
return
}
const nextName = name.trim()
@@ -96,14 +89,14 @@ export default function EditHostScreen() {
setSaveError('Enter a name.')
return
}
if (!normalizedEndpoint.ok) {
setSaveError(normalizedEndpoint.error)
if (endpointEdit.kind === 'invalid') {
setSaveError(endpointEdit.error)
return
}
const willRename = nextName !== host.name
const willUpdateEndpoint = normalizedEndpoint.endpoint !== host.endpoint
if (!willRename && !willUpdateEndpoint) {
const nextEndpoint = endpointEdit.kind === 'changed' ? endpointEdit.endpoint : undefined
if (!willRename && nextEndpoint === undefined) {
router.back()
return
}
@@ -117,7 +110,7 @@ export default function EditHostScreen() {
// other, and a host removed mid-edit throws instead of no-oping.
await updateHostNameAndEndpoint(host.id, {
...(willRename ? { name: nextName } : {}),
...(willUpdateEndpoint ? { endpoint: normalizedEndpoint.endpoint } : {})
...(nextEndpoint !== undefined ? { endpoint: nextEndpoint } : {})
})
} catch (err) {
setSaveError(err instanceof Error ? err.message : 'Failed to save host.')
@@ -140,7 +133,7 @@ export default function EditHostScreen() {
setSaving(false)
router.back()
if (willUpdateEndpoint) {
if (nextEndpoint !== undefined) {
// Why: reconnect is a follow-on side effect of a save that already
// committed — its failure or a hang must not be reported as a save
// failure or block navigating back.
@@ -247,12 +240,12 @@ export default function EditHostScreen() {
(or 6768).
</Text>
{normalizedEndpoint.ok ? (
{endpointEdit == null ? null : endpointEdit.kind !== 'invalid' ? (
<Text style={styles.preview} numberOfLines={2}>
Connects to {normalizedEndpoint.endpoint}
Connects to {endpointEdit.endpoint}
</Text>
) : address.trim().length > 0 ? (
<Text style={styles.previewError}>{normalizedEndpoint.error}</Text>
<Text style={styles.previewError}>{endpointEdit.error}</Text>
) : null}
{saveError ? <Text style={styles.errorText}>{saveError}</Text> : null}
+29 -30
View File
@@ -198,6 +198,11 @@ import {
confirmsMirroredTabSelection,
type AppliedSnapshotMarker
} from '../../../../src/session/session-tab-snapshot-gate'
import {
createInitialSessionAutoCreateState,
useInitialSessionTerminalAutoCreate,
useWorktreeSessionTabsLoaded
} from '../../../../src/session/use-initial-session-terminal-autocreate'
import {
buildMarkdownDiskFallbackDoc,
shouldReadMarkdownFromDiskAfterReadTabFailure
@@ -239,6 +244,7 @@ import {
getActiveTabIdForHandle,
isFileExistsErrorMessage,
isGestureMouseTrackingMode,
isTerminalPhoneDisplayMode,
MOBILE_SESSION_STATUS_LABELS,
TERMINAL_GESTURE_INPUT_BUCKET_CAPACITY,
TERMINAL_GESTURE_INPUT_FLUSH_DELAY_MS,
@@ -890,7 +896,7 @@ export default function SessionScreen() {
const appliedSessionTabsRevisionRef = useRef(0)
// Why: after an optimistic close, suppress the tab (with expiry) until the publisher confirms, so an in-flight snapshot can't flash it back.
const closedTabTombstonesRef = useRef<Map<string, number>>(new Map())
const [terminalsLoaded, setTerminalsLoaded] = useState(false)
const [terminalsLoaded, setTerminalsLoaded] = useWorktreeSessionTabsLoaded(worktreeId)
const [input, setInput] = useState('')
// Why: baseline terminal zoom reloaded on focus so a Settings → Terminal change applies in place (panes stay mounted).
const [terminalTextScale, setTerminalTextScale] = useState(1)
@@ -1032,7 +1038,7 @@ export default function SessionScreen() {
// Why: route the terminal URL tap through a ref so it runs the current handleCreateBrowser closure (the memoized one may hold a null-client render).
const handleCreateBrowserRef = useRef<((rawUrl?: string) => Promise<boolean>) | null>(null)
const initialEmptySessionAutoCreateRef = useRef<string | null>(null)
const initialSessionAutoCreateRef = useRef(createInitialSessionAutoCreateState())
const markdownSaveSeqRef = useRef<Map<string, number>>(new Map())
const markdownSaveInFlightRef = useRef<Set<string>>(new Set())
const subscribeSeqRef = useRef<Map<string, number>>(new Map())
@@ -1774,6 +1780,7 @@ export default function SessionScreen() {
nextTabs = [...orphanedDraftTabs, ...nextTabs]
}
sessionTabsRef.current = nextTabs
initialSessionAutoCreateRef.current.sawSessionTabs ||= nextTabs.length > 0
// Why: subscribe snapshots often repeat identical payloads; skip re-set to avoid a subscription teardown/replay loop.
setSessionTabs((prev) => (mobileSessionTabsEqual(prev, nextTabs) ? prev : nextTabs))
const terminalTabs = getTerminalRecordsFromSessionTabs(nextTabs)
@@ -2632,7 +2639,7 @@ export default function SessionScreen() {
pendingActiveTerminalHandleRef.current = null
pendingBrowserFocusPageIdRef.current = null
pendingTerminalActivationAttemptRef.current = null
initialEmptySessionAutoCreateRef.current = null
initialSessionAutoCreateRef.current = createInitialSessionAutoCreateState()
terminalDiagnosticsRef.current.resetRoute()
appliedSnapshotMarkerRef.current = { epoch: null, version: -1 }
closedTabTombstonesRef.current.clear()
@@ -3675,6 +3682,7 @@ export default function SessionScreen() {
getActiveWorktreeConnectionId,
beforeTerminalSend: flushPendingLiveInputBeforeAttachmentSend,
nativeChatBaseSend: nativeChatController.handleNativeChatSendWithOutcome,
readSeededLaunchDraft: nativeChatController.readSeededLaunchDraft,
showToast,
onNativeChatSendError: nativeChatSendError.show,
onSuccess: triggerSelection,
@@ -4160,14 +4168,6 @@ export default function SessionScreen() {
})
const closeWithBulkActions = createCloseWithBulkActions(handleCloseSessionTab, bulkCloseActions)
const isPhoneMode = (handle: string | null): boolean => {
if (!handle) {
return false
}
const mode = terminalModes.get(handle)
return mode === 'auto' || mode === 'phone' || mode === undefined
}
const visibleTabs: MobileSessionTab[] = sessionTabs
const activeMarkdownTab = activeSessionTab?.type === 'markdown' ? activeSessionTab : null
const activeFileTab = activeSessionTab?.type === 'file' ? activeSessionTab : null
@@ -4227,22 +4227,20 @@ export default function SessionScreen() {
const showEmptyState =
connState === 'connected' && terminalsLoaded && visibleTabs.length === 0 && !activeHandle
useEffect(() => {
if (
!client ||
!showEmptyState ||
creating ||
creatingBrowser ||
creatingMarkdown ||
initialEmptySessionAutoCreateRef.current === worktreeId
) {
return
}
// Why: a sleeping/new workspace can hydrate with zero tabs; create the first terminal once so mobile isn't blank.
initialEmptySessionAutoCreateRef.current = worktreeId
setCreateError('')
void handleCreateTerminal()
}, [client, creating, creatingBrowser, creatingMarkdown, showEmptyState, worktreeId])
// Why: a newly created workspace can hydrate with zero tabs before its first terminal exists.
useInitialSessionTerminalAutoCreate({
client,
newlyCreatedWorkspace: created === '1',
connState,
terminalsLoaded,
visibleTabCount: visibleTabs.length,
activeHandle,
createInFlight: creating || creatingBrowser || creatingMarkdown,
stateRef: initialSessionAutoCreateRef,
worktreeId,
consumeCreationRoute: () => router.setParams({ created: undefined }),
createTerminal: () => void handleCreateTerminal()
})
// Why: reconnect trickles to 90s at its give-up cap; surface tap-to-retry so recovery needn't wait it out (issue #5049).
const connectionVerdict = classifyConnection({
@@ -4827,12 +4825,12 @@ export default function SessionScreen() {
}
}}
accessibilityLabel={
isPhoneMode(activeHandle)
isTerminalPhoneDisplayMode(activeHandle, terminalModes)
? 'Switch to desktop mode'
: 'Switch to phone mode'
}
>
{isPhoneMode(activeHandle) ? (
{isTerminalPhoneDisplayMode(activeHandle, terminalModes) ? (
<Monitor
size={14}
color={canSend ? colors.textSecondary : colors.textMuted}
@@ -5211,11 +5209,12 @@ export default function SessionScreen() {
nativeChatTranscriptIsLocalReadable,
onDismiss: () => setActionTarget(null),
onToggleChat: toggleTabChatView,
isPhoneMode,
isPhoneMode: (handle) => isTerminalPhoneDisplayMode(handle, terminalModes),
onToggleDisplayMode: (handle) => void toggleDisplayMode(handle),
onRename: setRenameTarget,
onClear: (target) => void handleClearTerminal(target),
onClose: (target) => void handleCloseTerminal(target),
onCloseSessionTab: (tab) => void handleCloseSessionTab(tab),
bulkCloseActions
})}
onClose={() => setActionTarget(null)}
@@ -28,6 +28,7 @@ export type MobileSessionTab =
launchAgent?: TuiAgent
/** Host-provided launch context still parked as an unsent TUI-input draft. */
launchDraft?: string
launchDraftCreatedAt?: number
terminalTheme?: MobileTerminalTheme
isActive: boolean
}
+88 -97
View File
@@ -60,6 +60,13 @@ import {
resolveMobileSyntaxLanguage
} from '../../../src/session/mobile-file-syntax'
import { buildGitHubCheckSummary } from '../../../src/tasks/github-check-summary'
import { buildGitLabCheckSummary } from '../../../src/tasks/gitlab-check-summary'
import {
getHostedMergeLabel,
getHostedReviewLabel,
getHostedReviewSignalTone,
getHostedChecksLabel
} from '../../../src/tasks/mobile-hosted-check-status'
import { buildTaskWorkspaceCreateParams } from '../../../src/tasks/workspace-create-params'
import { MOBILE_TASKS_CAPABILITY } from '../../../src/tasks/mobile-tasks-capability'
import {
@@ -138,11 +145,13 @@ import {
import type {
BaseRefSearchResult,
GitHubOwnerRepo,
ProviderCheckSummary,
PersistedTrustedOrcaHooks,
SparsePreset,
TuiAgent
} from '../../../../src/shared/types'
import type { SshConnectionState } from '../../../../src/shared/ssh-types'
import type { HostedReviewDecision } from '../../../../src/shared/hosted-review'
import {
githubProjectHost,
githubProjectIdentityKey as githubProjectKey
@@ -181,31 +190,20 @@ type GitHubWorkItem = {
reviewDecision?: string | null
reviewRequests?: GitHubAssignableUser[]
latestReviews?: GitHubPRReviewSummary[]
checksSummary?: GitHubPRCheckSummary
checksSummary?: ProviderCheckSummary
mergeable?: GitHubPRMergeableState
mergeStateStatus?: string | null
}
type GitHubAssignableUser = {
login: string
name?: string | null
avatarUrl?: string | null
}
type GitHubPRReviewSummary = {
login: string
state?: string | null
avatarUrl?: string | null
}
type GitHubPRCheckSummary = {
state: 'success' | 'failure' | 'pending' | 'none'
total: number
passed: number
failed: number
pending: number
}
type GitHubPRMergeableState = 'MERGEABLE' | 'CONFLICTING' | 'UNKNOWN'
type GitHubPRReviewerRow = {
@@ -214,13 +212,11 @@ type GitHubPRReviewerRow = {
avatarUrl?: string | null
stateLabel: string
}
type GitHubRepoSources = {
issues: GitHubOwnerRepo | null
prs: GitHubOwnerRepo | null
upstreamCandidate: GitHubOwnerRepo | null
}
type TaskRuntimeStatus = {
capabilities?: string[]
}
@@ -229,7 +225,6 @@ type TasksSupportState =
| { kind: 'unknown'; client: RpcClient | null }
| { kind: 'supported'; client: RpcClient }
| { kind: 'unsupported'; client: RpcClient }
type GitLabWorkItem = {
id: string
type: 'issue' | 'mr'
@@ -244,6 +239,10 @@ type GitLabWorkItem = {
baseRefName?: string
isCrossRepository?: boolean
projectRef?: { host: string; path: string }
checksSummary?: ProviderCheckSummary
mergeable?: 'MERGEABLE' | 'CONFLICTING' | 'UNKNOWN'
reviewDecision?: HostedReviewDecision
reviewerCount?: number
repoId: string
repoName: string
}
@@ -1262,23 +1261,6 @@ function hostedBranchSummary(item: TaskItem): { head: string; base: string } | n
return null
}
function getGitHubChecksLabel(item: GitHubWorkItem): string {
const summary = item.checksSummary
if (!summary) {
return 'Checks'
}
if (summary.total === 0) {
return 'No checks'
}
if (summary.failed > 0) {
return `${summary.failed} failing`
}
if (summary.pending > 0) {
return `${summary.pending} pending`
}
return `${summary.passed}/${summary.total} passed`
}
function getGitHubMergeLabel(item: GitHubWorkItem): string {
if (item.mergeable === undefined && item.mergeStateStatus === undefined) {
return 'Merge'
@@ -1382,46 +1364,6 @@ function getHostedStateConfirmLabel(pending: PendingHostedStateChange): string {
return `${hostedStateChangeAction(pending.nextState)} ${target.labelTarget}`
}
function getGitHubPRSignalTone(
item: GitHubWorkItem,
signal: 'review' | 'checks' | 'merge'
): 'neutral' | 'success' | 'warning' | 'danger' {
if (signal === 'review') {
if (item.reviewDecision === 'APPROVED') {
return 'success'
}
if (item.reviewDecision === 'CHANGES_REQUESTED') {
return 'danger'
}
if (item.reviewRequests && item.reviewRequests.length > 0) {
return 'warning'
}
return 'neutral'
}
if (signal === 'checks') {
if (item.checksSummary?.state === 'success') {
return 'success'
}
if (item.checksSummary?.state === 'failure') {
return 'danger'
}
if (item.checksSummary?.state === 'pending') {
return 'warning'
}
return 'neutral'
}
if (item.mergeable === 'CONFLICTING' || item.mergeStateStatus === 'BLOCKED') {
return 'danger'
}
if (item.mergeStateStatus === 'BEHIND' || item.checksSummary?.state === 'pending') {
return 'warning'
}
if (item.mergeable === 'MERGEABLE' || item.mergeStateStatus === 'CLEAN') {
return 'success'
}
return 'neutral'
}
function mergeGitHubAssignableUsers(
users: GitHubAssignableUser[],
seeds: GitHubAssignableUser[]
@@ -4345,7 +4287,7 @@ export default function MobileTasksScreen() {
const details = response.result as {
body?: string
comments?: DetailComment[]
item?: { labels?: string[] }
item?: { labels?: string[]; mergeable?: 'MERGEABLE' | 'CONFLICTING' | 'UNKNOWN' }
assignees?: string[]
pipelineJobs?: Array<{
id?: number
@@ -4355,6 +4297,8 @@ export default function MobileTasksScreen() {
webUrl?: string | null
duration?: number | null
}>
reviewers?: unknown[]
approvalState?: { approvalsRequired: number | null; approvalsLeft: number | null }
} | null
if (!details) {
throw new Error('Details not found')
@@ -4368,6 +4312,44 @@ export default function MobileTasksScreen() {
assignees: details.assignees ?? [],
pipelineJobs: details.pipelineJobs ?? []
})
const checksSummary = buildGitLabCheckSummary(details.pipelineJobs ?? [])
const reviewDecision: Exclude<HostedReviewDecision, null> | undefined =
details.approvalState?.approvalsRequired && details.approvalState.approvalsLeft === 0
? 'approved'
: details.approvalState?.approvalsLeft && details.approvalState.approvalsLeft > 0
? 'review_required'
: undefined
const hydratedStatus = {
...(details.item?.mergeable !== undefined ? { mergeable: details.item.mergeable } : {}),
...(reviewDecision !== undefined ? { reviewDecision } : {}),
...(details.reviewers !== undefined ? { reviewerCount: details.reviewers.length } : {})
}
setActionItem((current) =>
current?.provider === 'gitlab' && current.source.id === actionItem.source.id
? {
...current,
source: {
...current.source,
checksSummary,
...hydratedStatus
}
}
: current
)
setItems((current) =>
current.map((candidate) =>
candidate.provider === 'gitlab' && candidate.source.id === actionItem.source.id
? {
...candidate,
source: {
...candidate.source,
checksSummary,
...hydratedStatus
}
}
: candidate
)
)
}
return
}
@@ -9689,6 +9671,7 @@ export default function MobileTasksScreen() {
const item = entry.item
const repo = taskRepositoryMeta(item, reposById)
const isGitHubPr = item.provider === 'github' && item.source.type === 'pr'
const isGitLabMr = item.provider === 'gitlab' && item.source.type === 'mr'
const githubPrDelta = isGitHubPr ? formatGitHubPRDelta(item.source) : null
const branchSummary = hostedBranchSummary(item)
return (
@@ -9734,45 +9717,53 @@ export default function MobileTasksScreen() {
</Text>
</View>
) : null}
{isGitHubPr ? (
{isGitHubPr || isGitLabMr ? (
<View style={styles.prSignalRow}>
{githubPrDelta ? (
{isGitHubPr && githubPrDelta ? (
<View style={styles.prSignalChip}>
<Text style={styles.prSignalText} numberOfLines={1}>
{githubPrDelta}
</Text>
</View>
) : null}
{isGitHubPr || isGitLabMr ? (
<View
style={[
styles.prSignalChip,
getPrSignalToneStyle(getHostedReviewSignalTone(item.source, 'review'))
]}
>
<Text style={styles.prSignalText} numberOfLines={1}>
{isGitHubPr
? getGitHubReviewSummary(item.source)
: getHostedReviewLabel(item.source)}
</Text>
</View>
) : null}
<View
style={[
styles.prSignalChip,
getPrSignalToneStyle(getGitHubPRSignalTone(item.source, 'review'))
getPrSignalToneStyle(getHostedReviewSignalTone(item.source, 'checks'))
]}
>
<Text style={styles.prSignalText} numberOfLines={1}>
{getGitHubReviewSummary(item.source)}
</Text>
</View>
<View
style={[
styles.prSignalChip,
getPrSignalToneStyle(getGitHubPRSignalTone(item.source, 'checks'))
]}
>
<Text style={styles.prSignalText} numberOfLines={1}>
{getGitHubChecksLabel(item.source)}
</Text>
</View>
<View
style={[
styles.prSignalChip,
getPrSignalToneStyle(getGitHubPRSignalTone(item.source, 'merge'))
]}
>
<Text style={styles.prSignalText} numberOfLines={1}>
{getGitHubMergeLabel(item.source)}
{getHostedChecksLabel(item.source)}
</Text>
</View>
{isGitHubPr || isGitLabMr ? (
<View
style={[
styles.prSignalChip,
getPrSignalToneStyle(getHostedReviewSignalTone(item.source, 'merge'))
]}
>
<Text style={styles.prSignalText} numberOfLines={1}>
{isGitHubPr
? getGitHubMergeLabel(item.source)
: getHostedMergeLabel(item.source)}
</Text>
</View>
) : null}
</View>
) : null}
</View>
+1
View File
@@ -72,6 +72,7 @@
"acorn": "8.15.0",
"esbuild": "0.25.4",
"expo-module-scripts": "^55.0.2",
"happy-dom": "^20.9.0",
"oxfmt": "^0.52.0",
"oxlint": "^1.71.0",
"react-test-renderer": "19.2.6",
+42 -2
View File
@@ -168,6 +168,9 @@ importers:
expo-module-scripts:
specifier: ^55.0.2
version: 55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(eslint@9.39.4)(expo@55.0.27)(jest@29.7.0(@types/node@26.1.1))(prettier@2.8.8)(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-refresh@0.14.2)(react-test-renderer@19.2.6(react@19.2.6))(react@19.2.6)
happy-dom:
specifier: ^20.9.0
version: 20.11.1
oxfmt:
specifier: ^0.52.0
version: 0.52.0
@@ -188,7 +191,7 @@ importers:
version: 8.1.0(@types/node@26.1.1)(esbuild@0.25.4)(terser@5.49.0)(tsx@4.22.4)(yaml@2.9.0)
vitest:
specifier: ^4.1.9
version: 4.1.9(@types/node@26.1.1)(jsdom@20.0.3)(vite@8.1.0(@types/node@26.1.1)(esbuild@0.25.4)(terser@5.49.0)(tsx@4.22.4)(yaml@2.9.0))
version: 4.1.9(@types/node@26.1.1)(happy-dom@20.11.1)(jsdom@20.0.3)(vite@8.1.0(@types/node@26.1.1)(esbuild@0.25.4)(terser@5.49.0)(tsx@4.22.4)(yaml@2.9.0))
packages:
@@ -2741,6 +2744,9 @@ packages:
'@types/unist@3.0.3':
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
'@types/whatwg-mimetype@3.0.2':
resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==}
'@types/ws@8.18.1':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
@@ -3177,6 +3183,10 @@ packages:
buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
buffer-image-size@0.6.4:
resolution: {integrity: sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==}
engines: {node: '>=4.0'}
buffer@6.0.3:
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
@@ -3556,6 +3566,10 @@ packages:
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
engines: {node: '>=0.12'}
entities@7.0.1:
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
engines: {node: '>=0.12'}
error-ex@1.3.4:
resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==}
@@ -4286,6 +4300,10 @@ packages:
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
happy-dom@20.11.1:
resolution: {integrity: sha512-XSt8tMzbW9ymE7687xztkO1ckR7qJNQ3LywY9vlYGhGi3zXrGBHuUo2Cl1ztZaICW+1eAGdkLbj6iwVqDT33kg==}
engines: {node: '>=20.0.0'}
has-bigints@1.1.0:
resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==}
engines: {node: '>= 0.4'}
@@ -9661,6 +9679,8 @@ snapshots:
'@types/unist@3.0.3': {}
'@types/whatwg-mimetype@3.0.2': {}
'@types/ws@8.18.1':
dependencies:
'@types/node': 25.6.0
@@ -10236,6 +10256,10 @@ snapshots:
buffer-from@1.1.2: {}
buffer-image-size@0.6.4:
dependencies:
'@types/node': 26.1.1
buffer@6.0.3:
dependencies:
base64-js: 1.5.1
@@ -10611,6 +10635,8 @@ snapshots:
entities@6.0.1: {}
entities@7.0.1: {}
error-ex@1.3.4:
dependencies:
is-arrayish: 0.2.1
@@ -11614,6 +11640,19 @@ snapshots:
graceful-fs@4.2.11: {}
happy-dom@20.11.1:
dependencies:
'@types/node': 26.1.1
'@types/whatwg-mimetype': 3.0.2
'@types/ws': 8.18.1
buffer-image-size: 0.6.4
entities: 7.0.1
whatwg-mimetype: 3.0.0
ws: 8.21.0
transitivePeerDependencies:
- bufferutil
- utf-8-validate
has-bigints@1.1.0: {}
has-flag@3.0.0: {}
@@ -14235,7 +14274,7 @@ snapshots:
tsx: 4.22.4
yaml: 2.9.0
vitest@4.1.9(@types/node@26.1.1)(jsdom@20.0.3)(vite@8.1.0(@types/node@26.1.1)(esbuild@0.25.4)(terser@5.49.0)(tsx@4.22.4)(yaml@2.9.0)):
vitest@4.1.9(@types/node@26.1.1)(happy-dom@20.11.1)(jsdom@20.0.3)(vite@8.1.0(@types/node@26.1.1)(esbuild@0.25.4)(terser@5.49.0)(tsx@4.22.4)(yaml@2.9.0)):
dependencies:
'@vitest/expect': 4.1.9
'@vitest/mocker': 4.1.9(vite@8.1.0(@types/node@26.1.1)(esbuild@0.25.4)(terser@5.49.0)(tsx@4.22.4)(yaml@2.9.0))
@@ -14259,6 +14298,7 @@ snapshots:
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 26.1.1
happy-dom: 20.11.1
jsdom: 20.0.3
transitivePeerDependencies:
- msw
+3 -1
View File
@@ -12,6 +12,7 @@ import { handleMockFilePreviewRequest } from './mock-server-file-preview-data'
import { handleMockGitRequest } from './mock-server-git-state'
import { handleMockAccountRequest } from './mock-server-account-rpc'
import { handleMockNativeChatRequest } from './mock-server-native-chat-scenario'
import { handleMockSessionTabsRequest } from './mock-server-session-tabs-fixture'
import {
createMockTerminals,
FAKE_SCROLLBACK,
@@ -124,7 +125,8 @@ export function handleRequest(
handleMockGitRequest(request, respond, success) ||
handleMockFilePreviewRequest(request, respond, success, error) ||
handleMockAccountRequest(request, respond, success, error) ||
handleMockNativeChatRequest(request, respond, success, error, ws)
handleMockNativeChatRequest(request, respond, success, error, ws) ||
handleMockSessionTabsRequest(request, respond, success, terminalListWorktreeId)
) {
return
}
@@ -0,0 +1,69 @@
import { randomUUID } from 'node:crypto'
import type { RuntimeMobileSessionTabsResult } from '../../src/shared/runtime-types'
import type { RpcRequest, RpcResponse } from './mock-server-rpc-handlers'
// Why: the client's snapshot-acceptance gate keys on the publisher epoch, so it
// must stay stable for the process and change on restart like a real publisher —
// hence a uuid, not a clock read two restarts could land on.
// The `mobile-local:` prefix is reserved for phone-local writes — never use it.
const PUBLICATION_EPOCH = `mock-server:${randomUUID()}`
const GROUP_ID = 'group-1'
const PARENT_TAB_ID = 'tab-1'
// The host only ever publishes terminal-layout UUIDs here; pane-key parsing
// rejects any other shape, so a placeholder would mask pane-attribution bugs.
const LEAF_ID = 'f47ac10b-58cc-4372-a567-0e02b2c3d479'
// The host publishes terminal surfaces as `${parentTabId}::${leafId}`.
const SURFACE_TAB_ID = `${PARENT_TAB_ID}::${LEAF_ID}`
/** One ready terminal tab bound to the `term-1` fixture. Mirrors the full
* `session.tabs.list` contract so mock-server repros of tab, split-pane, and
* pane-attribution bugs aren't shape-incomplete. */
function createMockSessionTabs(worktreeId: string): RuntimeMobileSessionTabsResult {
return {
worktree: worktreeId,
publicationEpoch: PUBLICATION_EPOCH,
snapshotVersion: 1,
activeGroupId: GROUP_ID,
activeTabId: SURFACE_TAB_ID,
activeTabType: 'terminal',
// Groups track top-level tabs, so they carry parentTabId, not surface ids.
tabGroups: [
{
id: GROUP_ID,
activeTabId: PARENT_TAB_ID,
tabOrder: [PARENT_TAB_ID],
recentTabIds: [PARENT_TAB_ID]
}
],
tabs: [
{
type: 'terminal',
id: SURFACE_TAB_ID,
title: 'zsh',
parentTabId: PARENT_TAB_ID,
leafId: LEAF_ID,
status: 'ready',
terminal: 'term-1',
isActive: true
}
]
}
}
/** Default session-tabs backend: without it the session screen hangs on
* 'Loading tabs'. Returns false for methods it does not own. */
export function handleMockSessionTabsRequest(
request: RpcRequest,
respond: (response: RpcResponse) => void,
success: (id: string, result: unknown, streaming?: boolean) => RpcResponse,
// Shared with `terminal.list` so both surfaces agree on which worktree an
// absent or `id:`-prefixed selector means.
resolveWorktreeId: (selector: unknown) => string | undefined
): boolean {
if (request.method !== 'session.tabs.list') {
return false
}
const worktreeId = resolveWorktreeId(request.params?.worktree) ?? 'mock'
respond(success(request.id, createMockSessionTabs(worktreeId)))
return true
}
@@ -10,15 +10,17 @@ import type { RpcClient } from '../transport/rpc-client'
import { getWorktreeLabel } from '../session/worktree-label'
import {
buildMobileAiVaultResumeLaunch,
prepareMobileAiVaultSessionResume,
createMobileAiVaultResumeMutationRegistry,
readMobileRuntimeHostPlatform,
readMobileRuntimeTerminalWindowsShell,
resolveMobileAiVaultResumePlatform,
resumeAiVaultSessionInTerminal,
RESUME_RPC_TIMEOUT_MS,
type MobileAiVaultResumeSettings
} from '../session/ai-vault-resume-launch'
import {
prepareMobileAiVaultSessionResume,
RESUME_RPC_TIMEOUT_MS
} from '../session/ai-vault-resume-preparation'
import { triggerError, triggerSuccess } from '../platform/haptics'
import type { AiVaultScope, AiVaultSession } from '../../../src/shared/ai-vault-types'
import type { Worktree } from '../worktree/workspace-list-types'
+33 -5
View File
@@ -1,4 +1,4 @@
import { type ReactNode, useState } from 'react'
import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react'
import { resolveBottomDrawerMounted } from './bottom-drawer-mount-state'
import { MountedBottomDrawer } from './mounted-bottom-drawer'
@@ -31,6 +31,37 @@ export function BottomDrawer({
zIndex
}: Props) {
const [mounted, setMounted] = useState(visible)
const onAfterCloseRef = useRef(onAfterClose)
const hiddenHandledRef = useRef(false)
const afterClosePendingRef = useRef(false)
useEffect(() => {
onAfterCloseRef.current = onAfterClose
}, [onAfterClose])
useEffect(() => {
if (visible) {
hiddenHandledRef.current = false
afterClosePendingRef.current = false
}
}, [visible])
useEffect(() => {
if (mounted || !afterClosePendingRef.current) {
return
}
afterClosePendingRef.current = false
onAfterCloseRef.current?.()
}, [mounted])
const handleHidden = useCallback(() => {
if (hiddenHandledRef.current) {
return
}
hiddenHandledRef.current = true
afterClosePendingRef.current = true
setMounted(false)
}, [])
const resolvedMounted = resolveBottomDrawerMounted(visible, mounted)
// Why: opening drawers should mount before commit; waiting for a passive
@@ -49,10 +80,7 @@ export function BottomDrawer({
<MountedBottomDrawer
visible={visible}
onClose={onClose}
onHidden={() => {
setMounted(false)
onAfterClose?.()
}}
onHidden={handleHidden}
dragContentToDismiss={dragContentToDismiss}
contentScrollable={contentScrollable}
fillAvailable={fillAvailable}
@@ -0,0 +1,103 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { BottomDrawer } from './BottomDrawer'
vi.mock('./mounted-bottom-drawer', () => ({
MountedBottomDrawer: 'MountedBottomDrawer'
}))
function renderDrawer(
visible: boolean,
onClose: () => void,
onAfterClose: () => void
): ReactTestRenderer {
let renderer: ReactTestRenderer | null = null
act(() => {
renderer = create(
createElement(
BottomDrawer,
{ visible, onClose, onAfterClose },
createElement('DrawerContent')
)
)
})
if (!renderer) {
throw new Error('Bottom drawer did not render')
}
return renderer
}
function updateDrawer(
renderer: ReactTestRenderer,
visible: boolean,
onClose: () => void,
onAfterClose: () => void
): void {
act(() => {
renderer.update(
createElement(
BottomDrawer,
{ visible, onClose, onAfterClose },
createElement('DrawerContent')
)
)
})
}
function mountedDrawer(renderer: ReactTestRenderer) {
return renderer.root.findByType('MountedBottomDrawer')
}
describe('BottomDrawer close lifecycle', () => {
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
const originalConsoleError = console.error
vi.spyOn(console, 'error').mockImplementation((...args) => {
const message = args[0]
if (
typeof message === 'string' &&
(message.includes('react-test-renderer is deprecated') ||
message.includes('The current testing environment is not configured to support act'))
) {
return
}
originalConsoleError(...args)
})
})
afterEach(() => {
vi.restoreAllMocks()
})
it('keeps close stable and delivers the latest action once after unmount', () => {
const firstAfterClose = vi.fn()
const rendered: { current?: ReactTestRenderer } = {}
const latestAfterClose = vi.fn(() => {
expect(rendered.current?.toJSON()).toBeNull()
})
const renderer = renderDrawer(true, vi.fn(), firstAfterClose)
rendered.current = renderer
const initialOnHidden = mountedDrawer(renderer).props.onHidden
updateDrawer(renderer, false, vi.fn(), firstAfterClose)
const closingOnHidden = mountedDrawer(renderer).props.onHidden
updateDrawer(renderer, false, vi.fn(), latestAfterClose)
const rerenderedOnHidden = mountedDrawer(renderer).props.onHidden
expect(closingOnHidden).toBe(initialOnHidden)
expect(rerenderedOnHidden).toBe(initialOnHidden)
act(() => {
rerenderedOnHidden()
rerenderedOnHidden()
})
act(() => {
rerenderedOnHidden()
})
expect(firstAfterClose).not.toHaveBeenCalled()
expect(latestAfterClose).toHaveBeenCalledTimes(1)
expect(renderer.toJSON()).toBeNull()
})
})
+5
View File
@@ -149,8 +149,13 @@ describe('edit host handleSave', () => {
})
it('rename-only save updates only the name and does not reconnect', async () => {
const storedEndpoint = 'wss://Desk.Example.com/%6Fruntime?route=%72ed'
dependencies.loadHosts.mockResolvedValueOnce([{ ...HOST_FIXTURE, endpoint: storedEndpoint }])
const renderer = await renderEditHostRoute()
setFieldValue(renderer, 'Address', ' wss://%64esk.example.com:443 ')
setFieldValue(renderer, 'Name', 'Home Desk')
expect(findText(renderer, `Connects to ${storedEndpoint}`)).toBe(true)
await pressSave(renderer)
expect(dependencies.updateHostNameAndEndpoint).toHaveBeenCalledWith('host-1', {
@@ -0,0 +1,77 @@
import { describe, expect, it } from 'vitest'
import type { WebSocket } from 'ws'
import {
handleRequest,
type RpcRequest,
type RpcResponse
} from '../scripts/mock-server-rpc-handlers'
function callRpc(method: string, params?: Record<string, unknown>): RpcResponse {
let response: RpcResponse | undefined
const request: RpcRequest = { id: 'request-1', method, ...(params ? { params } : {}) }
handleRequest(
request,
(nextResponse) => {
response = nextResponse
},
{} as WebSocket
)
expect(response).toBeDefined()
return response!
}
function listSessionTabs(worktree: string): RpcResponse {
return callRpc('session.tabs.list', { worktree })
}
describe('mock server session tabs fixture', () => {
it('returns a contract-complete terminal surface for the requested worktree', () => {
const response = listSessionTabs('id:repo-1::worktree-1')
expect(response.result).toEqual({
worktree: 'repo-1::worktree-1',
publicationEpoch: expect.stringMatching(/^mock-server:/),
snapshotVersion: 1,
activeGroupId: 'group-1',
activeTabId: 'tab-1::f47ac10b-58cc-4372-a567-0e02b2c3d479',
activeTabType: 'terminal',
tabGroups: [
{
id: 'group-1',
activeTabId: 'tab-1',
tabOrder: ['tab-1'],
recentTabIds: ['tab-1']
}
],
tabs: [
{
type: 'terminal',
id: 'tab-1::f47ac10b-58cc-4372-a567-0e02b2c3d479',
title: 'zsh',
parentTabId: 'tab-1',
leafId: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
status: 'ready',
terminal: 'term-1',
isActive: true
}
]
})
})
it('passes a bare worktree selector through unprefixed', () => {
const response = listSessionTabs('repo-1::worktree-1')
expect((response.result as { worktree: string }).worktree).toBe('repo-1::worktree-1')
})
it('falls back to the same worktree terminal.list uses when no selector is sent', () => {
const terminals = callRpc('terminal.list').result as {
terminals: { worktreeId: string }[]
}
const expected = terminals.terminals[0]?.worktreeId
expect(expected).toBeTruthy()
const tabs = callRpc('session.tabs.list').result as { worktree: string }
expect(tabs.worktree).toBe(expected)
})
})
@@ -15,9 +15,12 @@ import {
} from './local-notification-scheduling'
import {
adoptNotificationEpoch,
catchUpWatermarkSeq,
enqueueHostDelivery,
getHostNotificationSession,
quarantineCatchUpWatermark,
releaseQueuedShowNotificationId,
resolveCatchUpQuarantine,
saveWatermark,
seedWatermarkFromStorage,
seenKeyForEvent,
@@ -68,7 +71,10 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin
releaseQueuedShowNotificationId(session, event.notificationId)
}
}
})
// Why swallowed: the caller is an un-awaited handler, so a rejected show would
// surface as an unhandled rejection (a RN redbox) instead of being retried by
// the next catch-up — which is now possible, since `seen` is marked after the show.
}).catch(() => {})
}
async function deliverLive(
@@ -76,87 +82,127 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin
event: NotificationEvent | DismissNotificationEvent
): Promise<void> {
adoptNotificationEpoch(session, hostId, event.notificationEpoch)
// Why (#8129): mark seen on the live path too, so a later replay of an already-pushed id dedups instead of double-pushing.
const key = seenKeyForEvent(event)
if (key) {
session.seen.add(key)
}
const epochAtDelivery = session.lastDeliveredEpoch
if (type === 'notification') {
await showLocalNotification(event as NotificationEvent, hostId)
} else {
await dismissLocalNotification(event as DismissNotificationEvent, hostId)
}
// Why after the await, exactly like the watermark below: `seen` asserts this event
// reached the user (#8129). Marked before, a rejected show leaves the key behind and
// every later replay is dropped as a duplicate — loss the quarantine cannot recover,
// since the first event to drain a batch lifts it past the one never shown.
const key = seenKeyForEvent(event)
// A mid-flight epoch adoption already cleared the counter lifetime this key indexes.
if (key && session.lastDeliveredEpoch === epochAtDelivery) {
session.seen.add(key)
}
// Why after the await (#8591): the watermark is a promise that everything up
// to this seq has been shown. Advancing it before the local notification lands
// means a process death in between silently drops it — the next launch asks the
// desktop for seq greater than one the user never saw.
if (event.notificationSeq != null && event.notificationSeq > session.lastDeliveredSeq) {
session.lastDeliveredSeq = event.notificationSeq
// Persisted as a pair: a seq is only trustworthy alongside the epoch it indexes.
// Why clamped: while a failed catch-up's range is still unrecovered, persisting
// the live seq would let the next catch-up ask from above the gap and the desktop
// would cut it. resolveCatchUpQuarantine writes the held-back value on success.
void saveWatermark(hostId, {
seq: session.lastDeliveredSeq,
seq: catchUpWatermarkSeq(session),
epoch: session.lastDeliveredEpoch
})
}
}
// Claimed inline rather than via queueDelivery: the batch is already one queue
// entry, and re-enqueueing per item is what let a live event cut in.
async function deliverMissedEvent(
event: NotificationEvent | DismissNotificationEvent
): Promise<void> {
// No pre-marking here either: deliverLive marks the key once the show lands.
const key = seenKeyForEvent(event)
if (key && session.seen.has(key)) {
return
}
if (event.type === 'notification') {
if (!shouldQueueShowForNotificationId(session, event.notificationId)) {
return
}
try {
await deliverLive('notification', event)
} finally {
releaseQueuedShowNotificationId(session, event.notificationId)
}
return
}
if (event.type === 'dismiss') {
await deliverLive('dismiss', event)
}
}
// Why: desktop cuts by seq > lastSeenSeq, so re-fetching from the watermark is idempotent (session.seen guards residual overlap).
async function fetchMissed(): Promise<void> {
if (disposed) {
return
}
// Captured before the request: everything at or below it is known delivered, so
// it is the floor the watermark falls back to if this catch-up never completes.
const askFrom = catchUpWatermarkSeq(session)
const missed = await client
.sendRequest('notifications.getMissedSince', {
lastSeenSeq: session.lastDeliveredSeq,
lastSeenSeq: askFrom,
// Why: sending the epoch lets the desktop reject a watermark from a counter
// it no longer has and return the whole retained buffer instead of nothing.
...(session.lastDeliveredEpoch != null ? { epoch: session.lastDeliveredEpoch } : {})
})
.then((response) => {
if (!response.ok) {
return []
return null
}
const result = response.result as { notifications?: unknown[]; epoch?: string } | undefined
adoptNotificationEpoch(session, hostId, result?.epoch)
return Array.isArray(result?.notifications) ? result.notifications : []
})
.catch(() => [])
.catch(() => null)
if (missed == null) {
// Why quarantine rather than retry: the range this catch-up abandoned stays
// unrecovered until SOME later one succeeds, and a live seq persisting past it
// meanwhile would make the desktop cut it forever.
quarantineCatchUpWatermark(session, hostId, askFrom)
return
}
// Why the whole batch is ONE queue entry (#8591): awaiting per event returns to
// the event loop between replays, so a live seq 11 slots into the chain between
// seq 6 and 7 and persists a watermark past a notification still unshown. Why the
// request stays OUTSIDE the queue: sendRequest waits up to 30s, and holding the
// chain for that would stall live delivery on a slow link.
await enqueueHostDelivery(session, async () => {
for (const raw of missed) {
// Re-checked per event: the batch can start before a teardown and still be
// draining after it, and a torn-down host must stop pushing.
if (disposed) {
return
}
const event = raw as NotificationEvent | DismissNotificationEvent
const key = seenKeyForEvent(event)
if (key && session.seen.has(key)) {
continue
}
if (key) {
session.seen.add(key)
}
// Claimed inline rather than via queueDelivery: the batch is already one
// queue entry, and re-enqueueing per item is what let a live event cut in.
if (event.type === 'notification') {
if (!shouldQueueShowForNotificationId(session, event.notificationId)) {
continue
// Advances only past events this batch settled, so a teardown or a failing show
// quarantines the true contiguous point instead of the range it never reached.
let contiguousSeq = askFrom
let drained = false
try {
for (const raw of missed) {
// Re-checked per event: the batch can start before a teardown and still be
// draining after it, and a torn-down host must stop pushing.
if (disposed) {
return
}
try {
await deliverLive('notification', event)
} finally {
releaseQueuedShowNotificationId(session, event.notificationId)
}
} else if (event.type === 'dismiss') {
await deliverLive('dismiss', event)
const event = raw as NotificationEvent | DismissNotificationEvent
await deliverMissedEvent(event)
contiguousSeq = event.notificationSeq ?? contiguousSeq
}
drained = true
} finally {
if (drained) {
resolveCatchUpQuarantine(session, hostId)
} else {
quarantineCatchUpWatermark(session, hostId, contiguousSeq)
}
}
})
// Why swallowed here: the `finally` above already recorded the contiguous point,
// and the only caller is an un-awaited 'ready' continuation — letting a failed
// show escape turns every one into an unhandled rejection (a RN redbox).
}).catch(() => {})
}
seedWatermarkFromStorage(session, hostId)
@@ -0,0 +1,316 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import * as Notifications from 'expo-notifications'
import { subscribeToDesktopNotifications } from './mobile-notifications'
import { resetHostNotificationSessionsForTests } from './notification-reconnect-catchup'
import type { RpcClient } from '../transport/rpc-client'
import { loadPushNotificationsEnabled } from '../storage/preferences'
vi.mock('expo-notifications', () => ({
AndroidImportance: { HIGH: 'high' },
setNotificationChannelAsync: vi.fn(),
getPermissionsAsync: vi.fn(),
requestPermissionsAsync: vi.fn(),
scheduleNotificationAsync: vi.fn(),
dismissNotificationAsync: vi.fn()
}))
vi.mock('react-native', () => ({
Platform: { OS: 'ios', Version: 18 }
}))
const WATERMARK_KEY = 'orca:mobileNotificationsWatermark:host-1'
const storage = new Map<string, string>()
vi.mock('@react-native-async-storage/async-storage', () => ({
default: {
getItem: vi.fn(async (key: string) => storage.get(key) ?? null),
setItem: vi.fn(async (key: string, value: string) => {
storage.set(key, value)
})
}
}))
vi.mock('../storage/preferences', () => ({
loadPushNotificationsEnabled: vi.fn()
}))
function flushAsync(): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, 10)
})
}
function persistedSeq(): number {
return (JSON.parse(storage.get(WATERMARK_KEY) ?? '{}') as { seq?: number }).seq ?? 0
}
type MissedOutcome =
| { kind: 'reject' }
| { kind: 'notOk' }
| { kind: 'ok'; notifications: unknown[] }
// Rejects only once `settle()` is called, so a live event can land mid-request.
| { kind: 'heldReject' }
function makeHostClient() {
let onData: ((data: unknown) => void) | null = null
const askedFrom: number[] = []
let outcome: MissedOutcome = { kind: 'ok', notifications: [] }
let releaseHeld: (() => void) | null = null
const client = {
subscribe: vi.fn((_m: string, _p: unknown, cb: (data: unknown) => void) => {
onData = cb
return vi.fn(() => {
onData = null
})
}),
getState: vi.fn(() => 'connected'),
sendRequest: vi.fn(async (method: string, params: unknown = {}) => {
if (method !== 'notifications.getMissedSince') {
return { ok: true, result: undefined } as never
}
askedFrom.push((params as { lastSeenSeq: number }).lastSeenSeq)
if (outcome.kind === 'heldReject') {
await new Promise<void>((resolve) => {
releaseHeld = resolve
})
throw new Error('socket closed')
}
if (outcome.kind === 'reject') {
throw new Error('socket closed')
}
if (outcome.kind === 'notOk') {
return { ok: false, error: { message: 'timeout' } } as never
}
return { ok: true, result: { notifications: outcome.notifications } } as never
})
}
return {
client: client as unknown as RpcClient,
get onData() {
return onData
},
askedFrom,
setOutcome(next: MissedOutcome) {
outcome = next
},
settleHeld() {
releaseHeld?.()
}
}
}
function notification(seq: number) {
return {
type: 'notification',
title: `m${seq}`,
body: 'b',
notificationId: `agent:${seq}`,
notificationSeq: seq
}
}
describe('#8591 catch-up failure quarantines the watermark', () => {
beforeEach(() => {
vi.clearAllMocks()
storage.clear()
resetHostNotificationSessionsForTests()
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
status: 'granted',
canAskAgain: true
} as never)
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('sched-1')
vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined)
})
it('keeps asking from the abandoned range until a catch-up actually succeeds', async () => {
// The phone was offline while seqs 6-7 dispatched. The catch-up that would have
// replayed them dies (socket close / timeout / ok:false), and live traffic keeps
// flowing. If a live seq is allowed to persist past 6-7, the desktop cuts by
// `seq > lastSeenSeq` on the next catch-up and they are gone for good — and the
// window stays open until some catch-up succeeds, not for one round trip.
storage.set(WATERMARK_KEY, JSON.stringify({ seq: 5, epoch: 'epoch-1' }))
const host = makeHostClient()
host.setOutcome({ kind: 'reject' })
subscribeToDesktopNotifications(host.client, 'host-1')
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
await flushAsync()
expect(host.askedFrom).toEqual([5])
host.onData?.({ ...notification(11), notificationEpoch: 'epoch-1' })
await flushAsync()
expect(persistedSeq()).toBe(5)
// Second catch-up also fails; the gap is still open.
host.setOutcome({ kind: 'notOk' })
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
await flushAsync()
host.onData?.({ ...notification(12), notificationEpoch: 'epoch-1' })
await flushAsync()
expect(host.askedFrom).toEqual([5, 5])
expect(persistedSeq()).toBe(5)
// Third succeeds and replays the abandoned range.
host.setOutcome({ kind: 'ok', notifications: [notification(6), notification(7)] })
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
await flushAsync()
expect(host.askedFrom).toEqual([5, 5, 5])
const titles = vi
.mocked(Notifications.scheduleNotificationAsync)
.mock.calls.map((call) => (call[0] as { content: { title: string } }).content.title)
// Exact, not arrayContaining: a duplicate here is the double-push `seen` prevents.
// m11/m12 are the live events that kept flowing while the gap stayed open.
expect(titles).toEqual(['m11', 'm12', 'm6', 'm7'])
// Only now may the watermark move past the recovered range.
expect(persistedSeq()).toBe(12)
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
await flushAsync()
expect(host.askedFrom).toEqual([5, 5, 5, 12])
})
it('rolls back a watermark a live event stored while the catch-up was in flight', async () => {
// getMissedSince waits up to 30s, so live traffic routinely persists during it.
// Clamping only writes made AFTER the failure leaves that higher seq on disk, and
// the next launch reads it back and resumes past the range this catch-up abandoned.
storage.set(WATERMARK_KEY, JSON.stringify({ seq: 5, epoch: 'epoch-1' }))
const host = makeHostClient()
host.setOutcome({ kind: 'heldReject' })
subscribeToDesktopNotifications(host.client, 'host-1')
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
await flushAsync()
expect(host.askedFrom).toEqual([5])
host.onData?.({ ...notification(11), notificationEpoch: 'epoch-1' })
await flushAsync()
expect(persistedSeq()).toBe(11)
host.settleHeld()
await flushAsync()
expect(persistedSeq()).toBe(5)
})
it('quarantines at the last replayed seq when a teardown cuts the batch short', async () => {
// The batch can start before a teardown and still be draining after it, so the
// events past the interruption were never shown. A live seq arriving on the next
// connection must not persist over them.
storage.set(WATERMARK_KEY, JSON.stringify({ seq: 5, epoch: 'epoch-1' }))
const host = makeHostClient()
host.setOutcome({
kind: 'ok',
notifications: [notification(6), notification(7), notification(8)]
})
let unsubscribe: (() => void) | null = null
vi.mocked(Notifications.scheduleNotificationAsync).mockImplementation(async (request) => {
if ((request as { content: { title: string } }).content.title === 'm6') {
unsubscribe?.()
}
return 'sched-1'
})
unsubscribe = subscribeToDesktopNotifications(host.client, 'host-1')
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
await flushAsync()
const titles = vi
.mocked(Notifications.scheduleNotificationAsync)
.mock.calls.map((call) => (call[0] as { content: { title: string } }).content.title)
expect(titles).toEqual(['m6'])
// A fresh subscription on the same module-scope session takes a live seq 20 before
// its own catch-up, then resumes from 6 rather than from 20.
vi.mocked(Notifications.scheduleNotificationAsync).mockResolvedValue('sched-1')
const host2 = makeHostClient()
host2.setOutcome({ kind: 'ok', notifications: [notification(7), notification(8)] })
subscribeToDesktopNotifications(host2.client, 'host-1')
host2.onData?.({ ...notification(20), notificationEpoch: 'epoch-1' })
await flushAsync()
expect(persistedSeq()).toBe(6)
host2.onData?.({ type: 'ready', subscriptionId: 'sub-2', epoch: 'epoch-1' })
await flushAsync()
expect(host2.askedFrom).toEqual([6])
expect(
vi
.mocked(Notifications.scheduleNotificationAsync)
.mock.calls.map((call) => (call[0] as { content: { title: string } }).content.title)
).toEqual(['m6', 'm20', 'm7', 'm8'])
expect(persistedSeq()).toBe(20)
})
it('re-shows a replay whose show threw, instead of dropping it as already seen', async () => {
// The quarantine only holds the RANGE. If the failing event is also marked seen,
// the next catch-up re-fetches it and the dedup guard drops it — the banner is
// never shown, and the first later event to drain the batch lifts the quarantine
// past it. Silent loss with the watermark looking healthy.
storage.set(WATERMARK_KEY, JSON.stringify({ seq: 5, epoch: 'epoch-1' }))
const host = makeHostClient()
host.setOutcome({ kind: 'ok', notifications: [notification(6), notification(7)] })
let failNext = true
vi.mocked(Notifications.scheduleNotificationAsync).mockImplementation(async (request) => {
const title = (request as { content: { title: string } }).content.title
if (title === 'm6' && failNext) {
failNext = false
throw new Error('scheduling rejected')
}
return 'sched-1'
})
subscribeToDesktopNotifications(host.client, 'host-1')
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
await flushAsync()
expect(persistedSeq()).toBe(5)
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
await flushAsync()
const titles = vi
.mocked(Notifications.scheduleNotificationAsync)
.mock.calls.map((call) => (call[0] as { content: { title: string } }).content.title)
expect(titles).toEqual(['m6', 'm6', 'm7'])
expect(host.askedFrom).toEqual([5, 5])
expect(persistedSeq()).toBe(7)
})
it('re-shows a live event whose show threw, instead of dropping it as already seen', async () => {
// The same hole without any catch-up failing: the live path marks seen before the
// show, so a rejected show leaves the key behind while the watermark stays put.
// The next catch-up dutifully re-fetches the seq and the guard eats it.
storage.set(WATERMARK_KEY, JSON.stringify({ seq: 5, epoch: 'epoch-1' }))
const host = makeHostClient()
host.setOutcome({ kind: 'ok', notifications: [] })
let failNext = true
vi.mocked(Notifications.scheduleNotificationAsync).mockImplementation(async () => {
if (failNext) {
failNext = false
throw new Error('scheduling rejected')
}
return 'sched-1'
})
subscribeToDesktopNotifications(host.client, 'host-1')
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
await flushAsync()
host.onData?.({ ...notification(6), notificationEpoch: 'epoch-1' })
await flushAsync()
expect(persistedSeq()).toBe(5)
host.setOutcome({ kind: 'ok', notifications: [notification(6)] })
host.onData?.({ type: 'ready', subscriptionId: 'sub-1', epoch: 'epoch-1' })
await flushAsync()
const titles = vi
.mocked(Notifications.scheduleNotificationAsync)
.mock.calls.map((call) => (call[0] as { content: { title: string } }).content.title)
expect(titles).toEqual(['m6', 'm6'])
expect(persistedSeq()).toBe(6)
})
})
@@ -135,6 +135,9 @@ export type HostNotificationSession = {
// Counter lifetime lastDeliveredSeq belongs to; null until one is known. A
// mismatch on reconnect means the desktop restarted and the watermark is void.
lastDeliveredEpoch: string | null
// Highest seq known delivered CONTIGUOUSLY, frozen here while a catch-up is
// outstanding; null when none has failed. See quarantineCatchUpWatermark.
catchUpQuarantineSeq: number | null
seen: ReturnType<typeof createSeenNotificationGuard>
// False only until the host's first subscription reaches 'ready' — a true cold open.
connectedBefore: boolean
@@ -161,6 +164,7 @@ export function getHostNotificationSession(hostId: string): HostNotificationSess
session = {
lastDeliveredSeq: 0,
lastDeliveredEpoch: null,
catchUpQuarantineSeq: null,
seen: createSeenNotificationGuard(),
connectedBefore: false,
hadStoredWatermark: false,
@@ -239,6 +243,55 @@ export function resetHostNotificationSessionsForTests(): void {
sessionsByHost.clear()
}
/**
* Freeze the catch-up watermark at the last seq known delivered contiguously,
* after a catch-up that did not complete.
*
* Why: live delivery advances lastDeliveredSeq unconditionally, so an abandoned
* catch-up otherwise lets the NEXT one ask from above the range it gave up on —
* the desktop cuts by seq, so those notifications are never replayed and are
* gone. Lowest wins: an earlier failure's gap is still open.
*/
export function quarantineCatchUpWatermark(
session: HostNotificationSession,
hostId: string,
contiguousSeq: number
): void {
session.catchUpQuarantineSeq =
session.catchUpQuarantineSeq == null
? contiguousSeq
: Math.min(session.catchUpQuarantineSeq, contiguousSeq)
// Why re-persist: a live event delivered while the catch-up was still in flight
// already stored a seq above the gap. Clamping only later writes would leave that
// value on disk, so a restart still resumes past the abandoned range.
void saveWatermark(hostId, {
seq: catchUpWatermarkSeq(session),
epoch: session.lastDeliveredEpoch
})
}
/** Lift the quarantine once a catch-up completes, persisting what it held back. */
export function resolveCatchUpQuarantine(session: HostNotificationSession, hostId: string): void {
if (session.catchUpQuarantineSeq == null) {
return
}
session.catchUpQuarantineSeq = null
void saveWatermark(hostId, {
seq: session.lastDeliveredSeq,
epoch: session.lastDeliveredEpoch
})
}
/**
* The seq a catch-up may ask from and the highest seq safe to persist — the live
* watermark, clamped to any open gap.
*/
export function catchUpWatermarkSeq(session: HostNotificationSession): number {
return session.catchUpQuarantineSeq == null
? session.lastDeliveredSeq
: Math.min(session.catchUpQuarantineSeq, session.lastDeliveredSeq)
}
// Why (#8591): the desktop's seq counter restarts at 0 every launch, so a watermark
// from a previous lifetime indexes a counter that no longer exists. Comparing it
// against the fresh counter makes `lastSeenSeq >= seq` true for everything and
@@ -262,6 +315,8 @@ export function adoptNotificationEpoch(
// counter re-issues those same low seqs, so a stale `seq:1` would silently drop
// the new counter's first bell. The dedup window belongs to one counter lifetime.
session.seen.clear()
// The quarantined gap indexed the dead counter; the watermark it guarded is gone too.
session.catchUpQuarantineSeq = null
session.lastDeliveredEpoch = epoch
void saveWatermark(hostId, { seq: 0, epoch })
}
@@ -8,9 +8,9 @@ import {
readMobileRuntimeHostPlatform,
readMobileRuntimeTerminalWindowsShell,
resolveMobileAiVaultResumePlatform,
resumeAiVaultSessionInTerminal,
RESUME_RPC_TIMEOUT_MS
resumeAiVaultSessionInTerminal
} from './ai-vault-resume-launch'
import { RESUME_RPC_TIMEOUT_MS } from './ai-vault-resume-preparation'
function session(overrides: Partial<AiVaultSession> = {}): AiVaultSession {
return {
+1 -41
View File
@@ -4,10 +4,7 @@ import {
buildAiVaultResumeShellCommand,
realHomeCodexResumeEnvDeletion
} from '../../../src/shared/ai-vault-types'
import {
isAiVaultPrepareSessionResumeUnavailableError,
isLegacySharedCodexHome
} from '../../../src/shared/ai-vault-resume-preparation'
import { RESUME_RPC_TIMEOUT_MS } from './ai-vault-resume-preparation'
import { isResumableTuiAgent } from '../../../src/shared/agent-session-resume'
import type { SleepingAgentLaunchConfig } from '../../../src/shared/agent-session-resume'
import { buildAgentResumeStartupPlan } from '../../../src/shared/tui-agent-startup'
@@ -167,43 +164,6 @@ function normalizeMobileAiVaultResumeCommandOverrides(
return normalized
}
// Why: without an explicit timeout, a socket drop mid-resume parks the request
// on the reconnect waiter for the full reconnect budget, pinning the spinner.
export const RESUME_RPC_TIMEOUT_MS = 30_000
export async function prepareMobileAiVaultSessionResume(
client: Pick<RpcClient, 'sendRequest'>,
session: AiVaultSession
): Promise<AiVaultSession> {
if (session.agent !== 'codex' || !isLegacySharedCodexHome(session.codexHome)) {
return session
}
const response = await client.sendRequest(
'aiVault.prepareSessionResume',
{
agent: session.agent,
filePath: session.filePath,
codexHome: session.codexHome,
executionHostId: session.executionHostId
},
{ timeoutMs: RESUME_RPC_TIMEOUT_MS }
)
if (!response.ok) {
if (isAiVaultPrepareSessionResumeUnavailableError(response.error)) {
// Why: older hosts cannot prepare, but their shared home still supports the legacy resume path.
return session
}
throw new Error(
response.error?.message || 'Could not prepare this legacy Codex session. Retry resume.'
)
}
const result = response.result as { useRealCodexHome?: unknown } | null
if (result?.useRealCodexHome !== true) {
return session
}
return { ...session, codexHome: null }
}
export async function resumeAiVaultSessionInTerminal(
client: Pick<RpcClient, 'sendRequest'>,
worktreeId: string,
@@ -2,12 +2,15 @@ import { describe, expect, it, vi } from 'vitest'
import type { AiVaultSession } from '../../../src/shared/ai-vault-types'
import {
buildMobileAiVaultResumeLaunch,
prepareMobileAiVaultSessionResume,
resumeAiVaultSessionInTerminal,
RESUME_RPC_TIMEOUT_MS
resumeAiVaultSessionInTerminal
} from './ai-vault-resume-launch'
import {
prepareMobileAiVaultSessionResume,
RESUME_RPC_TIMEOUT_MS
} from './ai-vault-resume-preparation'
const LEGACY_CODEX_HOME = '/Users/ada/Library/Application Support/orca/codex-runtime-home/home'
const PER_ACCOUNT_HOME = '/Users/ada/Library/Application Support/orca/codex-accounts/a/home'
function legacySession(overrides: Partial<AiVaultSession> = {}): AiVaultSession {
return {
@@ -121,10 +124,11 @@ describe('prepareMobileAiVaultSessionResume', () => {
{ agent: 'codex' as const, codexHome: '/Users/ada/.config/codex' },
{
agent: 'codex' as const,
codexHome: '/Users/ada/Library/Application Support/orca/codex-accounts/a/home'
codexHome: PER_ACCOUNT_HOME,
executionHostId: 'ssh:server-1' as AiVaultSession['executionHostId']
},
{ agent: 'codex' as const, codexHome: '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.codex' }
])('does not prepare non-legacy session $agent at $codexHome', async (overrides) => {
])('does not prepare unrepinnable session $agent at $codexHome', async (overrides) => {
const current = legacySession(overrides)
const sendRequest = vi.fn()
@@ -132,6 +136,51 @@ describe('prepareMobileAiVaultSessionResume', () => {
expect(sendRequest).not.toHaveBeenCalled()
})
it('repins a per-account session to the home the host substitutes', async () => {
const substituteHome = '/Users/ada/Library/Application Support/orca/codex-accounts/b/home'
const current = legacySession({ codexHome: PER_ACCOUNT_HOME })
const sendRequest = vi.fn().mockResolvedValue({
ok: true,
result: { useRealCodexHome: false, substituteCodexHome: substituteHome }
})
const prepared = await prepareMobileAiVaultSessionResume({ sendRequest }, current)
const launch = buildMobileAiVaultResumeLaunch({ session: prepared, hostPlatform: 'darwin' })
expect(sendRequest).toHaveBeenCalledWith(
'aiVault.prepareSessionResume',
{
agent: 'codex',
filePath: current.filePath,
codexHome: PER_ACCOUNT_HOME,
executionHostId: 'local'
},
{ timeoutMs: RESUME_RPC_TIMEOUT_MS }
)
expect(prepared.codexHome).toBe(substituteHome)
expect(launch.command).toContain(`CODEX_HOME='${substituteHome}'`)
})
it('keeps a per-account session home when an older host sends no repin', async () => {
const current = legacySession({ codexHome: PER_ACCOUNT_HOME })
const sendRequest = vi.fn().mockResolvedValue({
ok: true,
result: { useRealCodexHome: false }
})
await expect(prepareMobileAiVaultSessionResume({ sendRequest }, current)).resolves.toBe(current)
})
it('keeps a per-account session usable when the host cannot prepare at all', async () => {
const current = legacySession({ codexHome: PER_ACCOUNT_HOME })
const sendRequest = vi.fn().mockResolvedValue({
ok: false,
error: { code: 'method_not_found', message: 'Unknown method' }
})
await expect(prepareMobileAiVaultSessionResume({ sendRequest }, current)).resolves.toBe(current)
})
it.each([
{
code: 'internal_error',
@@ -0,0 +1,60 @@
import type { AiVaultSession } from '../../../src/shared/ai-vault-types'
import {
isAiVaultPrepareSessionResumeUnavailableError,
isLegacySharedCodexHome,
isPerAccountManagedCodexHome
} from '../../../src/shared/ai-vault-resume-preparation'
import { LOCAL_EXECUTION_HOST_ID } from '../../../src/shared/execution-host'
import type { RpcClient } from '../transport/rpc-client'
// Why: without an explicit timeout, a socket drop mid-resume parks the request
// on the reconnect waiter for the full reconnect budget, pinning the spinner.
export const RESUME_RPC_TIMEOUT_MS = 30_000
export async function prepareMobileAiVaultSessionResume(
client: Pick<RpcClient, 'sendRequest'>,
session: AiVaultSession
): Promise<AiVaultSession> {
// Why: per-account repinning runs on the serving host, whose account
// selection only applies to that host's own ("local") sessions.
const needsAccountRepin =
isPerAccountManagedCodexHome(session.codexHome) &&
(!session.executionHostId || session.executionHostId === LOCAL_EXECUTION_HOST_ID)
if (
session.agent !== 'codex' ||
(!isLegacySharedCodexHome(session.codexHome) && !needsAccountRepin)
) {
return session
}
const response = await client.sendRequest(
'aiVault.prepareSessionResume',
{
agent: session.agent,
filePath: session.filePath,
codexHome: session.codexHome,
executionHostId: session.executionHostId
},
{ timeoutMs: RESUME_RPC_TIMEOUT_MS }
)
if (!response.ok) {
if (isAiVaultPrepareSessionResumeUnavailableError(response.error)) {
// Why: older hosts cannot prepare, but their shared home still supports the legacy resume path.
return session
}
throw new Error(
response.error?.message || 'Could not prepare this legacy Codex session. Retry resume.'
)
}
const result = response.result as {
useRealCodexHome?: unknown
substituteCodexHome?: unknown
} | null
if (result?.useRealCodexHome === true) {
return { ...session, codexHome: null }
}
// Why: older hosts never send a repin home, so absence keeps the session's own home.
if (typeof result?.substituteCodexHome === 'string' && result.substituteCodexHome) {
return { ...session, codexHome: result.substituteCodexHome }
}
return session
}
+11 -4
View File
@@ -1,7 +1,7 @@
import type {
CheckStatus,
GitHubAssignableUser,
GitHubPRCheckSummary,
ProviderCheckSummary,
GitHubPRMergeMethod,
GitHubPRMergeMethodSettings,
GitHubPRReviewSummary,
@@ -182,12 +182,18 @@ export function readMergeMethodSettings(value: unknown): GitHubPRMergeMethodSett
}
}
export function readCheckSummary(value: unknown): GitHubPRCheckSummary | undefined {
export function readCheckSummary(value: unknown): ProviderCheckSummary | undefined {
if (!isRecord(value)) {
return undefined
}
const state = value.state
if (state !== 'success' && state !== 'failure' && state !== 'pending' && state !== 'none') {
if (
state !== 'success' &&
state !== 'failure' &&
state !== 'pending' &&
state !== 'neutral' &&
state !== 'none'
) {
return undefined
}
return {
@@ -195,6 +201,7 @@ export function readCheckSummary(value: unknown): GitHubPRCheckSummary | undefin
total: readNumber(value.total) ?? 0,
passed: readNumber(value.passed) ?? 0,
failed: readNumber(value.failed) ?? 0,
pending: readNumber(value.pending) ?? 0
pending: readNumber(value.pending) ?? 0,
neutral: readNumber(value.neutral) ?? 0
}
}
@@ -0,0 +1,146 @@
import { describe, expect, it } from 'vitest'
import {
shouldAutoCreateInitialSessionTerminal,
type InitialSessionTerminalAutoCreateInput
} from './initial-session-terminal'
function baseInput(
overrides: Partial<InitialSessionTerminalAutoCreateInput> = {}
): InitialSessionTerminalAutoCreateInput {
return {
newlyCreatedWorkspace: true,
connected: true,
tabsLoaded: true,
visibleTabCount: 0,
hasActiveTerminalHandle: false,
createInFlight: false,
sawSessionTabs: false,
autoCreatedForWorktree: false,
...overrides
}
}
/**
* Replays the route state the session screen actually holds
* (`app/h/[hostId]/session/[worktreeId].tsx`): `applySessionTabs` publishes the
* tab list and flips `terminalsLoaded`, `handleCloseSessionTab` prunes the
* closed tab and nulls the active handle.
*/
class SessionRouteState {
newlyCreatedWorkspace = true
tabsLoaded = false
tabIds: string[] = []
activeHandle: string | null = null
sawSessionTabs = false
autoCreatedForWorktree = false
applySessionTabs(tabIds: string[], activeHandle: string | null = tabIds[0] ? 'pty-1' : null) {
this.tabIds = tabIds
this.activeHandle = activeHandle
if (tabIds.length > 0) {
this.sawSessionTabs = true
}
this.tabsLoaded = true
}
closeSessionTab(tabId: string) {
this.tabIds = this.tabIds.filter((candidate) => candidate !== tabId)
this.activeHandle = null
}
gate(): boolean {
return shouldAutoCreateInitialSessionTerminal({
newlyCreatedWorkspace: this.newlyCreatedWorkspace,
connected: true,
tabsLoaded: this.tabsLoaded,
visibleTabCount: this.tabIds.length,
hasActiveTerminalHandle: this.activeHandle !== null,
createInFlight: false,
sawSessionTabs: this.sawSessionTabs,
autoCreatedForWorktree: this.autoCreatedForWorktree
})
}
}
describe('shouldAutoCreateInitialSessionTerminal', () => {
it('creates the first terminal for a workspace that hydrates with nothing', () => {
const route = new SessionRouteState()
route.applySessionTabs([])
expect(route.gate()).toBe(true)
})
it('does not re-create after the user closes the last tab (#9717, #7345)', () => {
const route = new SessionRouteState()
route.applySessionTabs(['tab-1'])
expect(route.gate()).toBe(false)
route.closeSessionTab('tab-1')
expect(route.tabIds).toHaveLength(0)
expect(route.activeHandle).toBeNull()
// Emptiness that follows a populated list is a close, not a cold hydrate.
expect(route.gate()).toBe(false)
})
it('does not re-create after the consumed creation route remounts empty', () => {
const creationRoute = new SessionRouteState()
creationRoute.applySessionTabs([])
expect(creationRoute.gate()).toBe(true)
creationRoute.newlyCreatedWorkspace = false
creationRoute.autoCreatedForWorktree = true
creationRoute.applySessionTabs(['tab-1'])
creationRoute.closeSessionTab('tab-1')
expect(creationRoute.gate()).toBe(false)
const reopenedRoute = new SessionRouteState()
reopenedRoute.newlyCreatedWorkspace = creationRoute.newlyCreatedWorkspace
reopenedRoute.applySessionTabs([])
expect(reopenedRoute.gate()).toBe(false)
})
it('does not create from stale empty state while switching ordinary workspace routes', () => {
expect(
shouldAutoCreateInitialSessionTerminal(
baseInput({
newlyCreatedWorkspace: false,
tabsLoaded: true,
visibleTabCount: 0
})
)
).toBe(false)
})
it('does not re-create when the host drops every tab mid-session', () => {
const route = new SessionRouteState()
route.applySessionTabs(['tab-1', 'tab-2'])
route.applySessionTabs([], null)
expect(route.gate()).toBe(false)
})
it('stays armed while an empty workspace is still loading', () => {
expect(shouldAutoCreateInitialSessionTerminal(baseInput({ tabsLoaded: false }))).toBe(false)
})
it('waits for the host connection', () => {
expect(shouldAutoCreateInitialSessionTerminal(baseInput({ connected: false }))).toBe(false)
})
it('defers to a lagging snapshot that still has a streaming terminal', () => {
expect(
shouldAutoCreateInitialSessionTerminal(baseInput({ hasActiveTerminalHandle: true }))
).toBe(false)
})
it('does not stack a second create on an in-flight one', () => {
expect(shouldAutoCreateInitialSessionTerminal(baseInput({ createInFlight: true }))).toBe(false)
})
it('fires at most once per workspace on this route', () => {
expect(
shouldAutoCreateInitialSessionTerminal(baseInput({ autoCreatedForWorktree: true }))
).toBe(false)
})
it('does not fire while tabs are visible', () => {
expect(shouldAutoCreateInitialSessionTerminal(baseInput({ visibleTabCount: 2 }))).toBe(false)
})
})
@@ -0,0 +1,46 @@
// Mirrors the desktop `shouldAutoCreateInitialTerminal` gate: a newly created
// workspace that hydrates empty gets one terminal so the session isn't blank.
// Kept pure (no react-native imports) so the gate is unit-testable.
export type InitialSessionTerminalAutoCreateInput = {
/** Navigation came directly from creating this workspace. */
newlyCreatedWorkspace: boolean
/** Host connection is up and an RPC client is attached. */
connected: boolean
/** At least one session-tab snapshot has been applied on this route. */
tabsLoaded: boolean
/** Session tabs currently rendered on the phone. */
visibleTabCount: number
/** A terminal is still streaming even though the tab list reads empty. */
hasActiveTerminalHandle: boolean
/** A terminal, browser, or markdown create is already in flight. */
createInFlight: boolean
/** This route has shown at least one session tab for this workspace. */
sawSessionTabs: boolean
/** This route already auto-created a terminal for this workspace. */
autoCreatedForWorktree: boolean
}
/**
* Whether to auto-create the first terminal of a newly created mobile session.
*
* `sawSessionTabs` is the resurrection guard: emptiness that *follows* a
* populated tab list was produced by a close (the user's, or the host dropping
* a dead pane), and re-creating there spawns a brand-new terminal the user
* never asked for — issues #9717 / #7345. Only a workspace that has shown
* nothing since its creation route opened is eligible.
*/
export function shouldAutoCreateInitialSessionTerminal(
input: InitialSessionTerminalAutoCreateInput
): boolean {
if (!input.newlyCreatedWorkspace || !input.connected || !input.tabsLoaded) {
return false
}
if (input.visibleTabCount > 0 || input.hasActiveTerminalHandle) {
return false
}
if (input.createInFlight || input.autoCreatedForWorktree) {
return false
}
return !input.sawSessionTabs
}
@@ -10,6 +10,8 @@ function extname(filePath: string): string {
const EXT_TO_LANGUAGE: Record<string, string> = {
'.ts': 'typescript',
'.tsx': 'typescript',
'.cts': 'typescript',
'.mts': 'typescript',
'.js': 'javascript',
'.jsx': 'javascript',
'.mjs': 'javascript',
@@ -11,9 +11,12 @@ import type { MobileDiffLine } from './mobile-diff-lines'
describe('mobile file syntax highlighting', () => {
it('detects common source languages from file paths', () => {
expect(detectMobileFileLanguage('src/App.tsx')).toBe('typescript')
expect(detectMobileFileLanguage('config/vitest.config.mts')).toBe('typescript')
expect(detectMobileFileLanguage('C:\\repo\\scripts\\postinstall.CTS')).toBe('typescript')
expect(detectMobileFileLanguage('scripts/deploy.sh')).toBe('shell')
expect(detectMobileFileLanguage('Dockerfile')).toBe('dockerfile')
expect(resolveMobileSyntaxLanguage('src/App.tsx')).toBe('typescript')
expect(resolveMobileSyntaxLanguage('worktrees/feature/build.cts')).toBe('typescript')
expect(resolveMobileSyntaxLanguage('Dockerfile')).toBe('plaintext')
})
@@ -27,6 +27,7 @@ export type MobileNativeChatTab = {
agentStatus?: AgentStatusEntry | null
/** Host-provided launch context still parked as an unsent TUI-input draft. */
launchDraft?: string
launchDraftCreatedAt?: number
}
/** Resolve a session tab to the transcript identity native chat needs, or
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcResponse, RpcSuccess } from '../transport/types'
import { pasteMobileNativeChatImagePaths } from './mobile-native-chat-image-send'
import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear'
function sendResult(accepted: boolean, id = 'send'): RpcSuccess {
return { id, ok: true, result: { send: { accepted } }, _meta: { runtimeId: 'r' } }
@@ -101,3 +102,52 @@ describe('pasteMobileNativeChatImagePaths', () => {
}
})
})
describe('clearing a parked multi-line launch draft before the image paste', () => {
it('leads with the caller-sized burst instead of one Ctrl+U', async () => {
// One Ctrl+U kills only the LAST line, so the draft's earlier lines would
// survive and ride along with the image as part of the prompt body.
const client = clientWithResponses([sendResult(true), sendResult(true)])
const clearInput = buildAgentTuiClearInputForText('Linked Linear issue: ABC-123\nhttps://x')
await pasteMobileNativeChatImagePaths({
client,
terminal: 'term-1',
deviceToken: null,
imagePaths: ['/tmp/a.png'],
clearInput
})
expect(client.calls[0]?.params.text).toBe(clearInput)
expect(client.calls[0]?.params.text).not.toBe('\x15')
})
it('clears once, before the paste — never between or after the image writes', async () => {
const client = clientWithResponses([sendResult(true), sendResult(true), sendResult(true)])
const clearInput = buildAgentTuiClearInputForText('a\nb\nc')
await pasteMobileNativeChatImagePaths({
client,
terminal: 'term-1',
deviceToken: null,
imagePaths: ['/tmp/a.png', '/tmp/b.png'],
clearInput
})
expect(client.calls.filter((call) => call.params.text === clearInput)).toHaveLength(1)
expect(client.calls[0]?.params.text).toBe(clearInput)
})
it('falls back to a single Ctrl+U when no draft is parked', async () => {
const client = clientWithResponses([sendResult(true), sendResult(true)])
await pasteMobileNativeChatImagePaths({
client,
terminal: 'term-1',
deviceToken: null,
imagePaths: ['/tmp/a.png']
})
expect(client.calls[0]?.params.text).toBe('\x15')
})
})
@@ -26,6 +26,10 @@ type PasteImagesArgs = {
/** Budget shared with the rest of the user action (the text body that follows, or
* the send this is healing for). Omit to open a fresh one for this paste alone. */
readonly deadline?: number
/** Bytes for the leading clear. Defaults to a single Ctrl+U, which clears only
* ONE logical line — callers holding a parked multi-line launch draft must
* pass a burst, or its earlier lines survive and glue onto the message. */
readonly clearInput?: string
}
/** Clears the agent's unsubmitted input line, then pastes each uploaded image
@@ -38,7 +42,8 @@ export async function pasteMobileNativeChatImagePaths({
terminal,
deviceToken,
imagePaths,
deadline: sharedDeadline
deadline: sharedDeadline,
clearInput
}: PasteImagesArgs): Promise<boolean> {
const mobileClient: MobileTerminalClient | null = deviceToken
? { id: deviceToken, type: 'mobile' }
@@ -49,7 +54,7 @@ export async function pasteMobileNativeChatImagePaths({
// once and let each write draw from what's left.
const deadline = sharedDeadline ?? openMobileNativeChatSendBudget()
for (const text of [
MOBILE_NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT,
clearInput ?? MOBILE_NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT,
...imagePaths.map(buildMobileImagePastePayload)
]) {
const remainingMs = deadline - Date.now()
@@ -5,9 +5,11 @@ import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-clien
import {
MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS,
openMobileNativeChatSendBudget,
clearMobileNativeChatInput,
sendMobileNativeChatMessage,
sendMobileNativeChatMessageWithOutcome
} from './mobile-native-chat-send'
import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear'
function clientWithResponse(response: unknown): RpcClient {
return {
@@ -29,6 +31,7 @@ describe('sendMobileNativeChatMessage', () => {
client,
terminal: 'term',
text: 'hello',
resolvedLaunchDraft: { text: 'seed', createdAt: 7 },
mobileClient: { id: 'device', type: 'mobile' }
})
).resolves.toBe(true)
@@ -38,6 +41,7 @@ describe('sendMobileNativeChatMessage', () => {
terminal: 'term',
text: 'hello',
enter: true,
resolvedLaunchDraft: { text: 'seed', createdAt: 7 },
client: { id: 'device', type: 'mobile' }
},
{ timeoutMs: MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS, budgetSpansConnect: true }
@@ -285,3 +289,83 @@ describe('sendMobileNativeChatMessage', () => {
expect(budget).toBeLessThanOrEqual(MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS)
})
})
describe('clearMobileNativeChatInput', () => {
const accepted = {
id: 'request',
ok: true,
result: { send: { accepted: true } },
_meta: { runtimeId: 'runtime' }
}
const params = (client: RpcClient) =>
vi.mocked(client.sendRequest).mock.calls[0]![1] as { text: string; enter: boolean }
it('writes the burst as its OWN non-submitting write', async () => {
// Bundling the burst into the body write reached the agent as LITERAL Ctrl+U
// text and the parked draft concatenated (observed live).
const client = clientWithResponse(accepted)
const clearInput = buildAgentTuiClearInputForText('Linked Linear issue: ABC-123\nhttps://x')
await expect(
clearMobileNativeChatInput({ client, terminal: 'term', clearInput })
).resolves.toBe(true)
expect(params(client)).toMatchObject({ text: clearInput, enter: false })
})
it('reports failure when the host rejects the clear', async () => {
const client = clientWithResponse({
id: 'request',
ok: true,
result: { send: { accepted: false } },
_meta: { runtimeId: 'runtime' }
})
await expect(
clearMobileNativeChatInput({ client, terminal: 'term', clearInput: '\x15' })
).resolves.toBe(false)
})
it('refuses to start an underfunded clear rather than half-clearing', async () => {
const client = clientWithResponse(accepted)
await expect(
clearMobileNativeChatInput({
client,
terminal: 'term',
clearInput: '\x15',
deadline: Date.now() + 10
})
).resolves.toBe(false)
expect(client.sendRequest).not.toHaveBeenCalled()
})
})
describe('the body write never carries a multi-line burst', () => {
const accepted = {
id: 'request',
ok: true,
result: { send: { accepted: true } },
_meta: { runtimeId: 'runtime' }
}
const sentText = (client: RpcClient): string =>
(vi.mocked(client.sendRequest).mock.calls[0]![1] as { text: string }).text
it('still prefixes only a single Ctrl+U when asked to clear first', async () => {
const client = clientWithResponse(accepted)
await sendMobileNativeChatMessage({
client,
terminal: 'term',
text: 'hello',
clearInputFirst: true
})
expect(sentText(client)).toBe('\x15hello')
})
it('never prefixes a clear when the caller already pasted (image sends)', async () => {
const client = clientWithResponse(accepted)
await sendMobileNativeChatMessage({
client,
terminal: 'term',
text: 'caption',
clearInputFirst: false
})
expect(sentText(client)).toBe('caption')
})
})
@@ -11,6 +11,11 @@ type MobileTerminalClient = {
// Why: Ctrl+U kills the TUI's current input line (desktop native chat sends the
// same byte before its body), so a launch-context prefill parked there cannot
// concatenate with a mobile chat message. The host writes text bytes verbatim.
//
// One Ctrl+U clears ONE logical line, which is all this prefix can do. A parked
// launch draft is routinely multi-line (every Linear block is); callers that know
// one is parked must call clearMobileNativeChatInput FIRST — see
// src/shared/agent-tui-input-clear.ts for the measured 2N-1 law.
const CLEAR_UNSUBMITTED_INPUT = '\x15'
type MobileNativeChatSendArgs = {
@@ -19,6 +24,8 @@ type MobileNativeChatSendArgs = {
text: string
enter?: boolean
clearInputFirst?: boolean
/** Exact host launch draft this submitting write resolves when accepted. */
resolvedLaunchDraft?: { text: string; createdAt: number }
mobileClient?: MobileTerminalClient
/** Shared budget for a whole user action (heal → paste → text, or one selector's
* keystroke sequence). Omit to give this write its own full budget. */
@@ -59,6 +66,7 @@ export async function sendMobileNativeChatMessageWithOutcome(
terminal: args.terminal,
text: args.clearInputFirst ? `${CLEAR_UNSUBMITTED_INPUT}${args.text}` : args.text,
enter: args.enter ?? true,
...(args.resolvedLaunchDraft ? { resolvedLaunchDraft: args.resolvedLaunchDraft } : {}),
...(args.mobileClient ? { client: args.mobileClient } : {})
},
// The budget covers this whole write, reconnect wait included — a chat send
@@ -84,3 +92,42 @@ export async function sendMobileNativeChatMessage(
): Promise<boolean> {
return (await sendMobileNativeChatMessageWithOutcome(args)) === 'accepted'
}
/**
* Clear the agent's input line as its OWN write, before any body.
*
* Why not prefix it onto the body write: a multi-line clear burst bundled into
* the same `terminal.send` as the text reached the agent as LITERAL Ctrl+U
* characters — the draft survived and the burst landed in the middle of the
* message (observed live: draft + 21 literal \x15 + body). A standalone write is
* the shape the image paste has always used, and it clears as intended.
*/
export async function clearMobileNativeChatInput(args: {
client: RpcClient
terminal: string
clearInput: string
mobileClient?: MobileTerminalClient
deadline?: number
}): Promise<boolean> {
const timeoutMs =
args.deadline === undefined ? MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS : args.deadline - Date.now()
if (timeoutMs < MOBILE_NATIVE_CHAT_MIN_WRITE_TIMEOUT_MS) {
return false
}
try {
const response = await args.client.sendRequest(
'terminal.send',
{
terminal: args.terminal,
text: args.clearInput,
enter: false,
...(args.mobileClient ? { client: args.mobileClient } : {})
},
{ timeoutMs, budgetSpansConnect: true }
)
return isTerminalSendRpcAccepted(response)
} catch {
// A failed clear must not send the body on top of an uncleared line.
return false
}
}
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import { isTerminalPhoneDisplayMode } from './mobile-session-route-helpers'
describe('isTerminalPhoneDisplayMode', () => {
it('uses phone mode for automatic, phone, and unreported terminals', () => {
const modes = new Map([
['auto', 'auto'],
['phone', 'phone']
] as const)
expect(isTerminalPhoneDisplayMode('auto', modes)).toBe(true)
expect(isTerminalPhoneDisplayMode('phone', modes)).toBe(true)
expect(isTerminalPhoneDisplayMode('missing', modes)).toBe(true)
})
it('rejects absent handles and desktop terminals', () => {
const modes = new Map([['desktop', 'desktop']] as const)
expect(isTerminalPhoneDisplayMode(null, modes)).toBe(false)
expect(isTerminalPhoneDisplayMode('desktop', modes)).toBe(false)
})
})
@@ -34,6 +34,17 @@ export function isGestureMouseTrackingMode(
return mode === 'x10' || mode === 'vt200' || mode === 'drag' || mode === 'any'
}
export function isTerminalPhoneDisplayMode(
handle: string | null,
terminalModes: ReadonlyMap<string, 'auto' | 'phone' | 'desktop'>
): boolean {
if (!handle) {
return false
}
const mode = terminalModes.get(handle)
return mode === undefined || mode === 'auto' || mode === 'phone'
}
export function getActiveTabIdForHandle(
tabs: ReadonlyArray<{ id: string; type: string; terminal?: string | null }>,
terminalHandle: string | null
@@ -9,6 +9,10 @@ const reconciliationHookSource = readFileSync(
new URL('./use-mobile-session-tabs-reconciliation.ts', import.meta.url),
'utf8'
)
const autoCreateHookSource = readFileSync(
new URL('./use-initial-session-terminal-autocreate.ts', import.meta.url),
'utf8'
)
function sliceBetween(startPattern: string, endPattern: string): string {
const start = source.indexOf(startPattern)
@@ -19,18 +23,49 @@ function sliceBetween(startPattern: string, endPattern: string): string {
}
describe('mobile session startup', () => {
it('auto-creates one terminal for an initially empty connected session', () => {
expect(source).toContain('const initialEmptySessionAutoCreateRef = useRef<string | null>(null)')
expect(source).toContain('initialEmptySessionAutoCreateRef.current = null')
const autoCreateEffect = sliceBetween(
'if (\n !client ||\n !showEmptyState',
'const terminalSummary ='
it('auto-creates one terminal for a newly created empty session', () => {
expect(source).toContain('useWorktreeSessionTabsLoaded(worktreeId)')
expect(source).toContain(
'initialSessionAutoCreateRef.current = createInitialSessionAutoCreateState()'
)
expect(autoCreateEffect).toContain('initialEmptySessionAutoCreateRef.current === worktreeId')
expect(autoCreateEffect).toContain('initialEmptySessionAutoCreateRef.current = worktreeId')
expect(autoCreateEffect).toContain("setCreateError('')")
expect(autoCreateEffect).toContain('void handleCreateTerminal()')
const autoCreateCall = sliceBetween(
'useInitialSessionTerminalAutoCreate({',
'const connectionVerdict ='
)
expect(autoCreateCall).toContain('stateRef: initialSessionAutoCreateRef')
expect(autoCreateCall).toContain(
'consumeCreationRoute: () => router.setParams({ created: undefined })'
)
expect(autoCreateCall).toContain("newlyCreatedWorkspace: created === '1'")
expect(autoCreateCall).toContain('visibleTabCount: visibleTabs.length')
expect(autoCreateCall).toContain('createTerminal: () => void handleCreateTerminal()')
expect(autoCreateHookSource).toContain('shouldAutoCreateInitialSessionTerminal({')
expect(autoCreateHookSource).toContain('stateRef.current.autoCreatedForWorktree === worktreeId')
expect(autoCreateHookSource).toContain('stateRef.current.autoCreatedForWorktree = worktreeId')
expect(autoCreateHookSource).toContain("connState === 'connected'")
expect(autoCreateHookSource).toContain('(visibleTabCount > 0 || activeHandle !== null)')
// Why: both callbacks are re-created every render, so the effect must reach them
// through useEffectEvent rather than deps or a render-time ref write.
expect(autoCreateHookSource).toContain('useEffectEvent(args.consumeCreationRoute)')
expect(autoCreateHookSource).toContain('useEffectEvent(args.createTerminal)')
expect(autoCreateHookSource).toContain('consumeCreationRoute()')
expect(autoCreateHookSource).toContain('createTerminal()')
})
it('arms the auto-create only until the route has published a tab (#9717)', () => {
// Emptiness after a populated list is a close, not a cold hydrate.
expect(source).toContain(
'initialSessionAutoCreateRef.current.sawSessionTabs ||= nextTabs.length > 0'
)
const autoCreateCall = sliceBetween(
'useInitialSessionTerminalAutoCreate({',
'const connectionVerdict ='
)
expect(autoCreateCall).toContain('stateRef: initialSessionAutoCreateRef')
expect(autoCreateHookSource).toContain('sawSessionTabs: stateRef.current.sawSessionTabs')
})
it('delegates stream ownership while retaining the exact terminal polling cadence', () => {
@@ -9,24 +9,38 @@ vi.mock('lucide-react-native', () => ({
SquareTerminal: vi.fn()
}))
type SheetArgs = Parameters<typeof getMobileTerminalActionSheetActions>[0]
function buildActions(overrides: Partial<SheetArgs> = {}) {
return getMobileTerminalActionSheetActions({
target: { handle: 'terminal-1' },
tabs: [],
isTabChatView: () => false,
nativeChatTranscriptIsLocalReadable: true,
onDismiss: vi.fn(),
onToggleChat: vi.fn(),
isPhoneMode: () => false,
onToggleDisplayMode: vi.fn(),
onRename: vi.fn(),
onClear: vi.fn(),
onClose: vi.fn(),
onCloseSessionTab: vi.fn(),
...overrides
})
}
const terminalTab = (id: string, handle: string | null) => ({
type: 'terminal',
id,
terminal: handle
})
describe('getMobileTerminalActionSheetActions', () => {
it('defers Rename until after the action sheet closes', () => {
const target = { handle: 'terminal-1' }
const onDismiss = vi.fn()
const onRename = vi.fn()
const actions = getMobileTerminalActionSheetActions({
target,
tabs: [],
isTabChatView: () => false,
nativeChatTranscriptIsLocalReadable: true,
onDismiss,
onToggleChat: vi.fn(),
isPhoneMode: () => false,
onToggleDisplayMode: vi.fn(),
onRename,
onClear: vi.fn(),
onClose: vi.fn()
})
const actions = buildActions({ target, onDismiss, onRename })
const rename = actions.find((action) => action.label === 'Rename')
expect(rename).toMatchObject({ closeBeforePress: true })
@@ -35,4 +49,51 @@ describe('getMobileTerminalActionSheetActions', () => {
expect(onRename).toHaveBeenCalledWith(target)
expect(onDismiss).not.toHaveBeenCalled()
})
// Why: handle-only close lets the pending-terminal effect resurrect the tab (#6927, #7345).
it('closes the handle through its session tab, not the handle-only path', () => {
const onClose = vi.fn()
const onCloseSessionTab = vi.fn()
const tab = terminalTab('tab-1::leaf-1', 'terminal-1')
const actions = buildActions({
target: { handle: 'terminal-1' },
tabs: [tab],
onClose,
onCloseSessionTab
})
actions.find((action) => action.label === 'Close')?.onPress()
expect(onCloseSessionTab).toHaveBeenCalledWith(tab)
expect(onClose).not.toHaveBeenCalled()
})
it('closes the split leaf that owns the handle, not its sibling', () => {
const onCloseSessionTab = vi.fn()
const first = terminalTab('tab-1::leaf-1', 'terminal-1')
const second = terminalTab('tab-1::leaf-2', 'terminal-2')
const actions = buildActions({
target: { handle: 'terminal-2' },
tabs: [first, second],
onCloseSessionTab
})
actions.find((action) => action.label === 'Close')?.onPress()
expect(onCloseSessionTab).toHaveBeenCalledWith(second)
})
it('falls back to the handle close when no session tab owns the handle', () => {
const onClose = vi.fn()
const onCloseSessionTab = vi.fn()
const target = { handle: 'terminal-9' }
const actions = buildActions({
target,
tabs: [terminalTab('tab-1::leaf-1', 'terminal-1'), terminalTab('tab-2::leaf-1', null)],
onClose,
onCloseSessionTab
})
actions.find((action) => action.label === 'Close')?.onPress()
expect(onClose).toHaveBeenCalledWith(target)
expect(onCloseSessionTab).not.toHaveBeenCalled()
})
})
@@ -7,9 +7,12 @@ type TerminalTab = MobileNativeChatTab & { id: string; terminal: string | null }
/** Builds the terminal long-press menu without adding another action block to the
* already dense session route. Native chat stays first as the view switch. */
export function getMobileTerminalActionSheetActions<Target extends { handle: string }>(args: {
export function getMobileTerminalActionSheetActions<
Target extends { handle: string },
Tab extends TerminalTab
>(args: {
target: Target | null
tabs: readonly TerminalTab[]
tabs: readonly Tab[]
isTabChatView: (tabId: string) => boolean
nativeChatTranscriptIsLocalReadable: boolean
onDismiss: () => void
@@ -18,7 +21,10 @@ export function getMobileTerminalActionSheetActions<Target extends { handle: str
onToggleDisplayMode: (handle: string) => void
onRename: (target: Target) => void
onClear: (target: Target) => void
/** Fallback for a live handle with no matching session tab. */
onClose: (target: Target) => void
/** Preferred path runs host teardown and records the local tombstone. */
onCloseSessionTab: (tab: Tab) => void
/** Appended after Close; receives the pressed tab's id so the session route's
* bulk-close builder can resolve the anchor itself. */
bulkCloseActions?: (anchorTabId: string | undefined, dismiss: () => void) => ActionSheetAction[]
@@ -28,6 +34,7 @@ export function getMobileTerminalActionSheetActions<Target extends { handle: str
return []
}
const phoneMode = args.isPhoneMode(target.handle)
const sessionTab = args.tabs.find((tab) => tab.terminal === target.handle)
return [
...getMobileNativeChatToggleActions({
terminalHandle: target.handle,
@@ -65,12 +72,13 @@ export function getMobileTerminalActionSheetActions<Target extends { handle: str
destructive: true,
onPress: () => {
args.onDismiss()
if (sessionTab) {
args.onCloseSessionTab(sessionTab)
return
}
args.onClose(target)
}
},
...(args.bulkCloseActions?.(
args.tabs.find((tab) => tab.terminal === target.handle)?.id,
args.onDismiss
) ?? [])
...(args.bulkCloseActions?.(sessionTab?.id, args.onDismiss) ?? [])
]
}
@@ -102,12 +102,14 @@ describe('mobile terminal records', () => {
}
const seeded: MobileTerminalSessionTab = {
...base,
launchDraft: 'https://github.com/o/r/issues/12'
launchDraft: 'https://github.com/o/r/issues/12',
launchDraftCreatedAt: 1
}
expect(mobileSessionTabsEqual([base], [seeded])).toBe(false)
expect(mobileSessionTabsEqual([seeded], [base])).toBe(false)
expect(mobileSessionTabsEqual([seeded], [{ ...seeded }])).toBe(true)
expect(mobileSessionTabsEqual([seeded], [{ ...seeded, launchDraftCreatedAt: 2 }])).toBe(false)
})
it('treats terminal agent-status changes as session-tab changes', () => {
@@ -19,6 +19,7 @@ export type MobileTerminalSessionTab = {
agentStatus?: AgentStatusEntry | null
/** Host-provided launch context still parked as an unsent TUI-input draft. */
launchDraft?: string
launchDraftCreatedAt?: number
terminalTheme?: MobileTerminalTheme
isActive: boolean
}
@@ -89,6 +90,7 @@ function mobileSessionTabEqual(
// A frame whose only delta is the launch draft appearing or retracting
// still has to reach the chat composer.
a.launchDraft === b.launchDraft &&
a.launchDraftCreatedAt === b.launchDraftCreatedAt &&
JSON.stringify(a.agentStatus ?? null) === JSON.stringify(b.agentStatus ?? null) &&
JSON.stringify(a.terminalTheme ?? null) === JSON.stringify(b.terminalTheme ?? null)
)
@@ -0,0 +1,189 @@
import { createElement, type RefObject } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
useInitialSessionTerminalAutoCreate,
useWorktreeSessionTabsLoaded
} from './use-initial-session-terminal-autocreate'
type HarnessProps = {
newlyCreatedWorkspace: boolean
terminalsLoaded: boolean
visibleTabCount: number
worktreeId: string
connected?: boolean
hasClient?: boolean
}
describe('useInitialSessionTerminalAutoCreate', () => {
let renderer: ReactTestRenderer | null = null
let stateRef: RefObject<{
autoCreatedForWorktree: string | null
sawSessionTabs: boolean
}>
const consumeCreationRoute = vi.fn()
const createTerminal = vi.fn()
function Harness(props: HarnessProps): null {
useInitialSessionTerminalAutoCreate({
client: props.hasClient === false ? null : {},
newlyCreatedWorkspace: props.newlyCreatedWorkspace,
connState: props.connected === false ? 'disconnected' : 'connected',
terminalsLoaded: props.terminalsLoaded,
visibleTabCount: props.visibleTabCount,
activeHandle: null,
createInFlight: false,
stateRef,
worktreeId: props.worktreeId,
consumeCreationRoute,
createTerminal
})
return null
}
async function render(props: HarnessProps): Promise<void> {
await act(async () => {
if (renderer) {
renderer.update(createElement(Harness, props))
} else {
renderer = create(createElement(Harness, props))
}
})
}
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
stateRef = {
current: { autoCreatedForWorktree: null, sawSessionTabs: false }
}
consumeCreationRoute.mockClear()
createTerminal.mockClear()
vi.spyOn(console, 'error').mockImplementation((...args) => {
if (typeof args[0] !== 'string' || !args[0].includes('react-test-renderer is deprecated')) {
throw new Error(String(args[0]))
}
})
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
vi.restoreAllMocks()
})
it('waits for the new worktree snapshot after route reuse', async () => {
await render({
newlyCreatedWorkspace: false,
terminalsLoaded: true,
visibleTabCount: 0,
worktreeId: 'existing'
})
await render({
newlyCreatedWorkspace: true,
terminalsLoaded: false,
visibleTabCount: 0,
worktreeId: 'new'
})
expect(consumeCreationRoute).not.toHaveBeenCalled()
expect(createTerminal).not.toHaveBeenCalled()
await render({
newlyCreatedWorkspace: true,
terminalsLoaded: true,
visibleTabCount: 0,
worktreeId: 'new'
})
expect(consumeCreationRoute).not.toHaveBeenCalled()
expect(createTerminal).toHaveBeenCalledOnce()
await render({
newlyCreatedWorkspace: true,
terminalsLoaded: true,
visibleTabCount: 1,
worktreeId: 'new'
})
expect(consumeCreationRoute).toHaveBeenCalledOnce()
expect(createTerminal).toHaveBeenCalledOnce()
})
it('consumes a populated creation route before a later remount', async () => {
await render({
newlyCreatedWorkspace: true,
terminalsLoaded: true,
visibleTabCount: 1,
worktreeId: 'new'
})
expect(consumeCreationRoute).toHaveBeenCalledOnce()
expect(createTerminal).not.toHaveBeenCalled()
act(() => renderer?.unmount())
renderer = null
stateRef = {
current: { autoCreatedForWorktree: null, sawSessionTabs: false }
}
await render({
newlyCreatedWorkspace: false,
terminalsLoaded: true,
visibleTabCount: 0,
worktreeId: 'new'
})
expect(createTerminal).not.toHaveBeenCalled()
})
it.each([
{ connected: false, hasClient: true },
{ connected: true, hasClient: false }
])(
'keeps the creation route armed until a client reconnects (connected=$connected, hasClient=$hasClient)',
async ({ connected, hasClient }) => {
await render({
newlyCreatedWorkspace: true,
terminalsLoaded: true,
visibleTabCount: 0,
worktreeId: 'new',
connected,
hasClient
})
expect(consumeCreationRoute).not.toHaveBeenCalled()
expect(createTerminal).not.toHaveBeenCalled()
await render({
newlyCreatedWorkspace: true,
terminalsLoaded: true,
visibleTabCount: 0,
worktreeId: 'new'
})
expect(consumeCreationRoute).not.toHaveBeenCalled()
expect(createTerminal).toHaveBeenCalledOnce()
}
)
})
describe('useWorktreeSessionTabsLoaded', () => {
let renderer: ReactTestRenderer | null = null
let current: readonly [boolean, (loaded: boolean) => void] | null = null
function Harness({ worktreeId }: { worktreeId: string }): null {
current = useWorktreeSessionTabsLoaded(worktreeId)
return null
}
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
current = null
})
it('does not carry a loaded snapshot across a reused route', async () => {
await act(async () => {
renderer = create(createElement(Harness, { worktreeId: 'existing' }))
})
act(() => current?.[1](true))
expect(current?.[0]).toBe(true)
await act(async () => {
renderer?.update(createElement(Harness, { worktreeId: 'new' }))
})
expect(current?.[0]).toBe(false)
})
})
@@ -0,0 +1,106 @@
import { useEffect, useEffectEvent, useReducer, type RefObject } from 'react'
import { shouldAutoCreateInitialSessionTerminal } from './initial-session-terminal'
type InitialSessionTerminalAutoCreateState = {
autoCreatedForWorktree: string | null
sawSessionTabs: boolean
}
/**
* Fresh auto-create bookkeeping. The route must re-create this on every worktree
* change, or a revisit inherits the previous workspace's `sawSessionTabs`.
*/
export function createInitialSessionAutoCreateState(): InitialSessionTerminalAutoCreateState {
return { autoCreatedForWorktree: null, sawSessionTabs: false }
}
type InitialSessionTerminalAutoCreateArgs = {
client: unknown
newlyCreatedWorkspace: boolean
connState: string
terminalsLoaded: boolean
visibleTabCount: number
activeHandle: string | null
createInFlight: boolean
stateRef: RefObject<InitialSessionTerminalAutoCreateState>
worktreeId: string
consumeCreationRoute: () => void
createTerminal: () => void
}
/**
* Creates the first terminal of a newly created mobile workspace that hydrates
* empty, at most once per route. See initial-session-terminal.
*/
export function useInitialSessionTerminalAutoCreate(
args: InitialSessionTerminalAutoCreateArgs
): void {
const {
client,
newlyCreatedWorkspace,
connState,
terminalsLoaded,
visibleTabCount,
activeHandle,
createInFlight,
stateRef,
worktreeId
} = args
// Why: the route re-creates both callbacks every render; useEffectEvent keeps them
// out of the deps without mutating a ref during render.
const consumeCreationRoute = useEffectEvent(args.consumeCreationRoute)
const createTerminal = useEffectEvent(args.createTerminal)
useEffect(() => {
if (
newlyCreatedWorkspace &&
client &&
connState === 'connected' &&
terminalsLoaded &&
(visibleTabCount > 0 || activeHandle !== null)
) {
consumeCreationRoute()
}
if (
!client ||
!shouldAutoCreateInitialSessionTerminal({
newlyCreatedWorkspace,
connected: connState === 'connected',
tabsLoaded: terminalsLoaded,
visibleTabCount,
hasActiveTerminalHandle: activeHandle !== null,
createInFlight,
sawSessionTabs: stateRef.current.sawSessionTabs,
autoCreatedForWorktree: stateRef.current.autoCreatedForWorktree === worktreeId
})
) {
return
}
stateRef.current.autoCreatedForWorktree = worktreeId
createTerminal()
}, [
activeHandle,
client,
connState,
createInFlight,
newlyCreatedWorkspace,
stateRef,
terminalsLoaded,
visibleTabCount,
worktreeId
])
}
/**
* Tracks "tabs hydrated" per worktree so a reused route reports `false` until the
* new workspace's own snapshot lands, rather than inheriting the old one's.
*/
export function useWorktreeSessionTabsLoaded(
worktreeId: string
): readonly [boolean, (loaded: boolean) => void] {
const [loadedForWorktree, setLoaded] = useReducer(
(_current: string | null, loaded: boolean) => (loaded ? worktreeId : null),
null
)
return [loadedForWorktree === worktreeId, setLoaded]
}
@@ -35,6 +35,8 @@ vi.mock('./use-mobile-native-chat-drafts', () => ({
setComposerText: vi.fn(),
pending: [],
captureSendOrigin,
readSeededLaunchDraft: () => null,
readSeededLaunchDraftSeed: () => null,
clearDraftForSend,
restoreRejectedDraft,
acceptSend,
@@ -327,6 +329,7 @@ describe('useMobileNativeChatController launch-draft wiring', () => {
terminal: 'term-1',
launchAgent: 'claude',
launchDraft: 'https://github.com/o/r/issues/12',
launchDraftCreatedAt: 7,
isActive: true
}
@@ -385,6 +388,7 @@ describe('useMobileNativeChatController launch-draft wiring', () => {
expect(draftsArgs.at(-1)).toMatchObject({
tabId: 'tab-1',
launchDraft: 'https://github.com/o/r/issues/12',
launchDraftCreatedAt: 7,
chatActive: true,
transcriptLoading: false
})
@@ -73,6 +73,9 @@ export type MobileNativeChatController = {
images?: string[],
deadline?: number
) => Promise<MobileNativeChatSendOutcome>
/** Launch-context text still parked on the agent's TUI input line, or null.
* Image sends read it to size their leading clear (one Ctrl+U per line). */
readSeededLaunchDraft: () => string | null
}
/** Owns mobile native-chat state and teardown outside the already dense session
@@ -134,6 +137,8 @@ export function useMobileNativeChatController(args: {
setComposerText: setChatComposerText,
pending: chatPending,
captureSendOrigin,
readSeededLaunchDraft,
readSeededLaunchDraftSeed,
clearDraftForSend,
restoreRejectedDraft,
acceptSend,
@@ -145,6 +150,7 @@ export function useMobileNativeChatController(args: {
sessionId: activeChatSessionId,
messages: nativeChatSession.messages,
launchDraft: activeSessionTab?.launchDraft ?? null,
launchDraftCreatedAt: activeSessionTab?.launchDraftCreatedAt ?? null,
// Why: pass the raw draft plus this flag rather than nulling it off-chat —
// a null is indistinguishable from a host retraction, and peeking at the
// terminal view would permanently decline the prefill.
@@ -243,6 +249,7 @@ export function useMobileNativeChatController(args: {
handleRef: activeHandleRef,
deviceTokenRef,
captureSendOrigin,
readSeededLaunchDraftSeed,
clearDraftForSend,
restoreRejectedDraft,
acceptSend,
@@ -278,6 +285,7 @@ export function useMobileNativeChatController(args: {
loadNativeChatFiles,
handleNativeChatQuestionAnswer,
handleNativeChatSend,
handleNativeChatSendWithOutcome
handleNativeChatSendWithOutcome,
readSeededLaunchDraft
}
}
@@ -38,6 +38,7 @@ describe('useMobileNativeChatDrafts launch draft', () => {
sessionId = `session-${tabId}`,
messages = [],
launchDraft = null,
launchDraftCreatedAt = null,
chatActive = true,
transcriptLoading = false
}: {
@@ -45,6 +46,7 @@ describe('useMobileNativeChatDrafts launch draft', () => {
sessionId?: string | null
messages?: NativeChatMessage[]
launchDraft?: string | null
launchDraftCreatedAt?: number | null
chatActive?: boolean
transcriptLoading?: boolean
}): null {
@@ -55,6 +57,7 @@ describe('useMobileNativeChatDrafts launch draft', () => {
sessionId,
messages,
launchDraft,
launchDraftCreatedAt,
chatActive,
transcriptLoading
})
@@ -97,6 +100,21 @@ describe('useMobileNativeChatDrafts launch draft', () => {
expect(state?.composerText).toBe('')
})
it('captures the generation paired with the adopted text', async () => {
await mount('a')
await act(async () =>
renderer?.update(
createElement(Harness, {
tabId: 'a',
launchDraft: 'issue link',
launchDraftCreatedAt: 7
})
)
)
expect(state?.readSeededLaunchDraftSeed()).toEqual({ text: 'issue link', createdAt: 7 })
})
it('does not overwrite typed composer text with a launch draft', async () => {
await mount('a')
act(() => state?.setComposerText('typed first'))
@@ -8,6 +8,8 @@ import {
type UnconfirmedSend
} from './mobile-native-chat-draft-reconcile'
import { mobileNativeChatScopeKey } from './mobile-native-chat-scope-key'
import { useMobileNativeChatLaunchDraftSeed } from './use-mobile-native-chat-launch-draft-seed'
import type { MobileNativeChatLaunchDraftSeed } from './use-mobile-native-chat-launch-draft-seed'
export type MobileNativeChatPendingMessage = {
id: string
@@ -43,6 +45,7 @@ export function useMobileNativeChatDrafts(args: {
messages: readonly NativeChatMessage[]
/** Host-provided launch context still parked as an unsent TUI-input draft. */
launchDraft?: string | null
launchDraftCreatedAt?: number | null
/** Whether the tab is currently resolved to the chat view. Off-chat the
* launch-draft effects hold their state instead of acting on it. */
chatActive?: boolean
@@ -55,6 +58,11 @@ export function useMobileNativeChatDrafts(args: {
setComposerText: Dispatch<SetStateAction<string>>
pending: MobileNativeChatPendingMessage[]
captureSendOrigin: (text: string) => MobileNativeChatSendOrigin | null
/** Launch-context text still believed to be parked on the agent's TUI input
* line, or null once it has been declined or retired. Send paths size their
* pre-clear from it, since one Ctrl+U clears only one logical line. */
readSeededLaunchDraft: () => string | null
readSeededLaunchDraftSeed: () => MobileNativeChatLaunchDraftSeed | null
/** Clear the composer at send time, before the RPC settles. */
clearDraftForSend: (origin: MobileNativeChatSendOrigin, text: string) => void
/** Put the text back after a definite rejection, unless newer edits exist. */
@@ -73,6 +81,7 @@ export function useMobileNativeChatDrafts(args: {
sessionId,
messages,
launchDraft,
launchDraftCreatedAt,
chatActive = true,
transcriptLoading
} = args
@@ -91,62 +100,15 @@ export function useMobileNativeChatDrafts(args: {
activePendingKeyRef.current = pendingKey
const mountedRef = useRef(false)
// Seeded launch-context text per tab; '' marks a permanent decline so a
// cleared composer never resurrects the prefill.
const seededLaunchDraftByKeyRef = useRef(new Map<string, string>())
// Why: launch context delivered as a TUI-input prefill is invisible in chat;
// adopt it once as the composer draft so mobile shows the same context.
useEffect(() => {
if (
!draftKey ||
!chatActive ||
!launchDraft?.trim() ||
seededLaunchDraftByKeyRef.current.has(draftKey)
) {
return
}
// Why: `session.tabs` carries launchDraft before the transcript read settles,
// and an empty (or previous tab's) list would let the decline below misjudge
// an already-submitted prefill — long enough for a send to duplicate it.
if (transcriptLoading) {
return
}
// A user turn already in the transcript means the one-line TUI prefill was
// submitted or deliberately cleared; decline instead of resurrecting it.
if (messages.some((message) => normalizedUserText(message) !== null)) {
seededLaunchDraftByKeyRef.current.set(draftKey, '')
return
}
seededLaunchDraftByKeyRef.current.set(draftKey, launchDraft)
setDrafts((previous) =>
(previous[draftKey] ?? '') === '' ? { ...previous, [draftKey]: launchDraft } : previous
)
}, [chatActive, draftKey, launchDraft, messages, transcriptLoading])
// Drop an untouched adopted copy once the prefill is resolved elsewhere — a
// user turn landed (sent or cleared TUI-side) or the host stopped publishing
// it (desktop sent or reconciled it). User edits are always kept.
useEffect(() => {
// Same gates as the seed: off-chat there is no retraction to read (the tab
// publishes no draft to us), and an untrusted transcript would wipe an
// untouched copy on the strength of another tab's user turns.
if (!draftKey || !chatActive || transcriptLoading) {
return
}
const seeded = seededLaunchDraftByKeyRef.current.get(draftKey)
if (!seeded) {
return
}
const hasUserTurn = messages.some((message) => normalizedUserText(message) !== null)
if (!hasUserTurn && launchDraft?.trim()) {
return
}
seededLaunchDraftByKeyRef.current.set(draftKey, '')
setDrafts((previous) =>
(previous[draftKey] ?? '') === seeded ? { ...previous, [draftKey]: '' } : previous
)
}, [chatActive, draftKey, launchDraft, messages, transcriptLoading])
const { readSeededLaunchDraft, readSeededLaunchDraftSeed } = useMobileNativeChatLaunchDraftSeed({
draftKey,
messages,
launchDraft,
launchDraftCreatedAt,
chatActive,
transcriptLoading,
setDrafts
})
const setComposerText: Dispatch<SetStateAction<string>> = useCallback(
(value) => {
@@ -358,6 +320,8 @@ export function useMobileNativeChatDrafts(args: {
setComposerText,
pending,
captureSendOrigin,
readSeededLaunchDraft,
readSeededLaunchDraftSeed,
clearDraftForSend,
restoreRejectedDraft,
acceptSend,
@@ -1,5 +1,6 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcResponse, RpcSuccess } from '../transport/types'
@@ -69,6 +70,7 @@ function baseArgs(overrides: Partial<HookArgs> & Pick<HookArgs, 'client'>): Hook
showToast: vi.fn(),
onSendError: vi.fn(),
baseSend: vi.fn().mockResolvedValue('accepted'),
readSeededLaunchDraft: () => null,
sleep: async () => {},
...overrides
}
@@ -201,6 +203,40 @@ describe('useMobileNativeChatImageAttachments', () => {
expect(hook!.attachments).toEqual([])
})
it('leads the image paste with a clear sized to a parked multi-line launch draft', async () => {
// A single Ctrl+U kills only the last line, so the draft's earlier lines
// would survive the clear and ride along with the image as prompt body.
pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
const client = makeClient([
methodNotFound('start'),
ok('save', '/tmp/a.png'),
sendResult(true),
sendResult(true)
])
const draft = 'Linked Linear issue: ABC-123\nhttps://linear.app/x/issue/ABC-123'
mount(
baseArgs({
client: client as unknown as RpcClient,
deviceTokenRef: { current: 'device-1' },
readSeededLaunchDraft: () => draft
})
)
await act(async () => {
await hook!.attachImage('library')
})
await act(async () => {
await hook!.sendNativeChat('look at this')
})
const firstSend = client.calls.find((c) => c.method === 'terminal.send')
expect(firstSend?.params).toMatchObject({
text: buildAgentTuiClearInputForText(draft),
enter: false
})
expect(firstSend?.params.text).not.toBe('\x15')
})
it('spends one budget across the image paste and the text body that follows', async () => {
vi.useFakeTimers()
try {
@@ -1,5 +1,6 @@
import { useCallback, useRef, useState } from 'react'
import { CLIPBOARD_IMAGE_TOO_LARGE_ERROR } from '../../../src/shared/clipboard-image'
import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear'
import type { RpcClient } from '../transport/rpc-client'
import type { ConnectionState } from '../transport/types'
import {
@@ -56,6 +57,10 @@ type Args = {
imagePreviewUris?: string[],
deadline?: number
) => Promise<MobileNativeChatSendOutcome>
/** Launch-context text parked on the agent's TUI input line, or null. The
* paste's leading clear must cover every line of it, or the draft's earlier
* lines survive and ride along with the image. */
readonly readSeededLaunchDraft: () => string | null
readonly onAttachSuccess?: () => void
readonly onError?: () => void
// Injected so the settle between image paste and submit is instant in tests.
@@ -106,6 +111,7 @@ export function useMobileNativeChatImageAttachments({
showToast,
onSendError,
baseSend,
readSeededLaunchDraft,
onAttachSuccess,
onError,
sleep = defaultSleep
@@ -269,12 +275,16 @@ export function useMobileNativeChatImageAttachments({
return false
}
try {
const seededLaunchDraft = readSeededLaunchDraft()
const pasted = await pasteMobileNativeChatImagePaths({
client,
terminal: handle,
deviceToken: deviceTokenRef.current,
imagePaths: pendingImages.map((attachment) => attachment.path),
deadline
deadline,
...(seededLaunchDraft
? { clearInput: buildAgentTuiClearInputForText(seededLaunchDraft) }
: {})
})
if (!pasted) {
// Keep the chips so the user can retry; the failed paste never submitted.
@@ -351,6 +361,7 @@ export function useMobileNativeChatImageAttachments({
enabled,
onError,
onSendError,
readSeededLaunchDraft,
scopeKey,
sleep
]
@@ -0,0 +1,122 @@
import { useCallback, useEffect, useRef, type Dispatch, type SetStateAction } from 'react'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import { normalizedUserText } from './mobile-native-chat-draft-reconcile'
export type MobileNativeChatLaunchDraftSeed = {
text: string
createdAt: number | null
}
/**
* Adopting the host's launch-context prefill as the mobile composer draft, and
* retiring it again once it is resolved elsewhere. Split out of the drafts hook
* so the general draft/pending accounting stays separate from this one concern.
*/
export function useMobileNativeChatLaunchDraftSeed(args: {
draftKey: string | null
messages: readonly NativeChatMessage[]
/** Host-provided launch context still parked as an unsent TUI-input draft. */
launchDraft?: string | null
launchDraftCreatedAt?: number | null
chatActive: boolean
transcriptLoading?: boolean
setDrafts: Dispatch<SetStateAction<Record<string, string>>>
}): {
/** Text still believed to be parked on the agent's TUI input line, or null
* once declined or retired. Send paths size their pre-clear from it, since
* one Ctrl+U clears only one logical line. */
readSeededLaunchDraft: () => string | null
readSeededLaunchDraftSeed: () => MobileNativeChatLaunchDraftSeed | null
} {
const {
draftKey,
messages,
launchDraft,
launchDraftCreatedAt,
chatActive,
transcriptLoading,
setDrafts
} = args
// Seeded launch-context text per tab; null marks a permanent decline so a
// cleared composer never resurrects the prefill.
const seededLaunchDraftByKeyRef = useRef(
new Map<string, MobileNativeChatLaunchDraftSeed | null>()
)
// Why: launch context delivered as a TUI-input prefill is invisible in chat;
// adopt it once as the composer draft so mobile shows the same context.
useEffect(() => {
if (
!draftKey ||
!chatActive ||
!launchDraft?.trim() ||
seededLaunchDraftByKeyRef.current.has(draftKey)
) {
return
}
// Why: `session.tabs` carries launchDraft before the transcript read settles,
// and an empty (or previous tab's) list would let the decline below misjudge
// an already-submitted prefill — long enough for a send to duplicate it.
if (transcriptLoading) {
return
}
// A user turn already in the transcript means the TUI prefill was submitted
// or deliberately cleared; decline instead of resurrecting it.
if (messages.some((message) => normalizedUserText(message) !== null)) {
seededLaunchDraftByKeyRef.current.set(draftKey, null)
return
}
seededLaunchDraftByKeyRef.current.set(draftKey, {
text: launchDraft,
createdAt: launchDraftCreatedAt ?? null
})
setDrafts((previous) =>
(previous[draftKey] ?? '') === '' ? { ...previous, [draftKey]: launchDraft } : previous
)
}, [
chatActive,
draftKey,
launchDraft,
launchDraftCreatedAt,
messages,
setDrafts,
transcriptLoading
])
// Drop an untouched adopted copy once the prefill is resolved elsewhere — a
// user turn landed (sent or cleared TUI-side) or the host stopped publishing
// it (desktop sent or reconciled it). User edits are always kept.
useEffect(() => {
// Same gates as the seed: off-chat there is no retraction to read (the tab
// publishes no draft to us), and an untrusted transcript would wipe an
// untouched copy on the strength of another tab's user turns.
if (!draftKey || !chatActive || transcriptLoading) {
return
}
const seeded = seededLaunchDraftByKeyRef.current.get(draftKey)
if (!seeded) {
return
}
const hasUserTurn = messages.some((message) => normalizedUserText(message) !== null)
if (!hasUserTurn && launchDraft?.trim()) {
return
}
seededLaunchDraftByKeyRef.current.set(draftKey, null)
setDrafts((previous) =>
(previous[draftKey] ?? '') === seeded.text ? { ...previous, [draftKey]: '' } : previous
)
}, [chatActive, draftKey, launchDraft, messages, setDrafts, transcriptLoading])
// A missing or declined entry means there is nothing of ours on the TUI line.
const readSeededLaunchDraft = useCallback(
() => (draftKey ? (seededLaunchDraftByKeyRef.current.get(draftKey)?.text ?? null) : null),
[draftKey]
)
const readSeededLaunchDraftSeed = useCallback(
() => (draftKey ? (seededLaunchDraftByKeyRef.current.get(draftKey) ?? null) : null),
[draftKey]
)
return { readSeededLaunchDraft, readSeededLaunchDraftSeed }
}
@@ -0,0 +1,176 @@
// Covers the wiring the image-attachments suite structurally cannot: that hook
// injects its own baseSend stub, so it never observes the real send params.
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const sendWithOutcome = vi.fn()
const clearInputWrite = vi.fn()
vi.mock('./mobile-native-chat-send', () => ({
sendMobileNativeChatMessageWithOutcome: (...args: unknown[]) => sendWithOutcome(...args),
clearMobileNativeChatInput: (...args: unknown[]) => clearInputWrite(...args),
openMobileNativeChatSendBudget: () => Date.now() + 15_000,
MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS: 15_000,
MOBILE_NATIVE_CHAT_MIN_WRITE_TIMEOUT_MS: 2_000
}))
vi.mock('./mobile-native-chat-stale-input', () => ({
healMobileNativeChatStaleInput: () => Promise.resolve(true)
}))
import { useMobileNativeChatMessageSend } from './use-mobile-native-chat-message-send'
import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear'
type Send = ReturnType<typeof useMobileNativeChatMessageSend>
const DRAFT = 'Linked Linear issue: ABC-123\nhttps://linear.app/x/issue/ABC-123'
describe('useMobileNativeChatMessageSend', () => {
let renderer: ReactTestRenderer | null = null
let api: Send | null = null
const mount = (
readSeededLaunchDraftSeed: () => { text: string; createdAt: number | null } | null
): void => {
function Probe(): null {
api = useMobileNativeChatMessageSend({
client: { sendRequest: vi.fn() } as never,
enabled: true,
handleRef: { current: 'term' },
deviceTokenRef: { current: 'device' },
captureSendOrigin: () => ({ draftKey: 'k', pendingKey: 'p' }) as never,
readSeededLaunchDraftSeed,
clearDraftForSend: () => {},
restoreRejectedDraft: () => {},
acceptSend: () => {},
holdUnconfirmedSend: () => {},
onSendError: () => {}
})
return null
}
act(() => {
renderer = create(createElement(Probe))
})
}
const sentArgs = (): {
clearInputFirst?: boolean
resolvedLaunchDraft?: { text: string; createdAt: number }
} =>
sendWithOutcome.mock.calls[0]![0] as {
clearInputFirst?: boolean
resolvedLaunchDraft?: { text: string; createdAt: number }
}
const clearArgs = (): { clearInput?: string } =>
(clearInputWrite.mock.calls[0]?.[0] ?? {}) as { clearInput?: string }
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
sendWithOutcome.mockReset()
sendWithOutcome.mockResolvedValue('accepted')
clearInputWrite.mockReset()
clearInputWrite.mockResolvedValue(true)
})
afterEach(() => {
act(() => {
renderer?.unmount()
})
renderer = null
api = null
})
it('sizes the pre-clear to every line of a parked launch draft', async () => {
mount(() => ({ text: DRAFT, createdAt: 1 }))
await act(async () => {
await api!.send('hello')
})
expect(clearArgs().clearInput).toBe(buildAgentTuiClearInputForText(DRAFT))
})
it('issues the burst as its OWN write, before the body', async () => {
// Bundled into the body write it arrived as literal Ctrl+U text.
mount(() => ({ text: DRAFT, createdAt: 1 }))
await act(async () => {
await api!.send('hello')
})
expect(clearInputWrite).toHaveBeenCalledTimes(1)
expect(sendWithOutcome).toHaveBeenCalledTimes(1)
expect(clearInputWrite.mock.invocationCallOrder[0]).toBeLessThan(
sendWithOutcome.mock.invocationCallOrder[0]!
)
})
it('aborts without sending the body when the clear is rejected', async () => {
// Sending on top of an uncleared line is exactly the concatenation bug.
clearInputWrite.mockResolvedValue(false)
mount(() => ({ text: DRAFT, createdAt: 1 }))
let result: boolean | undefined
await act(async () => {
result = await api!.send('hello')
})
expect(result).toBe(false)
expect(sendWithOutcome).not.toHaveBeenCalled()
})
it('drops the body write\u2019s own Ctrl+U prefix once the dedicated clear ran', async () => {
// A Ctrl+U written immediately before body text in the SAME write arrives as
// a literal control character, so it would head the received message.
mount(() => ({ text: DRAFT, createdAt: 1 }))
await act(async () => {
await api!.send('hello')
})
expect(sentArgs().clearInputFirst).toBe(false)
expect(sentArgs().resolvedLaunchDraft).toEqual({ text: DRAFT, createdAt: 1 })
})
it('keeps the single-Ctrl+U prefix when no dedicated clear ran', async () => {
mount(() => null)
await act(async () => {
await api!.send('hello')
})
expect(sentArgs().clearInputFirst).toBe(true)
expect(sentArgs().resolvedLaunchDraft).toBeUndefined()
})
it('writes no clear at all when nothing is parked on the line', async () => {
mount(() => null)
await act(async () => {
await api!.send('hello')
})
expect(clearInputWrite).not.toHaveBeenCalled()
})
it('reads the draft at send time, so a retired seed stops widening the clear', async () => {
let parked: { text: string; createdAt: number } | null = { text: DRAFT, createdAt: 1 }
mount(() => parked)
await act(async () => {
await api!.send('first')
})
parked = null
await act(async () => {
await api!.send('second')
})
expect(sendWithOutcome.mock.calls[1]![0]).toMatchObject({ clearInputFirst: true })
expect(clearInputWrite).toHaveBeenCalledTimes(1)
expect(sendWithOutcome.mock.calls[0]![0]).toMatchObject({ clearInputFirst: false })
})
it('does not clear an image send after the image was pasted', async () => {
// A second clear here would wipe the image that was just pasted.
mount(() => ({ text: DRAFT, createdAt: 1 }))
await act(async () => {
await api!.send('caption', ['file:///a.png'])
})
expect(clearInputWrite).not.toHaveBeenCalled()
expect(sentArgs().clearInputFirst).toBe(false)
expect(sentArgs().resolvedLaunchDraft).toEqual({ text: DRAFT, createdAt: 1 })
})
it('does not resolve a composer seed from a question-card answer', async () => {
mount(() => ({ text: DRAFT, createdAt: 1 }))
await act(async () => {
await api!.answerQuestion('1')
})
expect(sentArgs().resolvedLaunchDraft).toBeUndefined()
})
})
@@ -1,12 +1,15 @@
import { useCallback, type MutableRefObject } from 'react'
import type { RpcClient } from '../transport/rpc-client'
import {
clearMobileNativeChatInput,
openMobileNativeChatSendBudget,
sendMobileNativeChatMessageWithOutcome,
type MobileNativeChatSendOutcome
} from './mobile-native-chat-send'
import { healMobileNativeChatStaleInput } from './mobile-native-chat-stale-input'
import type { MobileNativeChatSendOrigin } from './use-mobile-native-chat-drafts'
import type { MobileNativeChatLaunchDraftSeed } from './use-mobile-native-chat-launch-draft-seed'
import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear'
export type MobileNativeChatMessageSend = {
/** Composer send that syncs the draft (clear on send, restore on rejection). */
@@ -32,6 +35,9 @@ export function useMobileNativeChatMessageSend(args: {
handleRef: MutableRefObject<string | null>
deviceTokenRef: MutableRefObject<string | null>
captureSendOrigin: (text: string) => MobileNativeChatSendOrigin | null
/** Launch-context text Orca parked on the agent's TUI input line, or null. Read
* at send time so the pre-clear can be sized to every line it occupies. */
readSeededLaunchDraftSeed: () => MobileNativeChatLaunchDraftSeed | null
clearDraftForSend: (origin: MobileNativeChatSendOrigin, text: string) => void
restoreRejectedDraft: (origin: MobileNativeChatSendOrigin, text: string) => void
acceptSend: (origin: MobileNativeChatSendOrigin, text: string, images?: string[]) => void
@@ -48,6 +54,7 @@ export function useMobileNativeChatMessageSend(args: {
handleRef,
deviceTokenRef,
captureSendOrigin,
readSeededLaunchDraftSeed,
clearDraftForSend,
restoreRejectedDraft,
acceptSend,
@@ -95,6 +102,36 @@ export function useMobileNativeChatMessageSend(args: {
if (syncComposer) {
clearDraftForSend(origin, text)
}
// Why: a parked launch draft is routinely multi-line, and one Ctrl+U clears
// only one logical line. Size the clear to the text Orca injected, with
// slack — the user can also have typed into the TUI line directly, so that
// line count is a lower bound. Mobile cannot read the agent's screen, so
// there is no empty-line observable to confirm against here; the upper
// bound plus the host's write acceptance is what makes it safe.
//
// The burst goes out as its OWN write: bundled into the body write it
// arrived as literal Ctrl+U text and the draft concatenated (see
// clearMobileNativeChatInput). A rejected clear aborts the send rather
// than pasting on top of an uncleared line.
const seededLaunchDraft = readSeededLaunchDraftSeed()
if (seededLaunchDraft && !images?.length) {
const cleared = await clearMobileNativeChatInput({
client,
terminal: handle,
clearInput: buildAgentTuiClearInputForText(seededLaunchDraft.text),
deadline,
...(deviceTokenRef.current
? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } }
: {})
})
if (!cleared) {
if (syncComposer) {
restoreRejectedDraft(origin, text)
}
onSendError('Message not sent')
return 'rejected'
}
}
const outcome = await sendMobileNativeChatMessageWithOutcome({
client,
terminal: handle,
@@ -105,7 +142,20 @@ export function useMobileNativeChatMessageSend(args: {
// this message. An image send already led its own paste with Ctrl+U, and a
// second one here would wipe the image it just pasted (desktop's image path
// likewise clears once, before the paste, and never again).
clearInputFirst: !images?.length,
//
// Also skipped once the dedicated clear above ran: the line is already
// empty, and a Ctrl+U written immediately before body text in the SAME
// write reaches the agent as a literal control character rather than a
// keypress (observed live as a stray \x15 heading the received message).
clearInputFirst: !images?.length && !seededLaunchDraft,
...(syncComposer && typeof seededLaunchDraft?.createdAt === 'number'
? {
resolvedLaunchDraft: {
text: seededLaunchDraft.text,
createdAt: seededLaunchDraft.createdAt
}
}
: {}),
deadline,
...(deviceTokenRef.current
? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } }
@@ -141,6 +191,7 @@ export function useMobileNativeChatMessageSend(args: {
handleRef,
holdUnconfirmedSend,
onSendError,
readSeededLaunchDraftSeed,
restoreRejectedDraft
]
)
@@ -31,6 +31,9 @@ type Args = {
images?: string[],
deadline?: number
) => Promise<MobileNativeChatSendOutcome>
/** Launch-context text parked on the agent's TUI input line, or null — sizes
* the image paste's leading clear so a multi-line draft cannot ride along. */
readonly readSeededLaunchDraft: () => string | null
readonly showToast: (message: string, durationMs?: number) => void
/** Native-chat send failures — rendered in the composer's inline banner. */
readonly onNativeChatSendError: (message: string) => void
@@ -54,6 +57,7 @@ export function useMobileSessionImageAttachments({
getActiveWorktreeConnectionId,
beforeTerminalSend,
nativeChatBaseSend,
readSeededLaunchDraft,
showToast,
onNativeChatSendError,
onSuccess,
@@ -86,6 +90,7 @@ export function useMobileSessionImageAttachments({
showToast,
onSendError: onNativeChatSendError,
baseSend: nativeChatBaseSend,
readSeededLaunchDraft,
onAttachSuccess: onSuccess,
onError
})
+21 -6
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import { buildGitHubCheckSummary } from './github-check-summary'
import { buildGitLabCheckSummary } from './gitlab-check-summary'
describe('buildGitHubCheckSummary', () => {
it('returns none for empty check lists', () => {
@@ -8,7 +9,8 @@ describe('buildGitHubCheckSummary', () => {
total: 0,
passed: 0,
failed: 0,
pending: 0
pending: 0,
neutral: 0
})
})
@@ -24,22 +26,35 @@ describe('buildGitHubCheckSummary', () => {
total: 3,
passed: 1,
failed: 1,
pending: 1
pending: 1,
neutral: 0
})
})
it('marks all completed non-failing checks as successful', () => {
it('keeps neutral and unknown terminal conclusions out of passed', () => {
expect(
buildGitHubCheckSummary([
{ status: 'completed', conclusion: 'success' },
{ status: 'completed', conclusion: 'neutral' }
])
).toEqual({
state: 'success',
state: 'neutral',
total: 2,
passed: 2,
passed: 1,
failed: 0,
pending: 0
pending: 0,
neutral: 1
})
})
it('rolls up GitLab jobs with unknown terminal statuses as neutral', () => {
expect(buildGitLabCheckSummary([{ status: 'success' }, { status: 'future_status' }])).toEqual({
state: 'neutral',
total: 2,
passed: 1,
failed: 0,
pending: 0,
neutral: 1
})
})
})
+20 -5
View File
@@ -4,11 +4,12 @@ export type GitHubCheckLike = {
}
export type GitHubCheckSummary = {
state: 'success' | 'failure' | 'pending' | 'none'
state: 'success' | 'failure' | 'pending' | 'neutral' | 'none'
total: number
passed: number
failed: number
pending: number
neutral: number
}
function isFailedCheck(check: GitHubCheckLike): boolean {
@@ -28,18 +29,32 @@ function isPendingCheck(check: GitHubCheckLike): boolean {
export function buildGitHubCheckSummary(checks: GitHubCheckLike[]): GitHubCheckSummary {
let failed = 0
let pending = 0
let neutral = 0
for (const check of checks) {
if (isFailedCheck(check)) {
if (isFailedCheck(check) || check.conclusion === 'action_required') {
failed += 1
} else if (isPendingCheck(check)) {
pending += 1
} else if (check.conclusion === 'success') {
continue
} else {
neutral += 1
}
}
const total = checks.length
const passed = Math.max(0, total - failed - pending)
const state = total === 0 ? 'none' : failed > 0 ? 'failure' : pending > 0 ? 'pending' : 'success'
const passed = total - failed - pending - neutral
const state =
total === 0
? 'none'
: failed > 0
? 'failure'
: pending > 0
? 'pending'
: neutral > 0
? 'neutral'
: 'success'
return { state, total, passed, failed, pending }
return { state, total, passed, failed, pending, neutral }
}

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