Merge main into sta-3332-mobile-model-picker

Reconciles this PR's shared extraction with STA-3330 (#12369), which changed the
snapshot's caller contract, and with the per-terminal write lock that landed from
the STA-3333 splits.

- The snapshot builder no longer self-heals an unlisted tracked model into its own
  choices; callers reconcile it in first. #12369 added `withTrackedModel` in the
  renderer for that. It is pure logic both platforms need, so it moves to
  `src/shared` as `withTrackedNativeChatModel`, desktop imports it instead of
  keeping a copy, and every mobile consumer (snapshot, command build, option
  lookup, typed-command recording) now goes through the same reconciled list.
- Typed model values are validated against the active list AND the seed. Validating
  against the active list alone would have rejected an alias this host's CLI no
  longer lists — exactly the case #12369's reconciliation exists to preserve.
- `dispatchCommand` now takes the per-terminal write lock that main's
  `answerQuestion` uses. That is the real mutual exclusion for the picker-vs-send
  interleave, replacing the UI-level guard as the load-bearing fix; the disabled
  pills stay as the affordance.
This commit is contained in:
Brennan Benson
2026-08-04 16:24:27 -07:00
786 changed files with 58837 additions and 7107 deletions
+84 -9
View File
@@ -33,6 +33,13 @@ name: Adhoc macOS Dev Build
# Contents: Read and write. The secret names below are historical — one App, one
# private key, both dev-channel repos — and rotating it stays a single operation.
# Provision with `bash config/scripts/setup-hourly-release-token.sh`.
#
# The requested ref is vetted before checkout: it must be a branch or tag of this
# repo, or a commit reachable from one. PR refs are refused outright — this
# workflow runs the checked-out code next to MAC_CERTS and the notary password,
# so "just build that community PR" must not become a way to hand fork code the
# release identity. A branch here always belongs to someone with write access,
# which is the same trust the dispatch button itself already requires.
on:
workflow_dispatch:
@@ -45,7 +52,7 @@ on:
# than the one the workflow file itself is read from — normally leave the
# picker on main and name your branch here, so a stale copy of this
# workflow on an old branch is not what runs.
description: 'Branch, tag, or SHA to build (default: the branch selected above)'
description: 'Branch, tag, or SHA to build — must live in stablyai/orca; PR refs are refused (default: the branch selected above)'
required: false
default: ''
type: string
@@ -77,6 +84,14 @@ env:
jobs:
build-adhoc-mac:
if: github.repository == 'stablyai/orca'
# Why an environment: it gives the signing/notary/App secrets somewhere to
# live that a stale copy of this workflow on an old branch cannot reach.
# Referencing it is a no-op until repo settings give it teeth; the intended
# follow-up is to move MAC_CERTS, MAC_CERTS_PASSWORD, APPLE_ID,
# APPLE_APP_SPECIFIC_PASSWORD, APPLE_TEAM_ID, HOURLY_RELEASE_APP_ID, and
# HOURLY_RELEASE_APP_PRIVATE_KEY into it, then pin its deployment branch
# policy to main so only main's copy of this file can read them.
environment: adhoc-mac-build
runs-on: blacksmith-6vcpu-macos-15
# Why 150: it must exceed the worst case the retry budgets below can produce
# (install 3x10 + publish 2x45 = 120, plus ~25 for checkout/build/verify), or
@@ -85,6 +100,57 @@ jobs:
env:
NODE_OPTIONS: --max-old-space-size=4096
steps:
# Why vet before checkout: everything after this step runs the checked-out
# code with release signing credentials in reach. Branches and tags of this
# repo are the intended audience; refs/pull/* would smuggle in fork code,
# and a raw SHA is only accepted when some branch or tag of this repo can
# actually reach it. Resolving to a pinned SHA here also means the commit
# that was vetted is the commit that gets checked out — a push to the
# branch between the two steps cannot swap it.
- name: Vet the requested ref
id: vetted
shell: bash
env:
REQUESTED_REF: ${{ inputs.ref || github.ref_name }}
REPO_URL: https://github.com/${{ github.repository }}
run: |
set -euo pipefail
case "$REQUESTED_REF" in
refs/pull/*|pull/*)
echo "::error::Refusing to build PR ref '$REQUESTED_REF': this workflow signs with release credentials, so it only builds branches, tags, or commits of stablyai/orca. Push the code to a branch of this repo instead."
exit 1
;;
esac
# Bare: a work-tree repo refuses to fetch over its own checked-out
# branch. tree:0 keeps the fetch to the commit graph — no trees, no
# blobs — so this stays cheap next to the build it fronts.
scratch="$RUNNER_TEMP/vet-requested-ref"
git init -q --bare "$scratch"
git -C "$scratch" fetch -q --filter=tree:0 "$REPO_URL" '+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*'
# Branch first to keep actions/checkout's old tie-break: bare
# rev-parse would prefer the tag when a branch shares its name.
sha=""
for cand in "refs/heads/$REQUESTED_REF" "refs/tags/$REQUESTED_REF" "$REQUESTED_REF"; do
if sha="$(git -C "$scratch" rev-parse --verify --quiet "$cand^{commit}")"; then
break
fi
sha=""
done
if [[ -z "$sha" ]]; then
echo "::error::'$REQUESTED_REF' does not resolve to a branch, tag, or commit of stablyai/orca."
exit 1
fi
# The object resolving locally is not proof a branch or tag reaches
# it: a partial clone can lazily fetch a bare SHA on demand, and
# GitHub serves PR-only commits by SHA. Reachability is the actual
# trust test.
if [[ -z "$(git -C "$scratch" for-each-ref --contains "$sha" refs/heads refs/tags | head -1)" ]]; then
echo "::error::Commit $REQUESTED_REF is not reachable from any branch or tag of stablyai/orca; refusing to build it with release credentials."
exit 1
fi
echo "Vetted $REQUESTED_REF -> $sha"
echo "sha=$sha" >>"$GITHUB_OUTPUT"
- name: Checkout the requested ref
uses: actions/checkout@v6
with:
@@ -92,7 +158,7 @@ jobs:
# build code that has not landed, and the workflow definition itself
# always comes from the dispatch ref — naming the branch here instead
# applies main's current copy of this file to an arbitrary branch.
ref: ${{ inputs.ref || github.ref_name }}
ref: ${{ steps.vetted.outputs.sha }}
fetch-depth: 0
# This job only reads stablyai/orca and never pushes; every write goes
# to the adhoc repo through a minted App token passed by env. Not
@@ -146,17 +212,26 @@ jobs:
env:
REF: ${{ inputs.ref || github.ref_name }}
LABEL: ${{ inputs.label }}
MAIN_REPO_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
# Why this check: the version script is read from the branch being built,
# Why this check: the version scripts are read from the branch being built,
# not from main, so a branch cut before the adhoc channel landed has no
# copy of it. Say that plainly instead of failing with a module-not-found.
if [[ ! -f config/scripts/adhoc-build-version.mjs ]]; then
echo "::error::$REF has no config/scripts/adhoc-build-version.mjs; rebase it onto a main that has the adhoc channel."
exit 1
fi
# copy of them. Say that plainly instead of failing with a module-not-found.
for script in adhoc-build-version dev-channel-base-version; do
if [[ ! -f "config/scripts/$script.mjs" ]]; then
echo "::error::$REF has no config/scripts/$script.mjs; rebase it onto a main that has the adhoc channel."
exit 1
fi
done
echo "head_sha=$(git rev-parse HEAD)" >>"$GITHUB_OUTPUT"
ORCA_ADHOC_LABEL="${LABEL:-$REF}" node config/scripts/adhoc-build-version.mjs \
# Why the main repo's tags: package.json on a branch is as stale as the
# main it forked from, and stable patches never merge back into it.
published="$(GH_TOKEN="$MAIN_REPO_TOKEN" gh release list \
--repo "$GITHUB_REPOSITORY" --limit 100 --exclude-drafts \
--json tagName --jq '.[].tagName' || true)"
ORCA_PUBLISHED_VERSIONS="$published" ORCA_ADHOC_LABEL="${LABEL:-$REF}" \
node config/scripts/adhoc-build-version.mjs \
>"$RUNNER_TEMP/adhoc-identity.txt"
if ! grep -q '^name=' "$RUNNER_TEMP/adhoc-identity.txt"; then
echo "::error::adhoc-build-version.mjs emitted no release name; $REF's copy of the script is out of sync with this workflow."
+14 -1
View File
@@ -179,6 +179,7 @@ jobs:
shell: bash
env:
GH_TOKEN: ${{ steps.app_token.outputs.token }}
MAIN_REPO_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
# Why read the highest number off existing titles rather than counting
@@ -195,7 +196,19 @@ jobs:
--jq '[.[] | (.name // "") | capture(" • (?<n>[0-9]+) • ")? | .n | tonumber] | max // 0')"
build_number=$(( last_number + 1 ))
echo "Hourly build number $build_number (previous high: $last_number)"
ORCA_HOURLY_BUILD_NUMBER="$build_number" node config/scripts/hourly-build-version.mjs \
# Why the main repo's tags decide the base version rather than
# package.json: main's version only moves on `release:` commits, and
# stable patches are cut from release branches that never merge back, so
# package.json can sit several patches behind what users are running. A
# separate token because GH_TOKEN above is the App's, scoped to the
# hourly repo. Empty on failure — the script then falls back to
# package.json, which is stale but never wrong enough to fail a build.
published="$(GH_TOKEN="$MAIN_REPO_TOKEN" gh release list \
--repo "$GITHUB_REPOSITORY" --limit 100 --exclude-drafts \
--json tagName --jq '.[].tagName' || true)"
echo "Highest published tag seen: $(head -1 <<<"$published")"
ORCA_PUBLISHED_VERSIONS="$published" \
ORCA_HOURLY_BUILD_NUMBER="$build_number" node config/scripts/hourly-build-version.mjs \
>"$RUNNER_TEMP/hourly-identity.txt"
# Why check rather than trust: the checkout above pins `ref: main`, but a
# workflow_dispatch runs this file from whatever branch was dispatched. A
+1
View File
@@ -100,6 +100,7 @@ docs/**
!docs/reference/headless-linux-server.md
!docs/reference/linux-glibc-compatibility.md
!docs/reference/relay-grace-time-reconfiguration.md
!docs/reference/windows-setup-shell.md
# Stably CLI (only docs/ are tracked)
.stably/*
+1
View File
@@ -29,6 +29,7 @@ Orca targets macOS, Linux, and Windows. Keep all platform-dependent behavior beh
- **Keyboard shortcuts**: Never hardcode `e.metaKey`. Use a platform check (`navigator.userAgent.includes('Mac')`) to pick `metaKey` on Mac and `ctrlKey` on Linux/Windows. Electron menu accelerators should use `CmdOrCtrl`.
- **Shortcut labels in UI**: Display `⌘` / `⇧` on Mac and `Ctrl+` / `Shift+` on other platforms.
- **File paths**: Use `path.join` or Electron/Node path utilities — never assume `/` or `\`.
- **Windows setup scripts**: the setup/issue-command runner is a `.cmd` batch file unless the script starts with a `#!` line — never derive that from the user's terminal-shell preference, and never launch a `.cmd` runner with a bare `cmd.exe /c` from a Git Bash pane (MSYS rewrites the `/c`). See [`docs/reference/windows-setup-shell.md`](./docs/reference/windows-setup-shell.md).
- **Linux native modules**: keep the glibc floor at Ubuntu 20.04 / glibc 2.31. A module compiled from source on a newer runner can reference symbol versions absent on the floor and crash the app on startup. See [`docs/reference/linux-glibc-compatibility.md`](./docs/reference/linux-glibc-compatibility.md); packaging fails if a bundled native binary needs newer glibc.
## SSH Use Case
+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.36](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.36/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.37](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/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.36](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.36/app-release.apk)
- **Android:** [Download APK 0.0.37](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk)
---
+28 -3
View File
@@ -26,6 +26,23 @@ const PLAIN_NODE_ENTRY_NAMES = [
'codex/codex-app-server-grant-entry'
] as const
// Entries executed as worker threads of the main process. Electron's module is
// not registered on worker threads, so require("electron") throws
// "Cannot find module 'electron'" there too (verified on Electron 43) and kills
// the worker at startup. These carry hand-written "must stay electron-free"
// comments, which is convention, not enforcement — and the port-scan worker in
// particular sits one import away from a client module that deliberately does
// require electron.
const WORKER_THREAD_ENTRY_NAMES = [
'stt-worker',
'warp-theme-parser-worker',
'session-scanner-opencode-sqlite-worker-entry',
'main-thread-hang-watchdog-entry',
'port-scan-command-worker-entry'
] as const
type EntryRuntime = 'plain-Node process' | 'worker thread'
const ELECTRON_REQUIRE_RE = /require\(\s*["']electron["']\s*\)/
function collectReachableChunks(
@@ -56,13 +73,14 @@ function collectReachableChunks(
function assertNoElectronRequire(
entryName: string,
entry: OutputChunk,
byFileName: Map<string, OutputChunk>
byFileName: Map<string, OutputChunk>,
runtime: EntryRuntime = 'plain-Node process'
): void {
for (const chunk of collectReachableChunks(entry, byFileName)) {
if (ELECTRON_REQUIRE_RE.test(chunk.code)) {
throw new Error(
`[plain-node-entry-guard] "${entryName}" reaches chunk "${chunk.fileName}" that ` +
`requires electron. "${entryName}" runs as a plain-Node process, where ` +
`requires electron. "${entryName}" runs as a ${runtime}, where ` +
`require("electron") throws MODULE_NOT_FOUND and kills it at startup (the ` +
`v1.4.129-rc.1 daemon outage). Keep electron imports out of its module graph.`
)
@@ -125,7 +143,14 @@ export function createPlainNodeEntryGuardPlugin(): Plugin {
for (const entryName of PLAIN_NODE_ENTRY_NAMES) {
const entry = entryByName.get(entryName)
if (entry) {
assertNoElectronRequire(entryName, entry, byFileName)
assertNoElectronRequire(entryName, entry, byFileName, 'plain-Node process')
}
}
for (const entryName of WORKER_THREAD_ENTRY_NAMES) {
const entry = entryByName.get(entryName)
if (entry) {
assertNoElectronRequire(entryName, entry, byFileName, 'worker thread')
}
}
+1
View File
@@ -10,6 +10,7 @@
"src/main/speech/stt-worker.ts",
"src/main/warp-themes/warp-theme-parser-worker.ts",
"src/main/ai-vault/session-scanner-opencode-sqlite-worker-entry.ts",
"src/main/ports/port-scan-command-worker-entry.ts",
"src/main/ipc/parcel-watcher-process-entry.ts",
"src/main/hang-watchdog/main-thread-hang-watchdog-entry.ts",
"src/main/codex/codex-app-server-grant-entry.ts",
File diff suppressed because one or more lines are too long
+456 -5
View File
@@ -1,6 +1,6 @@
{
"schemaVersion": 1,
"updatedAt": "2026-08-01",
"updatedAt": "2026-08-03",
"policy": {
"maturityLevels": ["experimental", "soak", "blocking", "accepted-gap", "deprecated"],
"blockingPromotion": {
@@ -10,6 +10,101 @@
}
},
"gates": [
{
"id": "editor.restored-sibling-owner-reparent",
"title": "Restored sibling tabs migrate filesystem authority before becoming editable",
"maturity": "experimental",
"protection": "partial",
"owner": "editor-runtime",
"layer": "renderer-store-controller-contract",
"surfaces": [
"restored editor tabs",
"editor save and autosave",
"filesystem watches and hot-exit persistence"
],
"platforms": ["macos", "linux", "windows"],
"providers": ["local", "ssh", "paired-runtime"],
"coveredPlatforms": ["macos"],
"coveredProviders": ["local", "ssh", "paired-runtime"],
"coverageNotes": "Deterministic store/controller tests cover local, direct-SSH, paired-runtime identity, folder workspaces, queue quiescence, stale route and connection-generation rejection, authoritative active-workspace projection, owner-derived IDs, drafts and editor maps, tab/group placement, exact watch replacement, change/delete/rename correlation, explicit save, autosave, collision refusal without activation reconciliation, provenance, and restart hot-exit persistence. Live headed/headless paired-runtime and post-establish Electron IPC remain uncollected.",
"motivatingLinks": [
"https://github.com/stablyai/orca/issues/11304",
"https://github.com/stablyai/orca/pull/11369"
],
"invariant": "A restored absolute path owned by a same-host sibling workspace cannot read, save, watch, or persist until old saves drain, the exact route and host generations are revalidated, and one authoritative activation transaction installs the sibling owner, target projection, editor state, reconciliation, and first-activation terminal preparation; collisions and stale routes fail before activation preparation or reconciliation.",
"oracle": "Hold an old-owner save in flight, start migration, then remove the sibling, change a folder root, or reconnect SSH and require owner migration to fail closed after quiescence. On a stable route require one coherent target-workspace projection, first-activation terminal generation preparation, destination authority for explicit save and autosave, exact watch replacement, destination-only change/delete/rename handling, owner-derived state rekeys, and hot-exit restart ownership. Seed an invalid destination tab/group model before a collision and require the target projection to remain byte-identical.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/restored-editor-owner-reparent.test.ts src/renderer/src/components/editor/restored-editor-owner-save-lifecycle.test.ts src/renderer/src/lib/runtime-workspace-file-route.test.ts"
],
"testFiles": [
"src/renderer/src/store/slices/restored-editor-owner-reparent.test.ts",
"src/renderer/src/components/editor/restored-editor-owner-save-lifecycle.test.ts",
"src/renderer/src/lib/runtime-workspace-file-route.test.ts"
],
"assertionRefs": [
{
"file": "src/renderer/src/store/slices/restored-editor-owner-reparent.test.ts",
"assertions": [
"one atomic commit rekeys owner-derived editor, preview, draft, cursor, view, tab, group, active, reveal, focus, provenance, and persistence state",
"active reparenting uses the centralized workspace activation transaction for terminal, browser, pending-creation, explorer, first-activation, and post-commit state",
"source watch unsubscribes once, destination watch subscribes once, and only destination change/delete/rename events reach the file",
"dirty and clean destination collisions fail closed without changing either open session or reconciling destination tabs and groups",
"folder and direct-SSH owners retain exact root, host, target, and connection-generation authority"
]
},
{
"file": "src/renderer/src/components/editor/restored-editor-owner-save-lifecycle.test.ts",
"assertions": [
"migration waits for the old save queue and routes later explicit and automatic saves through the destination worktree",
"a removed sibling, changed folder root, or changed direct-SSH generation after quiescence rejects migration without changing ownership"
]
},
{
"file": "src/renderer/src/lib/runtime-workspace-file-route.test.ts",
"assertions": [
"same-host runtime, folder, local, and direct-SSH roots resolve without cross-host fallback"
]
}
],
"evidenceRuns": [
{
"date": "2026-08-03",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/store/slices/restored-editor-owner-reparent.test.ts src/renderer/src/components/editor/restored-editor-owner-save-lifecycle.test.ts src/renderer/src/lib/runtime-workspace-file-route.test.ts",
"result": "passed",
"durationSeconds": 3,
"summary": "Three deterministic files passed 20 lifecycle, stale-route, activation, ownership, host, collision, persistence, watch, and save tests."
}
],
"runtimeBudget": {
"p95Seconds": 5,
"scope": "focused renderer store/controller contract on a local development runner"
},
"flakeHistory": {
"status": "unknown",
"evidence": "One local deterministic run is recorded; CI and soak history have not started."
},
"redGreenEvidence": {
"status": "complete",
"evidence": "The final2 verifier's byte-identical post-quiescence lifecycle scope passed 21/21 on 7890271160 and failed 7 tests on f92db196a1; base e08eba674c, published 1bcfe3bcb0, and revert b3627461ab were structurally red. The permanent collision oracle is in test blob ac62a89c671a0c8f081ebf06736715bd2150ce9f (SHA-256 a61970ace80be1cb56f781aee6f878a8446c6c4527f9eb0cd0c151d8df96bf50). The same byte-identical blob is structurally red on base, published, and revert, behaviorally red on rejected final2 candidate 7890271160 because pre-commit reconciliation removes the stale destination tab, and green on implementation b72a838cdc; the full permanent gate passes 20/20."
},
"performanceBudget": {
"required": true,
"evidence": "Route lookup scans only the already-indexed renderer workspace catalog, while migration is O(open editor files + source/destination tabs/groups) and performs no provider call, Git scan, polling, retry, subprocess, or global fanout beyond the required old/new watch delta."
},
"promotionCriteria": [
"Collect two fresh child-verifier decisions against the correction candidate.",
"Collect headed and headless paired-runtime coverage after the post-establish Electron IPC gap is fixed.",
"Collect physical Windows or WSL path/host evidence and CI soak history."
],
"knownGaps": [
"Headed Electron post-establish IPC, paired headless, reconnect, and physical Windows/WSL journeys are uncollected.",
"Cold many-repo main-process sibling discovery remains a separately measured follow-up.",
"Registered-workspace symlink policy remains inherited and unchanged."
],
"demotionRule": "Keep experimental or demote if migration can race a save, retain source authority, fan out watches, lose dirty/editor state, accept an owner-generation change, merge a collision, or the focused contract flakes without a product or harness bug."
},
{
"id": "terminal-provider.login-session-retirement",
"title": "macOS login-session retirement preserves live daemons through transient rejection bursts",
@@ -1031,6 +1126,126 @@
],
"demotionRule": "Demote if any probe can run before lifecycle ownership, if more than one shared heartbeat timer is armed, if first-socket cleanup exceeds one interval, if later-socket cleanup exceeds two intervals, or if close/error cleanup retains listeners or timers."
},
{
"id": "runtime.streaming-subscription-close-delivery",
"title": "Retiring a runtime transport always tells the renderer its streams closed",
"maturity": "experimental",
"protection": "partial",
"owner": "runtime-platform",
"layer": "runtime-subscription-ipc-contract",
"surfaces": [
"paired remote server",
"runtime environment disconnect and re-pair",
"terminal.multiplex streaming",
"browser screencast streaming",
"parked remote terminal reveal"
],
"platforms": ["macos", "linux", "windows"],
"providers": ["remote-runtime"],
"coveredPlatforms": ["macos"],
"coveredProviders": ["remote-runtime"],
"coverageNotes": "Deterministic main-IPC contract tests cover disconnect-driven close delivery, exactly-once close, per-subscription teardown isolation against a failing socket close and a throwing liveness probe, containment of a throwing renderer send on the unguarded host-close path, and continued suppression of stale payloads from a retired transport. A headed paired-server journey (real Orca host plus a separate paired Orca desktop client) covers hidden-but-mounted reveal, cold-parked reveal, and cold-parked reveal across a disconnect/reconnect. Live Linux and Windows paired-server evidence and real sleep/wake transport loss remain uncollected.",
"motivatingLinks": [
"tests/e2e/paired-remote-terminal-parked-reveal-interactivity.spec.ts",
"docs/reference/headless-linux-server.md"
],
"invariant": "Every renderer-held runtime subscription receives exactly one terminal close event when its transport is retired, including when the retirement advanced the transport generation first, and a single failing teardown never abandons that environment's remaining subscriptions nor escapes into the transport that reported the close. Payload frames from a retired transport stay suppressed. A revealed remote terminal therefore reattaches over a live multiplex connection: its buffer restores, typed input reaches the host PTY, the echo paints without a tab flip, and the PTY converges on the revealed pane grid.",
"oracle": "The main IPC contract test subscribes terminal.multiplex through the real handler, disconnects the environment, and asserts the renderer received exactly one {type: close} subscription event. Two isolation tests subscribe a second stream to the same environment and make the first one fail -- in its socket close, and in the liveness probe inside notifyClosed -- then assert the disconnect does not throw, both transports closed, and every close the renderer could still receive was delivered. A third drives a host-initiated close through the transport callback, which is the one notifyClosed call site with no surrounding guard, with a renderer send that throws, and asserts it cannot escape into the WebSocket close handler. A fourth test asserts that after retirement a late response frame is not forwarded and a late transport close does not re-send. The paired-server journey runs three reveal scenarios against one real host and one real paired desktop client, and for each records buffer restore, host-side receipt of the typed marker through an out-of-band host sink file, live paint without a tab flip, and PTY-versus-pane grid convergence.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/runtime-environments.test.ts",
"pnpm exec playwright test tests/e2e/paired-remote-terminal-parked-reveal-interactivity.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1"
],
"testFiles": [
"src/main/ipc/runtime-environments.test.ts",
"tests/e2e/paired-remote-terminal-parked-reveal-interactivity.spec.ts"
],
"assertionRefs": [
{
"file": "src/main/ipc/runtime-environments.test.ts",
"assertions": [
"tells the renderer when retiring the transport closes its streaming subscription (the load-bearing repro; red with the fix reverted)",
"retires an environment's remaining subscriptions when one teardown throws (load-bearing; red without per-subscription isolation)",
"contains a throwing renderer send on a host-initiated close (load-bearing; red without the guarded send, and the only coverage of the unguarded notifyClosed call site)",
"retires remaining subscriptions when a liveness probe inside notifyClosed throws (load-bearing; keeps the isolation structural rather than comment-asserted)",
"suppresses stale payloads from a retired transport but never re-sends its close (forward guard on the retained generation gate, not a repro)"
]
},
{
"file": "tests/e2e/paired-remote-terminal-parked-reveal-interactivity.spec.ts",
"assertions": [
"a revealed hidden-but-mounted remote terminal restores, accepts input, and paints live",
"a revealed cold-parked remote terminal restores, accepts input, and paints live",
"a cold-parked remote terminal revealed after a runtime disconnect and reconnect restores, accepts input, and paints live",
"the host PTY grid converges on the revealed pane grid in every scenario"
]
}
],
"evidenceRuns": [
{
"date": "2026-08-03",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/runtime-environments.test.ts",
"result": "passed",
"durationSeconds": 0.81,
"summary": "53 runtime environment IPC tests passed, including the new close-delivery, both teardown-isolation, transport-path containment, and stale-payload-suppression contracts."
},
{
"date": "2026-08-03",
"runner": "local",
"platform": "macos",
"command": "pnpm exec playwright test tests/e2e/paired-remote-terminal-parked-reveal-interactivity.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
"result": "passed",
"durationSeconds": 15.5,
"summary": "All three reveal scenarios restored their buffer, delivered typed input to the host PTY, painted live without a tab flip, and converged the PTY on the 135x60 pane grid. Before the fix the reconnect-parked scenario stayed blank at recoveryState connecting with the PTY stranded at the host 128x60 grid."
},
{
"date": "2026-08-03",
"runner": "local",
"platform": "macos",
"command": "pnpm exec playwright test tests/e2e/paired-remote-terminal-parked-reveal-interactivity.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
"result": "passed",
"durationSeconds": 15.5,
"summary": "Repeat run on a clean uninstrumented build; all three scenarios green with identical grids."
},
{
"date": "2026-08-03",
"runner": "local",
"platform": "macos",
"command": "pnpm exec playwright test tests/e2e/paired-remote-terminal-parked-reveal-interactivity.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
"result": "passed",
"durationSeconds": 14.7,
"summary": "Third consecutive pass after the review follow-ups (env save/restore and the hidden-mounted stayed-mounted assertion)."
}
],
"runtimeBudget": {
"p95Seconds": 60,
"scope": "focused runtime environment IPC tests plus one headed paired-server reveal journey"
},
"flakeHistory": {
"status": "unknown",
"evidence": "Deterministic IPC coverage plus the three recorded paired-server journey runs, which passed consecutively on macOS at 14.7-15.5s; the spec also passed once on a shared CI runner. Longer soak history is not yet available."
},
"redGreenEvidence": {
"status": "complete",
"evidence": "On main the paired-server journey reproduced a blank, non-interactive pane after revealing a cold-parked remote terminal across a reconnect: recoveryState stayed connecting, the xterm buffer stayed empty, typed input never reached the host sink, a tab flip did not recover it, and the PTY stayed at the host 128x60 grid. Renderer instrumentation showed the multiplexer reusing a dead subscription (ensureConnected reuse-ready) and never receiving a subscribed event. Reverting the main-side fix also turns the close-delivery IPC test red; with the fix the multiplexer observes handleClose, reconnects fresh, and every scenario passes."
},
"performanceBudget": {
"required": true,
"evidence": "The fix adds one boolean latch and at most one extra IPC send per retired subscription, on a path that already tears the subscription down. It introduces no timer, poll, retry, subprocess, or per-frame work, and the close is deduplicated so a transport-driven close after an environment-wide retirement sends nothing."
},
"promotionCriteria": [
"Collect at least 100 consecutive CI or soak passes or 14 days without an unexplained flake.",
"Collect live Linux and Windows paired-server reveal evidence.",
"Add real sleep/wake and network-loss transport drops alongside the explicit disconnect trigger."
],
"knownGaps": [
"Live paired-server validation currently covers macOS host and client only.",
"The transport drop is an explicit runtime disconnect; real sleep/wake and network partitions are not yet exercised by this gate.",
"Browser screencast subscribers share the fixed contract but have no dedicated reveal journey; the web and mobile clients run parallel transports and are unaffected."
],
"demotionRule": "Demote if a retired runtime transport can leave a renderer subscription without a close event, if a close is delivered more than once, if one failing teardown strands its sibling subscriptions, if payload frames from a retired transport reach the renderer, or if a revealed remote terminal can stay blank or reject input after a reconnect."
},
{
"id": "editor.live-log-append-stability",
"title": "Long live session logs retain their Monaco viewport while appending",
@@ -1695,13 +1910,15 @@
"invariant": "Reconnect, replay, or lifecycle observations from a viewer that negotiated explicit close intent must never kill a live PTY. A capable reasonless close must keep and republish; a legacy paired viewer must retain current-main behavior because its intentional close and cleanup echo are wire-identical. Lifecycle close requires the exact observed publication, terminal, environment, and authoritative liveness, never signals a process, and leaves renderer-owned or partial-split retirement to its owner. Legacy daemon hello and warm reattachment remain non-destructive.",
"oracle": "Start isolated v25 and v26 daemon generations with one capable-viewer PTY and one legacy-viewer PTY per generation in four target worktrees plus an unaddressed control PTY in a fifth worktree. Route them through the production desktop scanner, runtime, RPC dispatcher, renderer-close relay, and daemon router. First issue sequential reasonless closes from an authenticated capable connection and require refusal, snapshot republish, zero shutdown calls, exact process survival, and post-close I/O. Then issue the byte-identical requests without the negotiated capability and require current-main behavior: two ordered immediate shutdowns, session-killed events, and exact root/descendant death, while capable and control PTYs survive. An observer lists all targets before, between, and after while issuing zero closes. Unit contracts also require in-process reasonless refusal, legacy runtime/mobile compatibility, explicit user closes, encrypted client-auth advertisement, and old-server lifecycle calls never to fall back.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/remote-runtime-request-connection.integration.test.ts src/main/runtime/rpc/methods/session-tabs.test.ts src/main/runtime/rpc/methods/session-tabs-schemas.test.ts src/main/runtime/rpc/e2ee-channel.test.ts src/main/runtime/rpc/e2ee-channel-v2.test.ts src/main/runtime/rpc/mobile-socket-wiring.test.ts src/main/runtime/rpc/runtime-client-capabilities.test.ts src/shared/remote-runtime-client.test.ts src/shared/remote-runtime-request-connection.test.ts src/shared/remote-runtime-shared-control-connection.test.ts src/cli/runtime/websocket-transport.test.ts src/renderer/src/web/web-runtime-client.test.ts src/renderer/src/runtime/web-runtime-session.test.ts src/renderer/src/runtime/web-session-close-intent.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts src/renderer/src/components/terminal/terminal-tab-actions.test.ts src/renderer/src/components/terminal/terminal-close-incarnation.test.ts src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/daemon-server-kill-attribution.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/remote-runtime-request-connection.integration.test.ts src/main/runtime/rpc/runtime-close-attribution-topology.test.ts src/main/runtime/rpc/methods/session-tabs.test.ts src/main/runtime/rpc/methods/session-tabs-schemas.test.ts src/main/runtime/rpc/e2ee-channel.test.ts src/main/runtime/rpc/e2ee-channel-v2.test.ts src/main/runtime/rpc/mobile-socket-wiring.test.ts src/main/runtime/rpc/runtime-client-capabilities.test.ts src/shared/remote-runtime-client.test.ts src/shared/remote-runtime-request-connection.test.ts src/shared/remote-runtime-shared-control-connection.test.ts src/cli/runtime/websocket-transport.test.ts src/renderer/src/web/web-runtime-client.test.ts src/renderer/src/runtime/web-runtime-session.test.ts src/renderer/src/runtime/web-session-close-intent.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts src/renderer/src/components/terminal/terminal-tab-actions.test.ts src/renderer/src/components/terminal/terminal-close-incarnation.test.ts src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts",
"pnpm exec playwright test tests/e2e/daemon-generation-reconnect-safety.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
"pnpm exec playwright test tests/e2e/daemon-generation-legacy-close-safety.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1"
],
"testFiles": [
"src/main/daemon/daemon-server-kill-attribution.test.ts",
"src/main/runtime/orca-runtime.test.ts",
"src/main/runtime/remote-runtime-request-connection.integration.test.ts",
"src/main/runtime/rpc/runtime-close-attribution-topology.test.ts",
"src/main/runtime/rpc/methods/session-tabs.test.ts",
"src/main/runtime/rpc/methods/session-tabs-schemas.test.ts",
"src/main/runtime/rpc/e2ee-channel.test.ts",
@@ -1723,6 +1940,19 @@
"tests/e2e/daemon-generation-legacy-close-safety.spec.ts"
],
"assertionRefs": [
{
"file": "src/main/daemon/daemon-server-kill-attribution.test.ts",
"assertions": [
"successful and failed daemon kill requests retain the authenticated control-client identity without claiming a failed kill succeeded"
]
},
{
"file": "src/main/runtime/rpc/runtime-close-attribution-topology.test.ts",
"assertions": [
"remote, legacy, reconnect, stale, concurrent, cross-worktree, and unowned close spans retain authoritative runtime, target, requester, decision, and outcome identities without serializing the bearer credential",
"close attribution adds no terminal inventory scan or provider fanout"
]
},
{
"file": "src/main/runtime/remote-runtime-request-connection.integration.test.ts",
"assertions": [
@@ -2108,6 +2338,120 @@
],
"demotionRule": "Demote or quarantine if the gate flakes without a product or harness bug, if a Pi handler can again await receiver I/O, or if coalescing allows an older status to overwrite a newer one."
},
{
"id": "agent-status.manual-compact-identity",
"title": "Manual Claude compact completion retires only its exact status generation",
"maturity": "experimental",
"protection": "partial",
"owner": "agent-session",
"layer": "shared-main-relay-contract",
"surfaces": [
"Claude status hooks",
"manual compact lifecycle",
"last-status persistence",
"SSH relay ingest"
],
"platforms": ["macos", "linux", "windows"],
"providers": ["local", "daemon", "ssh", "wsl", "remote-runtime"],
"coveredPlatforms": ["macos"],
"coveredProviders": ["local", "ssh"],
"coverageNotes": "Deterministic listener, loopback HTTP, relay-restart, persisted-restore, and fake-SSH transport contracts run on macOS. Claude Code 2.1.220 captures prove manual PreCompact starts a new prompt UUID and exact PreCompact/PostCompact pairs share it. The shared Node paths are platform-independent; live Linux, Windows, WSL, and paired-runtime journeys remain gaps.",
"motivatingLinks": [
"https://github.com/stablyai/orca/issues/11352",
"https://github.com/stablyai/orca/pull/11353"
],
"invariant": "A Claude manual PreCompact may replace only a current Claude row from the same receiving connection and provider session; a manual PostCompact may mark done only when the current authoritative row is its exact manual PreCompact generation, including provider prompt UUID. Missing identity is never a wildcard; later cross-provider work, replay, duplication, pane or tab retirement, and server stop invalidate completion. Automatic Claude and Kimi completion remain fail-closed until their hooks expose independently proven generation identity.",
"oracle": "Run one byte-identical state-machine oracle plus local HTTP, relay-restart, SSH, and persisted-restore streams using Claude's observed different prior-turn and compact prompt UUIDs. Require matching manual completion to emit done while wrong or absent prompt UUID, session-presence mismatch, wrong source or connection, stale replay and duplicate delivery, later Codex work, automatic completion, and lifecycle cleanup cannot retire the current row. Assert both emitted event order and the authoritative lastStatusByPaneKey snapshot.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/shared/manual-compact-prompt-identity.test.ts src/main/agent-hooks/manual-compact-hook-stream.test.ts src/main/agent-hooks/manual-compact-status-cleanup.test.ts src/shared/agent-hook-listener.test.ts src/shared/agent-hook-relay.test.ts src/relay/agent-hook-server.test.ts src/main/agent-hooks/server.test.ts src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts --reporter=dot",
"Manual Claude Code 2.1.220 interactive compact: isolate UserPromptSubmit, PreCompact, and PostCompact hooks; submit one ordinary prompt then /compact; compare source, trigger, session_id, and prompt_id"
],
"testFiles": [
"src/shared/manual-compact-prompt-identity.test.ts",
"src/main/agent-hooks/manual-compact-hook-stream.test.ts",
"src/main/agent-hooks/manual-compact-status-cleanup.test.ts",
"src/shared/agent-hook-listener.test.ts",
"src/shared/agent-hook-relay.test.ts",
"src/relay/agent-hook-server.test.ts",
"src/main/agent-hooks/server.test.ts",
"src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts"
],
"assertionRefs": [
{
"file": "src/shared/manual-compact-prompt-identity.test.ts",
"assertions": [
"matching manual identity settles once",
"stale, duplicate, session-mismatched, malformed, automatic, and unproven provider completion fail closed",
"4,097 rejected pane keys allocate no ownership state"
]
},
{
"file": "src/main/agent-hooks/manual-compact-hook-stream.test.ts",
"assertions": [
"local HTTP and relay-restart streams preserve the distinct prior-turn and compact prompt identities",
"later provider work and wrong transport identity remain authoritative",
"persisted PreCompact restores its prompt"
]
},
{
"file": "src/main/agent-hooks/manual-compact-status-cleanup.test.ts",
"assertions": ["pane, tab, and server cleanup revoke compact completion authority"]
},
{
"file": "src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts",
"assertions": [
"SSH stamps the receiving connection and forwards source, prompt UUID, session, and manual trigger"
]
}
],
"evidenceRuns": [
{
"date": "2026-08-02",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/shared/manual-compact-prompt-identity.test.ts src/main/agent-hooks/manual-compact-hook-stream.test.ts src/main/agent-hooks/manual-compact-status-cleanup.test.ts src/shared/agent-hook-listener.test.ts src/shared/agent-hook-relay.test.ts src/relay/agent-hook-server.test.ts src/main/agent-hooks/server.test.ts src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts --reporter=dot",
"result": "passed",
"durationSeconds": 2.66,
"summary": "Eight focused files and 435 tests passed with local HTTP, relay, SSH, persistence, adversarial identity, cleanup, and existing provider-regression coverage."
},
{
"date": "2026-08-02",
"runner": "manual",
"platform": "macos",
"command": "Manual Claude Code 2.1.220 interactive compact: isolate UserPromptSubmit, PreCompact, and PostCompact hooks; submit one ordinary prompt then /compact; compare source, trigger, session_id, and prompt_id",
"result": "passed",
"durationSeconds": 120,
"summary": "A fresh capture proved /compact emits no UserPromptSubmit and its PreCompact uses a new UUID distinct from the preceding user turn; the preserved authenticated capture proved the matching manual PreCompact and PostCompact share that UUID and session. Automatic capture reused one prompt UUID across multiple compact generations, so automatic completion remains disabled."
}
],
"runtimeBudget": {
"p95Seconds": 20,
"scope": "shared, main-process, relay, persistence, and fake-SSH contract tests"
},
"flakeHistory": {
"status": "unknown",
"evidence": "New deterministic gate with one local run; CI soak history is not yet available."
},
"redGreenEvidence": {
"status": "complete",
"evidence": "The byte-identical oracle (SHA-256 24e80c15b467ffa9c47a81099f11bf26dea71293e47b5d950b2fad1497094764) failed 5 of 6 on origin/main@a20165a43d, passed 6 of 6 on the candidate, failed 3 of 6 when only the exact-transition guard was disabled, and passed 6 of 6 after restoration."
},
"performanceBudget": {
"required": true,
"evidence": "Each compact event performs one O(1) current-status lookup plus bounded UUID, source, connection, session, and event comparisons. Identity lives on the existing status row and is removed by its existing lifecycle; the change adds no owner map, global scan, polling, timer, subprocess, network request, or listener. The 4,097-key adversarial arm retains zero status, prompt, or ownership entries."
},
"promotionCriteria": [
"Collect 100 consecutive focused CI passes or 14 days of soak history.",
"Run live manual compact journeys on Linux and Windows and through a real SSH target.",
"Keep automatic compact completion disabled until a generation-unique provider identity is proven."
],
"knownGaps": [
"No live Linux, Windows, WSL, paired-runtime, or real-SSH manual compact journey was run.",
"Claude versions before prompt_id support intentionally cannot complete a manual compact row through PostCompact.",
"Automatic Claude and Kimi completion remain intentionally fail-closed because their hooks do not prove an exact compact generation."
],
"demotionRule": "Demote or quarantine if identity-mismatched completion can retire newer work, cleanup retains compact authority, or the focused gate flakes without a product or harness bug."
},
{
"id": "agent-session.provider-ownership",
"title": "Provider sessions are resumed once per workspace ownership claim",
@@ -6511,15 +6855,16 @@
"providers": ["local", "daemon", "ssh", "wsl", "remote-runtime"],
"coveredPlatforms": ["macos"],
"coveredProviders": ["local", "daemon", "ssh"],
"coverageNotes": "A deterministic service-state-machine oracle now models a current-contract worker and coordinator whose renderer graph identities disappear across an app/runtime update. It exercises the production verifier with restored PTY and hydrated hook commitments, proves authenticated completion replay across a fresh runtime, explicit takeover, ordinary mail routing, remote-attachment process fencing, retained Task/Dispatch/terminal identity, and unchanged fixture marker bytes, and rejects foreign pane evidence. Other deterministic units cover authority-aware legacy formatting, exact legacy worker identity planning, local worker presentation, retained-output reads after adoption, reveal-failure warnings, stable-pane Run/Dispatch routing, the SSH in-process CLI fallback, and federated non-reveal. Two isolated macOS Electron journeys launch fake Codex workers through the real RPC path and record append-only spawn/interruption ledgers. They assert immediate inactive presentation, one live agent PID, stable PTY/incarnation/tab/leaf/worktree/Task/Dispatch identity, and no interruption after workspace re-entry; the restart journey additionally removes renderer ownership, marks the Dispatch legacy, retains the daemon process across an app restart, and proves exact background adoption with readable ACK output and no resume replay. Distinct A/B artifacts plus live SSH, WSL, folder, remote-runtime, Linux, and Windows cutover journeys remain explicit gaps.",
"coverageNotes": "A deterministic service-state-machine oracle now models a current-contract worker and coordinator whose renderer graph identities disappear across an app/runtime update. It exercises the production verifier with restored PTY and hydrated hook commitments, proves authenticated completion replay across a fresh runtime, explicit takeover, ordinary mail routing, remote-attachment process fencing, retained Task/Dispatch/terminal identity, and unchanged fixture marker bytes, and rejects foreign pane evidence. Other deterministic units cover authority-aware legacy formatting, exact legacy worker identity planning, local worker presentation, retained-output reads after adoption, reveal-failure warnings, stable-pane Run/Dispatch routing, creator pane/process/Run-generation fencing, indexed retained-Run lookup scaling, the SSH in-process CLI fallback, and federated non-reveal. Two isolated macOS Electron journeys launch fake Codex workers through the real RPC path and record append-only spawn/interruption ledgers. They assert immediate inactive presentation, one live agent PID, stable PTY/incarnation/tab/leaf/worktree/Task/Dispatch identity, and no interruption after workspace re-entry; the restart journey additionally removes renderer ownership, marks the Dispatch legacy, retains the daemon process across an app restart, and proves exact background adoption with readable ACK output and no resume replay. Distinct A/B artifacts plus live SSH, WSL, folder, remote-runtime, Linux, and Windows cutover journeys remain explicit gaps.",
"motivatingLinks": ["https://github.com/stablyai/orca/pull/11107#discussion_r3663321387"],
"invariant": "Starting a worker in the coordinator's current workspace must materialize one inactive terminal tab before worker-start returns, preserve coordinator focus, and remain exactly once after workspace re-entry. After an app update or restart, an exact live legacy worker must fence automatic provider resume, adopt its original PTY into its original background pane, retain readable output, and clear the resume record without spawning, writing, signalling, interrupting, replacing, or focusing the worker. A current-contract worker whose renderer graph identity is temporarily absent must retain its Dispatch capability and settle exactly once from exact hook-attested handle, pane, and process evidence; otherwise only an exact attested coordinator may take over. An exact existing target workspace must receive a discoverable tab without stealing coordinator focus; if renderer reveal fails, worker-start must expose that the live worker remains background-only. Run and Dispatch checks must resolve through the caller's stable pane identity when a terminal handle is reminted, while a live handle outranks mismatched pane metadata, explicit legacy terminal inspection remains handle-scoped, and remote or headless worker presentation remains background-only.",
"oracle": "Drive Run create, Task create, and worker-start through production Electron runtimes with a deterministic Codex fixture. Require append-only ledgers with one still-live PID and no interruption, a visible inactive worker tab while the coordinator stays active, Run delivery through stable pane identity, and stable PTY/incarnation, tab, leaf, worktree, Task, and Dispatch across workspace re-entry. In a restart journey, retain the original daemon PTY and PID, remove renderer ownership, retain sleeping-session evidence, mark the Dispatch legacy, relaunch, and require exact inactive tab adoption, readable ACK output, cleared resume state, one spawn, and no resume argv or Conversation interrupted text after another workspace round trip. The service oracle removes renderer lookup identity from current-contract callers while retaining real restored-PTY and hook commitments, replays authenticated completion and takeover across fresh runtimes, and requires one Task, Dispatch, terminal authority, message, mutation, ordinary-mail delivery, remote process fencing, and unchanged fixture marker bytes while foreign pane evidence remains rejected. Unit tests separately assert authority-specific legacy affordances, exact identity and owner matching, retained-output fallback, pane-stable routing, federated non-activation, and SSH fallback parity.",
"invariant": "Starting a worker in the coordinator's current workspace must materialize one inactive terminal tab before worker-start returns, preserve coordinator focus, and remain exactly once after workspace re-entry. After an app update or restart, an exact live legacy worker must fence automatic provider resume, adopt its original PTY into its original background pane, retain readable output, and clear the resume record without spawning, writing, signalling, interrupting, replacing, or focusing the worker. A current-contract worker whose renderer graph identity is temporarily absent must retain its Dispatch capability and settle exactly once from exact hook-attested handle, pane, and process evidence; otherwise only an exact attested coordinator may take over. An exact existing target workspace must receive a discoverable tab without stealing coordinator focus; if renderer reveal fails, worker-start must expose that the live worker remains background-only. Run and Dispatch checks must resolve through the caller's stable pane identity when a terminal handle is reminted, while a live handle outranks mismatched pane metadata. A nested worker's creator edge requires the current creator pane, process incarnation, and owning Run generation; reminting and rebinding that pane to another Run must remove the stale edge. Explicit legacy terminal inspection remains handle-scoped, and remote or headless worker presentation remains background-only.",
"oracle": "Drive Run create, Task create, and worker-start through production Electron runtimes with a deterministic Codex fixture. Require append-only ledgers with one still-live PID and no interruption, a visible inactive worker tab while the coordinator stays active, Run delivery through stable pane identity, and stable PTY/incarnation, tab, leaf, worktree, Task, and Dispatch across workspace re-entry. In a restart journey, retain the original daemon PTY and PID, remove renderer ownership, retain sleeping-session evidence, mark the Dispatch legacy, relaunch, and require exact inactive tab adoption, readable ACK output, cleared resume state, one spawn, and no resume argv or Conversation interrupted text after another workspace round trip. The service oracle removes renderer lookup identity from current-contract callers while retaining real restored-PTY and hook commitments, replays authenticated completion and takeover across fresh runtimes, and requires one Task, Dispatch, terminal authority, message, mutation, ordinary-mail delivery, remote process fencing, and unchanged fixture marker bytes while foreign pane evidence remains rejected. Unit tests separately remint a creator pane and process from Run A into Run B, require the nested Run A worker to fall back to its current coordinator, require indexed query plans, and bound 300 Task reads with 50,000 retained Runs. They also assert authority-specific legacy affordances, exact identity and owner matching, retained-output fallback, pane-stable routing, federated non-activation, and SSH fallback parity.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/orchestration-runtime-update-settlement.test.ts --reporter=dot",
"pnpm exec vitest run --config config/vitest.config.ts src/cli/handlers/orchestration.test.ts src/cli/handlers/orchestration-check-identity.test.ts src/cli/handlers/orchestration-worker-cli.test.ts src/main/runtime/rpc/methods/orchestration.test.ts src/main/ssh/ssh-remote-orca-cli.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration/formatter.test.ts src/main/runtime/rpc/methods/orchestration-federation.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration/orchestration-legacy-worker-terminal-recovery.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration/orchestration-creator-authority-performance.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts",
"pnpm run test:e2e -- tests/e2e/orchestration-worker-terminal-visibility.spec.ts --workers=1",
"pnpm run test:e2e -- tests/e2e/orchestration-legacy-worker-restart-recovery.spec.ts --workers=1"
@@ -6528,6 +6873,7 @@
"src/main/runtime/rpc/orchestration-runtime-update-settlement.test.ts",
"src/main/runtime/orchestration/formatter.test.ts",
"src/main/runtime/orchestration/orchestration-legacy-worker-terminal-recovery.test.ts",
"src/main/runtime/orchestration/orchestration-creator-authority-performance.test.ts",
"src/main/runtime/orca-runtime.test.ts",
"src/cli/handlers/orchestration.test.ts",
"src/cli/handlers/orchestration-check-identity.test.ts",
@@ -6549,6 +6895,13 @@
"ordinary mail and remote attachments use the same attested pane and process authority"
]
},
{
"file": "src/main/runtime/orchestration/orchestration-creator-authority-performance.test.ts",
"assertions": [
"creator lookup uses the assignee-handle and pane-leaf indexes without a retained-Run scan",
"300 Task reads remain bounded with 50,000 unrelated retained Runs"
]
},
{
"file": "src/main/runtime/orchestration/orchestration-legacy-worker-terminal-recovery.test.ts",
"assertions": [
@@ -6733,6 +7086,104 @@
],
"demotionRule": "Keep experimental or demote if either Electron journey flakes without a product or harness defect, if local worker-start can return before tab materialization without an explicit reveal warning, if focus moves to the worker, if restart or workspace re-entry spawns/resumes/duplicates/interferes with the worker, if retained output becomes unreadable, or if pane-stable delivery reads the wrong mailbox."
},
{
"id": "orchestration.settled-worker-terminal-release",
"title": "Settled worker cleanup preserves one exact terminal lease and immutable output",
"maturity": "experimental",
"protection": "partial",
"owner": "orchestration",
"layer": "runtime-sqlite-terminal-lifecycle",
"surfaces": [
"worker-release",
"worker-retain",
"explicit terminal reuse",
"restart reconciliation",
"worker-read archives",
"orchestration reset"
],
"platforms": ["macos", "linux", "windows"],
"providers": ["local", "daemon", "ssh", "wsl", "remote-runtime"],
"coveredPlatforms": ["macos"],
"coveredProviders": ["local"],
"coverageNotes": "Deterministic service tests cover release-versus-reuse ordering, transactional retain and takeover cancellation, exact host/pane/process identity, conservative schema-v23 backfill, immutable transcript and bounded terminal archives, mutation restart, reset cleanup, replay idempotency, and 50-resource accounting. Live SSH, WSL, Windows, paired-runtime, and provider-close lost-ack journeys remain explicit gaps.",
"motivatingLinks": ["https://github.com/stablyai/orca/pull/12355", "STA-905"],
"invariant": "A settled Dispatch may close only its one coordinator-created terminal lease. Explicit reuse, real user input, retain, identity or host change, ambiguity, and another resource for the same exact host/pane/process must fence closure. Output preservation and the requested-to-releasing transition are atomic, archives remain readable without the provider file, retries resume idempotently, and orchestration reset removes archive and authority state.",
"oracle": "Record release intent for a settled owner, attempt exact reuse before close, and require worker-start to fail with terminal_release_in_progress while the terminal stays open; then release the original owner exactly once. Race retain and real user input against a controlled archive promise and require no committed archive or close. Change host or process identity and inject duplicate resource evidence to require retention. Freeze a structured transcript, delete its source file, and require archived worker-read to return the same bounded redacted messages. Restart a pending mutation, reset orchestration state, and create 50 resources while asserting replay convergence, zero orphan rows, two-query worker listing, and no unrelated close.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration-worker-release.test.ts src/main/runtime/rpc/methods/orchestration-worker-release-recovery.test.ts src/main/runtime/rpc/orchestration-mutation-ledger.test.ts src/main/runtime/orchestration/worker-transcript-read.test.ts src/renderer/src/lib/worker-terminal-takeover-report.test.ts --reporter=dot"
],
"testFiles": [
"src/main/runtime/rpc/methods/orchestration-worker-release.test.ts",
"src/main/runtime/rpc/methods/orchestration-worker-release-recovery.test.ts",
"src/main/runtime/rpc/orchestration-mutation-ledger.test.ts",
"src/main/runtime/orchestration/worker-transcript-read.test.ts",
"src/renderer/src/lib/worker-terminal-takeover-report.test.ts"
],
"assertionRefs": [
{
"file": "src/main/runtime/rpc/methods/orchestration-worker-release.test.ts",
"assertions": [
"rejects exact reuse after release intent instead of closing the new worker",
"lets an explicit retain cancel a release while output capture is pending",
"retains when the terminal host scope changed instead of closing",
"reads an immutable transcript snapshot after the provider file disappears",
"backfills a legacy creator plus explicit reuser as ambiguous",
"removes terminal authority and archived output on orchestration reset"
]
},
{
"file": "src/main/runtime/rpc/orchestration-mutation-ledger.test.ts",
"assertions": ["resumes a pending idempotent worker release after restart"]
},
{
"file": "src/main/runtime/rpc/methods/orchestration-worker-release-recovery.test.ts",
"assertions": [
"finishes a requested release after restart-style interruption",
"coalesces overlapping reconciliation passes and closes each resource once",
"keeps live terminals bounded across 50 settled workers while controls survive"
]
}
],
"evidenceRuns": [
{
"date": "2026-08-03",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration-worker-release.test.ts src/main/runtime/rpc/methods/orchestration-worker-release-recovery.test.ts src/main/runtime/rpc/orchestration-mutation-ledger.test.ts src/main/runtime/orchestration/worker-transcript-read.test.ts src/renderer/src/lib/worker-terminal-takeover-report.test.ts --reporter=dot",
"result": "passed",
"durationSeconds": 3.48,
"summary": "Five focused files passed 56 tests covering lease serialization, reminted-handle transfer, duplicate-identity fencing, retain and takeover races, immutable archives, conservative legacy migration, mutation restart, reset cleanup, bounded accounting, and renderer input reporting."
}
],
"runtimeBudget": {
"p95Seconds": 8,
"scope": "deterministic runtime, SQLite, transcript, renderer-input, and mutation contracts"
},
"flakeHistory": {
"status": "unknown",
"evidence": "First local deterministic run only; focused CI and soak history are not yet available."
},
"redGreenEvidence": {
"status": "complete",
"evidence": "The byte-identical 56-test oracle produced 12 deterministic failures and 44 passes with production restored to exact PR head 4aa66b62e3, including ready reuse during release, unsafe migration, stale archives, mutable transcript output, host/identity close gaps, and mutation replay loss. Candidate 457bc4edd1 passed all 56; restoring the same five production files to 4aa66b62e3 reproduced the same red state."
},
"performanceBudget": {
"required": true,
"evidence": "Release performs constant-count indexed resource and identity queries plus one bounded archive capture. Worker-list uses two set queries rather than one resource lookup per worker. Reconciliation remains serial and adds no polling, timers, subprocesses, renderer subscriptions, or provider-wide listing."
},
"promotionCriteria": [
"Collect 100 consecutive focused CI passes or 14 days of soak history.",
"Add live headed and headless paired-runtime release/reuse journeys.",
"Add Docker SSH reconnect and physical Windows WSL host-scope evidence.",
"Prove provider-close lost acknowledgements converge with an exact durable close receipt."
],
"knownGaps": [
"Provider close has no durable cross-process operation receipt, so a crash after host mutation but before SQLite settlement remains release_pending until exact inventory returns.",
"Federated release remains explicitly unsupported and retained.",
"Mobile/direct remote input takeover and live Linux, Windows, WSL, SSH, headed, and headless paired-runtime release are not exercised."
],
"demotionRule": "Keep experimental or demote if release can overlap exact reuse, close a conflicting lease, lose immutable output, retain reset archives, fan out per worker, or any focused ordering test flakes without a product or harness defect."
},
{
"id": "terminal-render.windows-cjk-repaint",
"title": "Windows ConPTY wide glyphs and cursor rewrites repaint without stale cells",
+12 -3
View File
@@ -2,6 +2,10 @@ import { execFileSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { formatReleaseTitleTimestamp } from './release-title-timestamp.mjs'
import {
readPublishedVersionsFromEnv,
resolveDevChannelBaseVersion
} from './dev-channel-base-version.mjs'
/** Long enough to name a feature, short enough that a picker row stays readable. */
export const ADHOC_LABEL_MAX_LENGTH = 32
@@ -83,12 +87,13 @@ export function formatAdhocReleaseName(version, label, commit, date) {
].join(' • ')
}
export function getAdhocBuildIdentity(now = new Date(), label = '') {
export function getAdhocBuildIdentity(now = new Date(), label = '', publishedVersions = []) {
const packageJson = JSON.parse(readFileSync(resolve('package.json'), 'utf8'))
const commit = execFileSync('git', ['rev-parse', '--short=12', 'HEAD'], {
encoding: 'utf8'
}).trim()
const version = createAdhocBuildVersion(packageJson.version, now)
const base = resolveDevChannelBaseVersion(packageJson.version, publishedVersions)
const version = createAdhocBuildVersion(base, now)
return {
commit,
version,
@@ -98,7 +103,11 @@ export function getAdhocBuildIdentity(now = new Date(), label = '') {
}
if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) {
const identity = getAdhocBuildIdentity(new Date(), process.env.ORCA_ADHOC_LABEL ?? '')
const identity = getAdhocBuildIdentity(
new Date(),
process.env.ORCA_ADHOC_LABEL ?? '',
readPublishedVersionsFromEnv()
)
// Consumed by the workflow via $GITHUB_OUTPUT.
process.stdout.write(
`version=${identity.version}\ncommit=${identity.commit}\nlabel=${identity.label}\nname=${identity.name}\n`
@@ -1,4 +1,12 @@
import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import {
copyFileSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync
} from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { spawnSync } from 'node:child_process'
@@ -6,6 +14,21 @@ import { describe, expect, it } from 'vitest'
const itCrossHost = process.platform === 'win32' ? it.skip : it
const projectRoot = resolve(import.meta.dirname, '../..')
const WINDOWS_LOCK_CODES = ['EBUSY', 'ENOTEMPTY', 'EPERM']
// Why: Windows releases the image handle on a just-executed exe (and finishes the
// AV scan of the freshly compiled one) after the process exits, so tearing down the
// fixture races those locks. Retry, then leave the temp tree rather than reporting a
// teardown lock as a launcher failure.
function removeFixtureTree(path) {
try {
rmSync(path, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })
} catch (error) {
if (process.platform !== 'win32' || !WINDOWS_LOCK_CODES.includes(error?.code)) {
throw error
}
}
}
// Why: cold csc.exe startup exceeds Vitest's 5s unit budget on hosted Windows;
// keep the larger allowance scoped to the real compiler integration test.
function itWindows(name, test) {
@@ -27,10 +50,24 @@ describe('Windows CLI launcher', () => {
expect(result.stderr).toContain('Windows CLI launcher')
expect(result.stderr).toContain('Windows host')
} finally {
rmSync(outputRoot, { recursive: true, force: true })
removeFixtureTree(outputRoot)
}
})
itCrossHost('never materializes the child environment block from ProcessStartInfo', () => {
// Why: both ProcessStartInfo env properties copy the process block into a case-insensitive
// dictionary that throws when the inherited block holds PATH and Path (stablyai/orca#12046).
const source = readFileSync(
join(projectRoot, 'native', 'windows-cli-launcher', 'OrcaCliLauncher.cs'),
'utf8'
)
const code = source.replace(/^\s*\/\/.*$/gm, '')
expect(code).not.toContain('EnvironmentVariables')
expect(code).not.toContain('startInfo.Environment')
expect(code).toContain('Environment.SetEnvironmentVariable')
})
itWindows('preserves a multiline argument from PowerShell through the native launcher', () => {
const appRoot = mkdtempSync(join(tmpdir(), 'orca cli launcher '))
try {
@@ -86,7 +123,73 @@ describe('Windows CLI launcher', () => {
orcaNodeOptions: '--no-warnings'
})
} finally {
rmSync(appRoot, { recursive: true, force: true })
removeFixtureTree(appRoot)
}
})
itWindows('survives an inherited environment block containing PATH and Path', () => {
const appRoot = mkdtempSync(join(tmpdir(), 'orca duplicate path launcher '))
try {
const resourcesPath = join(appRoot, 'resources')
const launcherPath = join(resourcesPath, 'bin', 'orca.exe')
const cliPath = join(resourcesPath, 'app.asar.unpacked', 'out', 'cli', 'index.js')
const outputPath = join(appRoot, 'child-result.json')
const harnessSourcePath = join(
projectRoot,
'config',
'scripts',
'fixtures',
'DuplicatePathProcessLauncher.cs'
)
const harnessPath = join(appRoot, 'DuplicatePathLauncher.exe')
mkdirSync(dirname(launcherPath), { recursive: true })
mkdirSync(dirname(cliPath), { recursive: true })
copyFileSync(process.execPath, join(appRoot, 'Orca.exe'))
writeFileSync(
cliPath,
`require('node:fs').writeFileSync(process.env.ORCA_TEST_OUTPUT, JSON.stringify({
electronRunAsNode: process.env.ELECTRON_RUN_AS_NODE,
pathKeys: Object.keys(process.env).filter((key) => key.toLowerCase() === 'path')
}))\n`,
'utf8'
)
const build = spawnSync(
process.execPath,
['config/scripts/build-windows-cli-launcher.mjs', '--output', launcherPath],
{ cwd: projectRoot, encoding: 'utf8' }
)
expect(build.status, `${build.stdout}\n${build.stderr}`).toBe(0)
const compiler = findFrameworkCompiler()
expect(compiler).not.toBeNull()
const compileHarness = spawnSync(
compiler,
['/nologo', '/target:exe', `/out:${harnessPath}`, harnessSourcePath],
{ encoding: 'utf8' }
)
expect(compileHarness.status, `${compileHarness.stdout}\n${compileHarness.stderr}`).toBe(0)
const launch = spawnSync(harnessPath, [launcherPath, outputPath], { encoding: 'utf8' })
expect(launch.status, `${launch.stdout}\n${launch.stderr}`).toBe(0)
expect(JSON.parse(readFileSync(outputPath, 'utf8'))).toEqual({
electronRunAsNode: '1',
pathKeys: ['PATH', 'Path']
})
} finally {
removeFixtureTree(appRoot)
}
})
})
function findFrameworkCompiler() {
const windowsDirectory = process.env.WINDIR ?? process.env.SystemRoot
if (!windowsDirectory) {
return null
}
return (
[
join(windowsDirectory, 'Microsoft.NET', 'Framework64', 'v4.0.30319', 'csc.exe'),
join(windowsDirectory, 'Microsoft.NET', 'Framework', 'v4.0.30319', 'csc.exe')
].find((candidate) => existsSync(candidate)) ?? null
)
}
@@ -0,0 +1,66 @@
const SEMVER = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/
function parseVersionTriple(value) {
const match = SEMVER.exec(String(value ?? '').trim())
if (!match) {
return null
}
return {
major: Number(match[1]),
minor: Number(match[2]),
patch: Number(match[3]),
prerelease: match[4] ?? null
}
}
function compareTriples(a, b) {
return a.major - b.major || a.minor - b.minor || a.patch - b.patch
}
/**
* The base `X.Y.Z` an hourly or adhoc build should carry.
*
* Why not package.json alone: main's version only moves on `release:` commits, and
* stable patches are cut from release branches that never merge back. On
* 2026-08-03 main read `1.4.165-rc.0` for twenty hours while 1.4.165, 1.4.166 and
* 1.4.167 all shipped — so hourlies built from that main claimed 1.4.165 while
* carrying code newer than 1.4.167, and sorted *below* the stable their user was
* already running. Published tags are the only honest answer to "what number is
* taken"; package.json is a floor, not a source of truth.
*/
export function resolveDevChannelBaseVersion(packageVersion, publishedVersions = []) {
const fromPackage = parseVersionTriple(packageVersion)
if (!fromPackage) {
throw new Error(`Package version is not valid semver: ${packageVersion}`)
}
// Unparseable tags are skipped rather than fatal: the main repo carries old tags
// that predate the current scheme, and one of them must not fail every build.
const published = publishedVersions.map(parseVersionTriple).filter(Boolean)
let base = fromPackage
if (published.length > 0) {
const highest = published.reduce((best, entry) =>
compareTriples(entry, best) > 0 ? entry : best
)
// A shipped stable owns its number, so the next dev build belongs on the patch
// above it. A bare prerelease does not — rc.1 of 1.4.168 means 1.4.168 is still
// the version being worked toward, which is exactly what main is building.
const shipped = published.some(
(entry) => !entry.prerelease && compareTriples(entry, highest) === 0
)
const next = shipped ? { ...highest, patch: highest.patch + 1 } : highest
if (compareTriples(next, base) > 0) {
base = next
}
}
return `${base.major}.${base.minor}.${base.patch}`
}
/** Tag list the workflow reads out of the main repo, newline separated. */
export function readPublishedVersionsFromEnv(value = process.env.ORCA_PUBLISHED_VERSIONS) {
return String(value ?? '')
.split(/\s+/)
.filter(Boolean)
}
@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest'
import {
readPublishedVersionsFromEnv,
resolveDevChannelBaseVersion
} from './dev-channel-base-version.mjs'
describe('dev channel base version', () => {
it('falls back to package.json when no tags are supplied', () => {
expect(resolveDevChannelBaseVersion('1.4.168-rc.1')).toBe('1.4.168')
expect(resolveDevChannelBaseVersion('1.4.168', [])).toBe('1.4.168')
})
// The bug this exists for: main sat at 1.4.165-rc.0 for twenty hours while three
// stables shipped, so hourlies claimed a version their users had already passed.
it('climbs past stables that shipped while main stood still', () => {
expect(
resolveDevChannelBaseVersion('1.4.165-rc.0', [
'v1.4.165',
'v1.4.166',
'v1.4.167',
'v1.4.165-rc.0'
])
).toBe('1.4.168')
})
// Why +1 on a stable but not on a prerelease: 1.4.167 is spent, so the next dev
// build is 1.4.168. But 1.4.168-rc.1 means 1.4.168 is still being built toward,
// which is what main holds — claiming 1.4.169 would jump a release nobody cut.
it('takes the patch above a shipped stable and holds at an open prerelease', () => {
expect(resolveDevChannelBaseVersion('1.4.167', ['v1.4.167'])).toBe('1.4.168')
expect(resolveDevChannelBaseVersion('1.4.168-rc.1', ['v1.4.168-rc.1'])).toBe('1.4.168')
})
// Why max and not most-recent: a hotfix on an old line published today would
// otherwise drag every subsequent hourly backwards.
it('reads the highest tag, not the last one listed', () => {
expect(resolveDevChannelBaseVersion('1.4.160', ['v1.4.167', 'v1.3.99', 'v1.4.120'])).toBe(
'1.4.168'
)
})
it('treats package.json as a floor when it leads the tags', () => {
expect(resolveDevChannelBaseVersion('1.5.0-rc.0', ['v1.4.167'])).toBe('1.5.0')
})
// Why skipped rather than fatal: the main repo carries legacy tags, and one
// unparseable entry must not fail every hourly build.
it('ignores tags it cannot parse', () => {
expect(resolveDevChannelBaseVersion('1.4.160', ['nightly', '', 'v1.4.167', 'latest'])).toBe(
'1.4.168'
)
})
it('rejects a package version that is not semver', () => {
expect(() => resolveDevChannelBaseVersion('not-a-version')).toThrow(/not valid semver/)
})
it('carries major and minor rollovers through the bump', () => {
expect(resolveDevChannelBaseVersion('1.4.0', ['v2.0.0'])).toBe('2.0.1')
})
it('splits an env tag list on any whitespace', () => {
expect(readPublishedVersionsFromEnv('v1.4.167\nv1.4.166\n')).toEqual(['v1.4.167', 'v1.4.166'])
expect(readPublishedVersionsFromEnv('')).toEqual([])
expect(readPublishedVersionsFromEnv(undefined)).toEqual([])
})
})
@@ -0,0 +1,123 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.ComponentModel;
using System.Runtime.InteropServices;
using System.Text;
internal static class DuplicatePathProcessLauncher
{
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct StartupInfo
{
public int cb;
public string lpReserved;
public string lpDesktop;
public string lpTitle;
public int dwX;
public int dwY;
public int dwXSize;
public int dwYSize;
public int dwXCountChars;
public int dwYCountChars;
public int dwFillAttribute;
public int dwFlags;
public short wShowWindow;
public short cbReserved2;
public IntPtr lpReserved2;
public IntPtr hStdInput;
public IntPtr hStdOutput;
public IntPtr hStdError;
}
[StructLayout(LayoutKind.Sequential)]
private struct ProcessInformation
{
public IntPtr hProcess;
public IntPtr hThread;
public int dwProcessId;
public int dwThreadId;
}
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern bool CreateProcess(
string applicationName,
StringBuilder commandLine,
IntPtr processAttributes,
IntPtr threadAttributes,
bool inheritHandles,
uint creationFlags,
IntPtr environment,
string currentDirectory,
ref StartupInfo startupInfo,
out ProcessInformation processInformation
);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern uint WaitForSingleObject(IntPtr handle, uint milliseconds);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool GetExitCodeProcess(IntPtr process, out uint exitCode);
[DllImport("kernel32.dll")]
private static extern bool CloseHandle(IntPtr handle);
private static int Main(string[] args)
{
List<string> entries = new List<string>();
foreach (DictionaryEntry entry in Environment.GetEnvironmentVariables())
{
if (!String.Equals((string)entry.Key, "PATH", StringComparison.OrdinalIgnoreCase))
{
entries.Add((string)entry.Key + "=" + (string)entry.Value);
}
}
entries.Add("PATH=C:\\live");
entries.Add("Path=C:\\shadowed");
entries.Add("ORCA_TEST_OUTPUT=" + args[1]);
IntPtr environment = Marshal.StringToHGlobalUni(String.Join("\0", entries.ToArray()) + "\0\0");
StartupInfo startupInfo = new StartupInfo();
startupInfo.cb = Marshal.SizeOf(startupInfo);
ProcessInformation processInformation;
try
{
bool started = CreateProcess(
args[0],
new StringBuilder("\"" + args[0] + "\""),
IntPtr.Zero,
IntPtr.Zero,
false,
0x00000400,
environment,
null,
ref startupInfo,
out processInformation
);
if (!started)
{
throw new Win32Exception(Marshal.GetLastWin32Error());
}
}
finally
{
Marshal.FreeHGlobal(environment);
}
try
{
WaitForSingleObject(processInformation.hProcess, 0xffffffff);
uint exitCode;
if (!GetExitCodeProcess(processInformation.hProcess, out exitCode))
{
throw new Win32Exception(Marshal.GetLastWin32Error());
}
return (int)exitCode;
}
finally
{
CloseHandle(processInformation.hThread);
CloseHandle(processInformation.hProcess);
}
}
}
@@ -0,0 +1,72 @@
/** @vitest-environment happy-dom */
import { describe, expect, it } from 'vitest'
import {
installHappyDomMutationObserverRetention,
retainedMutationCallbackCount
} from './happy-dom-mutation-observer-retention'
function readListenerCallbacks(target: Node): unknown[] {
const listenersSymbol = Object.getOwnPropertySymbols(target).find(
(candidate) => candidate.description === 'mutationListeners'
)
const listeners = listenersSymbol
? (target as unknown as Record<symbol, unknown>)[listenersSymbol]
: []
if (!Array.isArray(listeners)) {
return []
}
return listeners.map((listener: { callback?: { deref: () => unknown } }) =>
listener.callback?.deref()
)
}
describe('happy-dom MutationObserver retention', () => {
it('pins the internal callback that happy-dom only holds weakly', () => {
expect(installHappyDomMutationObserverRetention()).toBe(true)
const target = document.createElement('div')
document.body.append(target)
const observer = new MutationObserver(() => {})
observer.observe(target, { childList: true, subtree: true })
const callbacks = readListenerCallbacks(target)
expect(callbacks.length).toBe(1)
expect(callbacks[0]).toBeTypeOf('function')
expect(retainedMutationCallbackCount(observer)).toBe(1)
observer.disconnect()
expect(retainedMutationCallbackCount(observer)).toBe(0)
target.remove()
})
it('keeps delivering records after the weak callback would have been collected', async () => {
installHappyDomMutationObserverRetention()
const target = document.createElement('div')
document.body.append(target)
let deliveries = 0
const observer = new MutationObserver(() => {
deliveries += 1
})
observer.observe(target, { childList: true, subtree: true })
target.replaceChildren(document.createElement('div'))
await Promise.resolve()
expect(deliveries).toBe(1)
const collectGarbage = (globalThis as { gc?: () => void }).gc
if (collectGarbage) {
for (let round = 0; round < 5; round += 1) {
await new Promise((resolve) => setTimeout(resolve, 1))
collectGarbage()
}
await new Promise((resolve) => setTimeout(resolve, 1))
}
target.replaceChildren(document.createElement('span'))
await Promise.resolve()
expect(deliveries).toBe(2)
observer.disconnect()
target.remove()
})
})
@@ -0,0 +1,81 @@
// Why: happy-dom stores each MutationObserver's internal callback in a WeakRef, so any GC pause
// under parallel test load silently and permanently stops a still-connected observer. Browsers
// keep that callback reachable for as long as the observer observes; mirror that lifetime here so
// DOM-driven tests never lose mutation records mid-run.
type HappyDomMutationListener = {
callback?: { deref: () => unknown }
}
type PatchableMutationObserver = {
observe: (target: Node, options?: MutationObserverInit) => void
disconnect: () => void
}
const MUTATION_LISTENERS_SYMBOL_DESCRIPTION = 'mutationListeners'
const RETENTION_INSTALLED = Symbol.for('orca.happyDomMutationObserverRetention')
const retainedCallbacks = new WeakMap<object, Set<unknown>>()
function readMutationListeners(target: Node): HappyDomMutationListener[] {
const listenersSymbol = Object.getOwnPropertySymbols(target).find(
(candidate) => candidate.description === MUTATION_LISTENERS_SYMBOL_DESCRIPTION
)
if (!listenersSymbol) {
return []
}
const listeners = (target as unknown as Record<symbol, unknown>)[listenersSymbol]
return Array.isArray(listeners) ? (listeners as HappyDomMutationListener[]) : []
}
/** Number of internal callbacks pinned for `observer`; drops to 0 once it disconnects. */
export function retainedMutationCallbackCount(observer: MutationObserver): number {
return retainedCallbacks.get(observer)?.size ?? 0
}
export function installHappyDomMutationObserverRetention(): boolean {
const observerClass = (globalThis as { MutationObserver?: typeof MutationObserver })
.MutationObserver
if (!observerClass) {
return false
}
const prototype = observerClass.prototype as unknown as PatchableMutationObserver &
Record<symbol, unknown>
if (prototype[RETENTION_INSTALLED] === true) {
return true
}
const observe = prototype.observe
const disconnect = prototype.disconnect
prototype.observe = function patchedObserve(
this: object,
target: Node,
options?: MutationObserverInit
): void {
const existing = new Set(readMutationListeners(target))
observe.call(this as unknown as PatchableMutationObserver, target, options)
const pinned = retainedCallbacks.get(this) ?? new Set<unknown>()
for (const listener of readMutationListeners(target)) {
if (existing.has(listener)) {
continue
}
const callback = listener.callback?.deref()
if (callback) {
pinned.add(callback)
}
}
if (pinned.size > 0) {
retainedCallbacks.set(this, pinned)
}
}
prototype.disconnect = function patchedDisconnect(this: object): void {
disconnect.call(this as unknown as PatchableMutationObserver)
retainedCallbacks.delete(this)
}
prototype[RETENTION_INSTALLED] = true
return true
}
installHappyDomMutationObserverRetention()
+8 -3
View File
@@ -2,6 +2,10 @@ import { execFileSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { formatReleaseTitleTimestamp } from './release-title-timestamp.mjs'
import {
readPublishedVersionsFromEnv,
resolveDevChannelBaseVersion
} from './dev-channel-base-version.mjs'
/** `1.4.160-hourly.202607281400` — UTC to the minute, so tags sort chronologically
* by semver and every build is uniquely versioned. */
@@ -45,18 +49,19 @@ export function formatHourlyReleaseName(version, buildNumber, commit, date) {
].join(' • ')
}
export function getHourlyBuildIdentity(now = new Date(), buildNumber = 1) {
export function getHourlyBuildIdentity(now = new Date(), buildNumber = 1, publishedVersions = []) {
const packageJson = JSON.parse(readFileSync(resolve('package.json'), 'utf8'))
const commit = execFileSync('git', ['rev-parse', '--short=12', 'HEAD'], {
encoding: 'utf8'
}).trim()
const version = createHourlyBuildVersion(packageJson.version, now)
const base = resolveDevChannelBaseVersion(packageJson.version, publishedVersions)
const version = createHourlyBuildVersion(base, now)
return { commit, version, name: formatHourlyReleaseName(version, buildNumber, commit, now) }
}
if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) {
const buildNumber = Number(process.env.ORCA_HOURLY_BUILD_NUMBER ?? '1')
const identity = getHourlyBuildIdentity(new Date(), buildNumber)
const identity = getHourlyBuildIdentity(new Date(), buildNumber, readPublishedVersionsFromEnv())
// Consumed by the workflow via $GITHUB_OUTPUT.
process.stdout.write(
`version=${identity.version}\ncommit=${identity.commit}\nname=${identity.name}\n`
@@ -134,5 +134,30 @@ export const CROSS_LOCALE_KEY_OVERRIDES = {
'auto.hooks.useSettingsNavigationMetadata.40d80bad8a': {
zh: '测试版',
ja: 'ベータ'
},
// Issue/PR state picker; it sits beside 已关闭, so 进行中 is a different state.
'auto.components.PullRequestPage.7b8f6bf6d8': {
zh: '开放'
},
// This "Open" is the button that opens the config file, not an issue state.
'auto.components.settings.McpConfigFileRow.e720c139cd': {
zh: '打开'
},
// Terminal cursor-color group: the on-screen cursor, not the Cursor editor.
'auto.components.settings.TerminalWindowSection.c9e1fdf42f': {
ko: '커서',
zh: '光标'
},
'auto.components.onboarding.ThemeStep.ab2a583a97': {
ko: '커서',
zh: '光标'
},
// Tailwind swatch labels: bare 天空 (the sky), 锌 and 空 (the metal, the sky) are not colors.
'auto.components.sidebar.workspace.status.6437a8c253': {
zh: '天蓝',
ja: '空色'
},
'auto.components.sidebar.workspace.status.caabd5ca85': {
zh: '锌灰'
}
}
+102
View File
@@ -0,0 +1,102 @@
// Ordinary UI vocabulary that reads like a brand but is not one. Every locale should translate
// these, so the policy must neither pin the whole value to English nor revert the translation
// inside a sentence. The product-name homonyms (Continue.dev, the agent entries) stay Latin
// through ENGLISH_ONLY_KEY_PREFIXES, which matches on the catalog key rather than the word.
// Renderings a locale is expected to use. They are translations, never machine-translation
// errors, so a brand revert must leave them alone. Nonsense forms that share the same English
// term (zh 回购 "repurchase agreement", ja 端子 "electrical connector", es "Comprometerse")
// stay in BRAND_MISTRANSLATIONS and keep getting reverted.
const GENERIC_TERM_FAMILIES = [
{
terms: ['Agent', 'Agents', 'agent', 'agents'],
renderings: {
ko: ['에이전트'],
ja: ['エージェント'],
zh: ['代理', '智能体'],
es: ['Agente', 'agente', 'Agentes', 'agentes']
}
},
{
terms: ['Commit', 'Commits', 'commit', 'commits'],
renderings: {
ko: ['커밋'],
ja: ['コミット'],
zh: ['提交'],
es: [
'Confirmación',
'confirmación',
'Confirmaciones',
'confirmaciones',
'Confirmar',
'confirmar'
]
}
},
{
terms: ['Continue'],
renderings: {
ko: ['계속하다', '계속'],
ja: ['続ける', '続行'],
zh: ['继续'],
es: ['Continuar', 'continuar']
}
},
{
terms: ['Repo', 'Repos', 'repo', 'repos'],
renderings: {
ko: ['저장소', '레포'],
ja: ['リポジトリ', 'リポ'],
zh: ['存储库', '仓库'],
es: ['Repositorio', 'repositorio', 'Repositorios', 'repositorios']
}
},
{
terms: ['Terminal', 'Terminals', 'terminal', 'terminals'],
renderings: {
ko: ['터미널'],
ja: ['ターミナル'],
zh: ['终端'],
es: ['Terminales', 'terminales']
}
}
]
export const LOCALIZABLE_GENERIC_TERMS = new Set(
GENERIC_TERM_FAMILIES.flatMap((family) => family.terms)
)
const RENDERINGS_BY_TERM = new Map(
GENERIC_TERM_FAMILIES.flatMap((family) => family.terms.map((term) => [term, family.renderings]))
)
export function isLocalizableGenericTerm(term) {
return LOCALIZABLE_GENERIC_TERMS.has(term)
}
export function isCanonicalGenericRendering(term, locale, form) {
return RENDERINGS_BY_TERM.get(term)?.[locale]?.includes(form) ?? false
}
export function canonicalGenericRenderings(locale) {
return GENERIC_TERM_FAMILIES.flatMap((family) =>
(family.renderings[locale] ?? []).map((form) => ({ form, terms: family.terms }))
)
}
function spansOf(value, needle) {
const spans = []
for (let at = value.indexOf(needle); at !== -1; at = value.indexOf(needle, at + 1)) {
spans.push([at, at + needle.length])
}
return spans
}
// Why: zh 终端子进程 ("terminal sub-process") contains 端子, so a blind revert of the
// electrical-connector mistranslation would cut a valid word in half.
export function overlapsCanonicalRendering(term, locale, value, start, end) {
const renderings = RENDERINGS_BY_TERM.get(term)?.[locale] ?? []
return renderings.some((form) =>
spansOf(value, form).some(([from, to]) => start < to && from < end)
)
}
@@ -0,0 +1,174 @@
import { describe, expect, it } from 'vitest'
import { LOCALIZABLE_GENERIC_TERMS } from './locale-generic-ui-terms.mjs'
import { NEVER_TRANSLATE_VALUES, repairTranslatedValue } from './locale-translation-policy.mjs'
// Why: #12113 — repair-locale-catalog rewrote ~2000 translated values back to English because
// generic UI words (agent, terminal, commit, repo, Continue) were treated as brands. These pin
// the boundary: generic words stay translated, real brand names stay Latin.
describe('locale generic UI terms', () => {
it('keeps a translated generic term when the whole value is that term', () => {
expect(
repairTranslatedValue({
key: 'auto.components.settings.AppearancePane.terminalTitle',
enValue: 'Terminal',
localeValue: '터미널',
locale: 'ko'
})
).toBe('터미널')
expect(
repairTranslatedValue({
key: 'auto.components.settings.QuickCommandsPane.4ccc63da87',
enValue: 'Agent',
localeValue: '에이전트',
locale: 'ko'
})
).toBe('에이전트')
expect(
repairTranslatedValue({
key: 'auto.components.mobile.MobileHero.a8fb43cf1c',
enValue: 'Continue',
localeValue: '继续',
locale: 'zh'
})
).toBe('继续')
})
it('keeps a translated generic term inside a sentence', () => {
expect(
repairTranslatedValue({
key: 'auto.components.settings.EditorFontSection.matchTerminal',
enValue: 'Match terminal font',
localeValue: '터미널 글꼴과 동일',
locale: 'ko'
})
).toBe('터미널 글꼴과 동일')
expect(
repairTranslatedValue({
key: 'auto.components.settings.ExperimentalPane.agentDashboard.toggleLabel',
enValue: 'Toggle Agent Dashboard',
localeValue: '에이전트 대시보드 전환',
locale: 'ko'
})
).toBe('에이전트 대시보드 전환')
expect(
repairTranslatedValue({
key: 'components.agentSessionContinuation.dialogTitle',
enValue: 'Continue in New Session',
localeValue: '新しいセッションで続ける',
locale: 'ja'
})
// 新しい → 新規 is an unrelated phrase fix; 続ける is what must survive.
).toBe('新規セッションで続ける')
})
it('still reverts real brand names that machine translation localized', () => {
expect(
repairTranslatedValue({
key: 'auto.stats.StatsPane.7d26110cea',
enValue: 'Codex',
localeValue: '사본',
locale: 'ko'
})
).toBe('Codex')
expect(
repairTranslatedValue({
key: 'auto.components.settings.appearance.search.9ae151b26b',
enValue: 'linear',
localeValue: '线性',
locale: 'zh'
})
).toBe('Linear')
expect(
repairTranslatedValue({
key: 'auto.components.settings.NetworkPane.tailscale',
enValue: 'Connect over Tailscale',
localeValue: '通过尾鳞连接',
locale: 'zh'
})
).toBe('通过 Tailscale 连接')
})
it('still reverts nonsense renderings of a generic term', () => {
// 端子 is an electrical connector, 回购 a repurchase agreement, Comprometerse "to pledge".
expect(
repairTranslatedValue({
key: 'auto.components.agent.AgentCombobox.986f946354',
enValue: 'Blank Terminal',
localeValue: '空白端子',
locale: 'zh'
})
).toBe('空白 Terminal')
expect(
repairTranslatedValue({
key: 'auto.components.right.sidebar.source.control.primary.action.ed93b4f14f',
enValue: 'Commit',
localeValue: 'Comprometerse',
locale: 'es'
})
).toBe('Commit')
expect(
repairTranslatedValue({
key: 'auto.components.LinearIssueMarkdownDescriptionEditor.d9c47069ef',
enValue: 'Markdown',
localeValue: '가격 인하',
locale: 'ko'
})
).toBe('Markdown')
})
it('does not cut a valid word that contains a nonsense rendering', () => {
// 终端子进程 is "terminal sub-process" — 端子 sits inside 终端 + 子.
expect(
repairTranslatedValue({
key: 'auto.components.settings.AdvancedNetworkSettingsSection.823e0f15b1',
enValue: 'Proxy URL for Orca network requests and local terminal subprocesses.',
localeValue: '用于 Orca 网络请求和本地终端子进程的代理 URL。',
locale: 'zh'
})
).toBe('用于 Orca 网络请求和本地终端子进程的代理 URL。')
})
it('still fills in a translation when the catalog holds English', () => {
expect(
repairTranslatedValue({
key: 'auto.components.feature.wall.BrowserAnimatedVisual.04096318ab',
enValue: 'Terminal 1',
localeValue: 'Terminal 1',
locale: 'ko'
})
).toBe('터미널 1')
expect(
repairTranslatedValue({
key: 'auto.store.slices.worktrees.889487d8bb',
enValue: 'Dismiss',
localeValue: '해고하다',
locale: 'ko'
})
).toBe('닫기')
})
it('keeps agent catalog product names in English by key, not by word', () => {
expect(
repairTranslatedValue({
key: 'auto.lib.agent.catalog.9e2a9bb87b',
enValue: 'Continue',
localeValue: '继续',
locale: 'zh'
})
).toBe('Continue')
expect(
repairTranslatedValue({
key: 'auto.lib.agent.catalog.760bc6883d',
enValue: 'Codex',
localeValue: '사본',
locale: 'ko'
})
).toBe('Codex')
})
it('does not list a generic term as never-translatable', () => {
const pinned = [...LOCALIZABLE_GENERIC_TERMS].filter((term) => NEVER_TRANSLATE_VALUES.has(term))
expect(pinned).toEqual([])
})
})
+2 -2
View File
@@ -94,7 +94,7 @@ export const JA_VALUE_OVERRIDES = {
'Launch plan': '起動プラン',
'Launch agent': 'エージェントを起動',
'Launch {{value0}} in a new terminal': '新規ターミナルで {{value0}} を起動',
Play: 'Play',
Play: '再生',
Action: '操作',
Actions: '操作',
action: '操作',
@@ -135,7 +135,7 @@ export const JA_VALUE_OVERRIDES = {
'Pick a base branch below': '以下のベースブランチを選択',
'Choose floating workspace directory': 'フローティング ワークスペース ディレクトリを選択',
'Local project, Git repo, or folder with many repos':
'ローカルプロジェクト、Git repo、または多数の repos を含むフォルダー',
'ローカルプロジェクト、Git リポジトリ、または多数のリポジトリを含むフォルダー',
'Enter passphrase': 'パスフレーズを入力',
'Enter password': 'パスワードを入力',
'Enter the passphrase for': 'のパスフレーズを入力',
+6 -5
View File
@@ -162,8 +162,8 @@ const BASE_LOCALE_KEY_OVERRIDES = {
ja: 'クローズ'
},
'auto.components.GitHubItemDialog.dc1ca081a8': {
ko: '진행 중',
zh: '进行中',
ko: '열림',
zh: '开放',
ja: 'オープン'
},
'auto.components.tab.bar.TabBarCreateEntry.b27864279e': {
@@ -176,10 +176,11 @@ const BASE_LOCALE_KEY_OVERRIDES = {
zh: '打开 Linear 任务',
ja: 'Linear タスクを開く'
},
// Onboarding pill beside Orca Mobile: "New" marks a new feature, not a create action.
'auto.components.sidebar.SidebarNav.c86d83b5c3': {
ko: '새로 만들기',
zh: '新建',
ja: '新規'
ko: '신규',
zh: '新功能',
ja: '新機能'
},
'auto.components.sidebar.SidebarSettingsHelpMenu.eb9884e55b': {
ko: 'Discord',
+45 -3
View File
@@ -752,6 +752,21 @@
"auto.components.contextual.tours.ContextualTourProgressDots.dcd6e6b03e": {
"ko": "{{value1}}단계 중 {{value0}}단계"
},
"auto.components.contextual.tours.ContextualTourOverlaySurface.complete": {
"ko": "완료"
},
"auto.components.contextual.tours.contextual.tour.overlay.measurement.automations.intro.body": {
"ko": "자동화는 일정에 따라 agent 작업을 실행합니다. 이 버튼을 눌러 자동화를 추가하세요."
},
"auto.components.contextual.tours.contextual.tour.overlay.measurement.automations.intro.title": {
"ko": "자동화란 무엇인가요?"
},
"auto.components.contextual.tours.contextual.tour.overlay.measurement.automations.results.body": {
"ko": "실행 내역에서 자동화가 언제 실행되었는지, 어떤 일이 발생했는지, 출력을 어디서 확인할 수 있는지 볼 수 있습니다."
},
"auto.components.contextual.tours.contextual.tour.overlay.measurement.automations.results.title": {
"ko": "결과 확인"
},
"auto.components.crash.report.CrashReportDialog.88fea8e84e": {
"ko": "보내지 않음"
},
@@ -1868,6 +1883,9 @@
"auto.components.right.sidebar.SourceControl.3a231c845b": {
"ko": "알 수 없음"
},
"auto.components.right.sidebar.SourceControl.6d7f2a47e5": {
"ko": "폴더의 변경 사항 취소"
},
"auto.components.right.sidebar.SourceControl.812cb992ee": {
"ko": "{{value0}}에서 열기"
},
@@ -1880,6 +1898,9 @@
"auto.components.right.sidebar.SourceControl.9bb062a886": {
"ko": "commit 되지 않음"
},
"auto.components.right.sidebar.SourceControl.a5e5a11090": {
"ko": "모든 변경 사항 취소 실패 — 취소 전에 파일의 스테이징을 해제하지 못했습니다."
},
"auto.components.right.sidebar.SourceControl.aaf1451654": {
"ko": "초안 {{value0}} 만들기"
},
@@ -1994,6 +2015,9 @@
"auto.components.right.sidebar.source.control.discard.confirmation.2ae5a785b3": {
"ko": "스테이징되지 않은 변경 사항을 모두 삭제하시겠습니까?"
},
"auto.components.right.sidebar.source.control.discard.confirmation.40e9357b2a": {
"ko": "이렇게 하면 HEAD에서 파일을 복원하고 파일 삭제를 취소합니다. 이 작업은 취소할 수 없습니다."
},
"auto.components.right.sidebar.source.control.discard.confirmation.5ddd8cac7f": {
"ko": "스테이징된 변경 사항을 모두 삭제하시겠습니까?"
},
@@ -2015,6 +2039,9 @@
"auto.components.right.sidebar.source.control.primary.action.1d47e850cf": {
"ko": "연결된 리뷰 브랜치에 업데이트 푸시"
},
"auto.components.right.sidebar.source.control.primary.action.5a477d80cb": {
"ko": "모든 변경 사항 스테이징"
},
"auto.components.right.sidebar.source.control.primary.action.a5d1f7b036": {
"ko": "{{value0}}을(를) 만들기 전에 commits을 게시하세요."
},
@@ -2162,6 +2189,9 @@
"auto.components.settings.AutoRenameBranchFromWorkSetting.a4fa380b67": {
"ko": "{assistantMessage}"
},
"auto.components.settings.AutoRenameBranchFromWorkSetting.d9b65054ef": {
"ko": ") 작업을 요약하는 짧은 이름으로 변경됩니다. Orca가 직접 이름 붙인 브랜치만 이름을 바꾸며, 푸시된 후에는 이름을 바꾸지 않습니다."
},
"auto.components.settings.AutoRenameBranchPromptEditor.ebb942a2ec": {
"ko": "fix-login-flow"
},
@@ -2228,6 +2258,9 @@
"auto.components.settings.DefaultWindowsProjectRuntimeSetting.wslUnavailable": {
"ko": "WSL을 사용할 수 없습니다. WSL을 상속하는 프로젝트는 복구가 필요합니다."
},
"auto.components.settings.DevToolsPane.orcaCloudDescription": {
"ko": "자사 클라우드 로그인의 개발자 전용 미리보기. 프로덕션에서는 숨겨집니다. 개발 환경에서는 ORCA_CLOUD_API_URL과 ORCA_CLOUD_CLIENT_ID가 설정되면 사이드바 계정 전환기에도 표시됩니다."
},
"auto.components.settings.DeveloperPermissionsPane.16381e040a": {
"ko": "마이크"
},
@@ -2255,6 +2288,12 @@
"auto.components.settings.ExperimentalPane.newWorktreeCardStyle.description": {
"ko": "업데이트된 워크트리 카드 레이아웃, 메타데이터 배치, 카드 표시 메뉴 옵션 및 상태 표시를 미리 봅니다."
},
"auto.components.settings.EphemeralVmsPane.recipes": {
"ko": "레시피"
},
"auto.components.settings.EphemeralVmsPane.whatTitle": {
"ko": "이 스킬로 함께 하는 작업"
},
"auto.components.settings.GeneralEditorSettingsSection.45c6e85c4d": {
"ko": "편집기"
},
@@ -2727,7 +2766,7 @@
"ko": "이 SSH 대상의 원격 릴레이를 강제로 중지합니다. 이 대상의 활성 원격 terminals과 포트 포워딩이 종료됩니다."
},
"auto.components.settings.SshTargetForm.137e88ce8d": {
"ko": "연결 해제 후 릴레이가 terminals을 활성 상태로 유지하는 기간입니다. 기본값: 10800(3시간). 최대:"
"ko": "Orca가 이 호스트에서 연결 해제된 뒤에도 원격 터미널은 계속 실행됩니다."
},
"auto.components.settings.SshTargetForm.2ee9bcd2e8": {
"ko": "server, deploy@server:2222, ssh://server"
@@ -3710,6 +3749,9 @@
"auto.components.settings.ssh.search.f9493b80c0": {
"ko": "서버"
},
"auto.components.settings.source.control.action.recipe.options.commitMessage": {
"ko": "스테이징된 변경 사항에서 commit 메시지를 생성합니다."
},
"auto.components.settings.task.tracker.integration.cards.1a12e33fe5": {
"ko": "Linear 액세스 추가"
},
@@ -4683,7 +4725,7 @@
"ko": "중지 중"
},
"auto.components.status.bar.WorkspaceSpaceCompactPanel.8ff597593d": {
"ko": "Space"
"ko": "저장 공간"
},
"auto.components.status.bar.WorkspaceSpaceCompactPanel.9be86c46a0": {
"ko": "확보 가능"
@@ -4953,7 +4995,7 @@
"ko": "선택한 리포지토리와 일치하는 비활성 워크스페이스가 없습니다."
},
"auto.components.workspace.space.WorkspaceSpacePage.45f6302dbc": {
"ko": "Space"
"ko": "저장 공간"
},
"auto.components.workspace.space.WorkspaceSpacePage.8d0048e1cb": {
"ko": "워크스페이스 디스크 사용량과 회수 가능한 워크트리 저장 공간."
+1 -1
View File
@@ -209,7 +209,7 @@ export const KO_VALUE_OVERRIDES = {
'Install the Orca skill so agents know to use the Orca CLI.':
'에이전트가 Orca CLI를 사용하도록 Orca 스킬을 설치하세요.',
'Local project, Git repo, or folder with many repos':
'로컬 프로젝트, Git repo 또는 repos가 많은 폴더',
'로컬 프로젝트, Git 저장소 또는 저장소가 많은 폴더',
'Linear, GitLab, Bitbucket, Azure DevOps, Gitea, and Jira live in Settings > Integrations.':
'Linear, GitLab, Bitbucket, Azure DevOps, Gitea 및 Jira는 설정 > 연동에 있습니다.',
'changed since you last approved. Re-review before it runs':
+4 -3
View File
@@ -371,9 +371,10 @@ export const LOCALE_PHRASE_FIXES = {
{ pattern: /指挥进展/g, replacement: 'Conductor 进度', whenEnIncludes: 'Conductor Progress' },
{ pattern: /指挥评论/g, replacement: 'Conductor 评审', whenEnIncludes: 'Conductor Review' },
{ pattern: /指挥完成/g, replacement: 'Conductor 完成', whenEnIncludes: 'Conductor Done' },
{ pattern: /琥珀色/g, replacement: 'Amber', whenEnIncludes: 'Amber' },
{ pattern: /蓝色的/g, replacement: 'Blue', whenEnIncludes: 'Blue' },
{ pattern: /中性的/g, replacement: 'Neutral', whenEnIncludes: 'Neutral' },
// Swatch labels want the bare color noun, not the adjectival 的 form — and not English.
{ pattern: /琥珀色/g, replacement: '琥珀', whenEnIncludes: 'Amber' },
{ pattern: /蓝色的/g, replacement: '蓝色', whenEnIncludes: 'Blue' },
{ pattern: /中性的/g, replacement: '中性', whenEnIncludes: 'Neutral' },
{ pattern: /破坏性的/g, replacement: 'destructive', whenEnIncludes: 'destructive' },
{ pattern: /注解/g, replacement: '批注', whenEnIncludes: 'Annotation' },
...ZH_PHRASE_FIXES_ROUND5
@@ -1,19 +0,0 @@
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)
}
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest'
import { repairCatalog } from './locale-translation-policy.mjs'
// Regression: en.json routinely carries keys a locale catalog has not been bootstrapped with yet
// (~190 per locale at the time of writing), which crashed the whole repair run before it did any work.
describe('repairCatalog with un-bootstrapped keys', () => {
const enCatalog = {
auto: {
lib: { agent: { catalog: { '760bc6883d': 'Codex' } } },
components: { untranslated: 'Continue', nested: { alsoMissing: 'Refresh' } }
}
}
const translatedOnly = () => ({ auto: { lib: { agent: { catalog: { '760bc6883d': '사본' } } } } })
it('skips leaves the locale catalog is missing instead of throwing', () => {
for (const locale of ['ko', 'ja', 'zh', 'es']) {
const localeCatalog = translatedOnly()
expect(() => repairCatalog(enCatalog, localeCatalog, locale), locale).not.toThrow()
expect(localeCatalog.auto.components, locale).toBeUndefined()
}
})
it('still repairs the leaves that are present', () => {
const localeCatalog = translatedOnly()
expect(repairCatalog(enCatalog, localeCatalog, 'ko')).toBe(1)
expect(localeCatalog.auto.lib.agent.catalog['760bc6883d']).toBe('Codex')
})
})
@@ -45,7 +45,7 @@ describe('locale-translation-policy ko round 5', () => {
localeValue: '에이전트가 Orca CLI 사용 방법을 알 수 있도록 Orca 기술을 설치합니다.',
locale: 'ko'
})
).toBe('agents가 Orca CLI를 사용하도록 Orca 스킬을 설치하세요.')
).toBe('에이전트가 Orca CLI를 사용하도록 Orca 스킬을 설치하세요.')
expect(
repairTranslatedValue({
key: 'auto.components.editor.MarkdownPreview.322afab6ff',
@@ -92,7 +92,8 @@ describe('locale-translation-policy ko round 5', () => {
).toBe('파이프라인')
})
it('keeps protected workflow terms in English', () => {
// Why: #12113 — brand names stay Latin, but generic workflow nouns keep their Korean.
it('keeps brand names English and generic workflow terms translated', () => {
expect(
repairTranslatedValue({
key: 'auto.components.feature.wall.BrowserAnimatedVisual.04096318ab',
@@ -100,7 +101,7 @@ describe('locale-translation-policy ko round 5', () => {
localeValue: '터미널 1',
locale: 'ko'
})
).toBe('Terminal 1')
).toBe('터미널 1')
expect(
repairTranslatedValue({
key: 'auto.components.skills.SkillsPage.38e0951c3a',
@@ -108,7 +109,7 @@ describe('locale-translation-policy ko round 5', () => {
localeValue: '에이전트 스킬',
locale: 'ko'
})
).toBe('Agent 스킬')
).toBe('에이전트 스킬')
expect(
repairTranslatedValue({
key: 'auto.components.LinearIssueMarkdownDescriptionEditor.d9c47069ef',
@@ -124,7 +125,7 @@ describe('locale-translation-policy ko round 5', () => {
localeValue: '푸시되지 않은 커밋',
locale: 'ko'
})
).toBe('푸시되지 않은 commits')
).toBe('푸시되지 않은 커밋')
expect(
repairTranslatedValue({
key: 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.0b1766738a',
@@ -132,7 +133,7 @@ describe('locale-translation-policy ko round 5', () => {
localeValue: '레포',
locale: 'ko'
})
).toBe('Repo')
).toBe('레포')
expect(
repairTranslatedValue({
key: 'auto.components.sidebar.add.repo.local.start.actions.fb4fc5380e',
@@ -140,7 +141,7 @@ describe('locale-translation-policy ko round 5', () => {
localeValue: '로컬 프로젝트, Git 저장소 또는 저장소가 많은 폴더',
locale: 'ko'
})
).toBe('로컬 프로젝트, Git repo 또는 repos가 많은 폴더')
).toBe('로컬 프로젝트, Git 저장소 또는 저장소가 많은 폴더')
})
it('re-glues Korean particles after Latin terms without gluing content words', () => {
@@ -3,7 +3,8 @@ import { describe, expect, it } from 'vitest'
import { repairTranslatedValue } from './locale-translation-policy.mjs'
describe('locale-translation-policy es round 5', () => {
it('keeps protected workflow terms in English', () => {
// Why: #12113 — brand names stay Latin, but generic workflow nouns keep their Spanish.
it('keeps brand names English and generic workflow terms translated', () => {
expect(
repairTranslatedValue({
key: 'auto.components.LinearIssueMarkdownDescriptionEditor.d9c47069ef',
@@ -43,7 +44,7 @@ describe('locale-translation-policy es round 5', () => {
localeValue: 'mensaje de confirmación',
locale: 'es'
})
).toBe('mensaje de Commit')
).toBe('mensaje de confirmación')
expect(
repairTranslatedValue({
key: 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.0b1766738a',
@@ -51,7 +52,7 @@ describe('locale-translation-policy es round 5', () => {
localeValue: 'repositorio',
locale: 'es'
})
).toBe('Repo')
).toBe('repositorio')
expect(
repairTranslatedValue({
key: 'auto.components.sidebar.add.repo.local.start.actions.fb4fc5380e',
@@ -59,6 +60,6 @@ describe('locale-translation-policy es round 5', () => {
localeValue: 'Proyecto local, repositorio de Git o carpeta con muchos repositorios',
locale: 'es'
})
).toBe('Proyecto local, repo de Git o carpeta con muchos repos')
).toBe('Proyecto local, repositorio de Git o carpeta con muchos repositorios')
})
})
@@ -27,7 +27,7 @@ describe('locale-translation-policy ja round 5', () => {
localeValue: '遊ぶ',
locale: 'ja'
})
).toBe('Play')
).toBe('再生')
expect(
repairTranslatedValue({
key: 'auto.components.TaskPage.8396825a14',
@@ -102,7 +102,8 @@ describe('locale-translation-policy ja round 5', () => {
).toBe('まずプロジェクトを追加')
})
it('keeps protected workflow terms in English', () => {
// Why: #12113 — brand names stay Latin, but generic workflow nouns keep their Japanese.
it('keeps brand names English and generic workflow terms translated', () => {
expect(
repairTranslatedValue({
key: 'auto.components.status.bar.WorkspaceSpaceManagerPanel.e9528a89b3',
@@ -118,7 +119,7 @@ describe('locale-translation-policy ja round 5', () => {
localeValue: 'エージェントのスキル',
locale: 'ja'
})
).toBe('Agent のスキル')
).toBe('エージェントのスキル')
expect(
repairTranslatedValue({
key: 'auto.components.tab.bar.TabBar.3d5d6c960d',
@@ -134,7 +135,7 @@ describe('locale-translation-policy ja round 5', () => {
localeValue: 'コミット',
locale: 'ja'
})
).toBe('commits')
).toBe('コミット')
expect(
repairTranslatedValue({
key: 'auto.components.mobile.slides.WorktreeListSlide.22971156df',
@@ -142,7 +143,7 @@ describe('locale-translation-policy ja round 5', () => {
localeValue: 'リポ',
locale: 'ja'
})
).toBe('Repo')
).toBe('リポ')
expect(
repairTranslatedValue({
key: 'auto.components.sidebar.add.repo.local.start.actions.fb4fc5380e',
@@ -151,6 +152,6 @@ describe('locale-translation-policy ja round 5', () => {
'ローカル プロジェクト、Git リポジトリ、または多数のリポジトリを含むフォルダー',
locale: 'ja'
})
).toBe('ローカルプロジェクト、Git repo、または多数の repos を含むフォルダー')
).toBe('ローカルプロジェクト、Git リポジトリ、または多数のリポジトリを含むフォルダー')
})
})
+30 -22
View File
@@ -1,5 +1,8 @@
import { CJK_LATIN_SPACED_TERMS } from './locale-cjk-latin-spaced-terms.mjs'
import { isLocalizedProseTermContext } from './locale-prose-term-exemptions.mjs'
import {
isCanonicalGenericRendering,
overlapsCanonicalRendering
} from './locale-generic-ui-terms.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'
@@ -17,9 +20,10 @@ const OPEN_IN_APP_CATALOG_PREFIX = 'auto.lib.open.in.app.catalog.'
// Why: product names and agent labels stay Latin — MT reads them as common words (Codex→copy, Gemini→zodiac).
export const ENGLISH_ONLY_KEY_PREFIXES = [AGENT_CATALOG_PREFIX, OPEN_IN_APP_CATALOG_PREFIX]
// Only genuine brand, product, and code tokens belong here. Ordinary UI words that happen to
// name a product (agent, terminal, commit, repo, Continue) live in locale-generic-ui-terms.mjs
// and are translated; their product sense is pinned by ENGLISH_ONLY_KEY_PREFIXES instead.
export const NEVER_TRANSLATE_VALUES = new Set([
'Agent',
'Agents',
'Aider',
'Amp',
'Android',
@@ -33,7 +37,6 @@ export const NEVER_TRANSLATE_VALUES = new Set([
'Codebuff',
'Codex',
'Command Code',
'Continue',
'Cursor',
'Droid',
'Devin',
@@ -60,30 +63,16 @@ export const NEVER_TRANSLATE_VALUES = new Set([
'Pi',
'PostHog',
'Qwen Code',
'Repo',
'Repos',
'Rovo Dev',
'Commit',
'Commits',
'Markdown',
'Terminal',
'Terminals',
'VS Code',
'Warp',
'Zed',
'agent',
'agents',
'android',
'codex',
'commit',
'commits',
'gemini',
'claude',
'markdown',
'repo',
'repos',
'terminal',
'terminals',
'gh',
'idle',
'anthropic',
@@ -368,6 +357,18 @@ function includesPreservedLatinTerm(value, term) {
return new RegExp(`(^|[^A-Za-z_])${escapeRegExp(term)}($|[^A-Za-z_])`).test(value)
}
function replaceMistranslatedForm(value, wrong, brand, locale) {
let result = ''
let cursor = 0
for (let at = value.indexOf(wrong); at !== -1; at = value.indexOf(wrong, cursor)) {
const end = at + wrong.length
result += value.slice(cursor, at)
result += overlapsCanonicalRendering(brand, locale, value, at, end) ? wrong : brand
cursor = end
}
return result + value.slice(cursor)
}
function applyBrandMistranslationFixes(enValue, localeValue, locale, key = '') {
let result = localeValue
const mistranslations = BRAND_MISTRANSLATIONS[locale] ?? {}
@@ -383,9 +384,6 @@ function applyBrandMistranslationFixes(enValue, localeValue, locale, key = '') {
if (isScreenCursorContext(brand, enValue, key)) {
continue
}
if (isLocalizedProseTermContext(brand, key)) {
continue
}
if (includesPreservedLatinTerm(result, brand)) {
continue
}
@@ -393,11 +391,16 @@ function applyBrandMistranslationFixes(enValue, localeValue, locale, key = '') {
if (!result.includes(wrong)) {
continue
}
// Why: #12113 — a generic term's correct translation is not a mistranslation; reverting it
// rewrote ~2000 translated values back to English on every repair run.
if (isCanonicalGenericRendering(brand, locale, wrong)) {
continue
}
// Why: "Copy identifier" legitimately uses 사본/复制 — only swap when English names the brand.
if (brand === 'Codex' && /\bCopy\b/i.test(enValue)) {
continue
}
result = result.replaceAll(wrong, brand)
result = replaceMistranslatedForm(result, wrong, brand, locale)
}
}
@@ -548,6 +551,11 @@ export function repairCatalog(enCatalog, localeCatalog, locale) {
for (const leaf of leaves) {
const current = leaf.key.split('.').reduce((cursor, part) => cursor?.[part], localeCatalog)
// Why: en.json carries keys the locale catalog has not been bootstrapped with yet; repair only
// rewrites values that already exist, so skip instead of crashing on undefined.
if (typeof current !== 'string') {
continue
}
const next = repairTranslatedValue({
key: leaf.key,
enValue: leaf.value,
@@ -80,7 +80,7 @@ describe('locale-translation-policy', () => {
localeValue: '壊れた小切手に対して AI エージェントを開始しました。',
locale: 'ja'
})
).toBe('失敗したチェックに対して AI agent を開始しました。')
).toBe('失敗したチェックに対して AI エージェントを開始しました。')
expect(
repairTranslatedValue({
key: 'auto.hooks.useSettingsNavigationMetadata.95a1886d94',
@@ -88,7 +88,7 @@ describe('locale-translation-policy', () => {
localeValue: '電話機からターミナルとエージェントを制御します。',
locale: 'ja'
})
).toBe('スマートフォンから terminals と agents を操作')
).toBe('スマートフォンからターミナルとエージェントを操作')
expect(
repairTranslatedValue({
key: 'auto.components.GitHubItemDialog.934add88b6',
@@ -267,7 +267,7 @@ describe('locale-translation-policy', () => {
localeValue: '未已检测代理',
locale: 'zh'
})
).toBe('未检测到 agents')
).toBe('未检测到代理')
expect(
repairTranslatedValue({
key: 'auto.components.skills.SkillsPage.38e0951c3a',
@@ -275,7 +275,7 @@ describe('locale-translation-policy', () => {
localeValue: '代理技巧',
locale: 'zh'
})
).toBe('Agent 技能')
).toBe('代理技能')
expect(
repairTranslatedValue({
key: 'auto.components.settings.appearance.search.9ae151b26b',
@@ -358,7 +358,7 @@ describe('locale-translation-policy', () => {
localeValue: 'コミットするものは何もありません。 PR はすでに統合されています。',
locale: 'ja'
})
).toBe('commit するものはありません。PR はすでにマージされています。')
).toBe('コミットするものはありません。PR はすでにマージされています。')
expect(
repairTranslatedValue({
key: 'auto.components.settings.integrations.search.581844769a',
@@ -546,7 +546,7 @@ describe('locale-translation-policy', () => {
localeValue: '玩',
locale: 'zh'
})
).toBe('Play')
).toBe('播放')
expect(
repairTranslatedValue({
key: 'auto.components.right.sidebar.SourceControlAgentActionDialogForm.1bc0bdbb5e',
@@ -86,7 +86,9 @@ describe('locale-translation-policy zh round 5', () => {
).toBe('集成')
})
it('keeps Terminal as a product surface term', () => {
// Why: #12113 — 终端 is the correct rendering and survives; only the 端子 ("electrical
// connector") mistranslation still reverts to Latin.
it('keeps terminal translated but reverts the 端子 mistranslation', () => {
expect(
repairTranslatedValue({
key: 'auto.components.settings.Settings.3de4bbb841',
@@ -94,7 +96,7 @@ describe('locale-translation-policy zh round 5', () => {
localeValue: '终端',
locale: 'zh'
})
).toBe('Terminal')
).toBe('终端')
expect(
repairTranslatedValue({
key: 'auto.components.feature.wall.BrowserAnimatedVisual.04096318ab',
@@ -102,7 +104,7 @@ describe('locale-translation-policy zh round 5', () => {
localeValue: '终端 1',
locale: 'zh'
})
).toBe('Terminal 1')
).toBe('终端 1')
expect(
repairTranslatedValue({
key: 'auto.components.agent.AgentCombobox.986f946354',
@@ -118,7 +120,7 @@ describe('locale-translation-policy zh round 5', () => {
localeValue: '分体式端子右',
locale: 'zh'
})
).toBe('向右拆分 Terminal')
).toBe('向右拆分终端')
expect(
repairTranslatedValue({
key: 'auto.components.settings.TerminalAppearanceSection.abcb4dd019',
@@ -126,7 +128,7 @@ describe('locale-translation-policy zh round 5', () => {
localeValue: '终端Cursor',
locale: 'zh'
})
).toBe('Terminal Cursor')
).toBe('终端 Cursor')
expect(
repairTranslatedValue({
key: 'auto.components.terminal.FloatingTerminalPanel.3215fc73e9',
@@ -137,7 +139,8 @@ describe('locale-translation-policy zh round 5', () => {
).toBe('新 Terminal')
})
it('keeps workflow terms in English', () => {
// Why: #12113 — brand names stay Latin, but generic workflow nouns keep their Chinese.
it('keeps brand names English and generic workflow terms translated', () => {
expect(
repairTranslatedValue({
key: 'auto.components.sidebar.SidebarNav.9c95e1ce91',
@@ -145,7 +148,7 @@ describe('locale-translation-policy zh round 5', () => {
localeValue: '代理',
locale: 'zh'
})
).toBe('Agents')
).toBe('代理')
expect(
repairTranslatedValue({
key: 'auto.components.GitHubItemDialog.28986b3747',
@@ -153,7 +156,7 @@ describe('locale-translation-policy zh round 5', () => {
localeValue: '已启动 AI 代理处理失败的检查。',
locale: 'zh'
})
).toBe('已启动 AI agent 处理失败的检查。')
).toBe('已启动 AI 代理处理失败的检查。')
expect(
repairTranslatedValue({
key: 'auto.components.LinearIssueMarkdownDescriptionEditor.d9c47069ef',
@@ -177,7 +180,7 @@ describe('locale-translation-policy zh round 5', () => {
localeValue: '次提交',
locale: 'zh'
})
).toBe('commits')
).toBe('次提交')
expect(
repairTranslatedValue({
key: 'auto.store.slices.worktrees.d1d78a7baa',
@@ -187,7 +190,7 @@ describe('locale-translation-policy zh round 5', () => {
'Git 无法安全删除分支“{{value0}}”{{value1}},因此 Orca 保留它以避免丢失本地提交。',
locale: 'zh'
})
).toBe('Git 无法安全删除分支“{{value0}}”{{value1}},因此 Orca 保留它以避免丢失本地 commits。')
).toBe('Git 无法安全删除分支“{{value0}}”{{value1}},因此 Orca 保留它以避免丢失本地提交。')
})
it('does not confuse proxy copy with Agent terminology', () => {
@@ -234,7 +237,7 @@ describe('locale-translation-policy zh round 5', () => {
localeValue: '本地项目、Git 存储库或包含多个存储库的文件夹',
locale: 'zh'
})
).toBe('本地项目、Git repo 或包含多个 repos 的文件夹')
).toBe('本地项目、Git 仓库或包含多个仓库的文件夹')
})
it('keeps product, provider, code, and shell tokens untranslated', () => {
+3 -2
View File
@@ -8,7 +8,7 @@ export const LOCALE_VALUE_OVERRIDES = {
'OpenCode Go': 'OpenCode Go',
'Open in Cursor': 'Abrir en Cursor',
'Local project, Git repo, or folder with many repos':
'Proyecto local, repo de Git o carpeta con muchos repos'
'Proyecto local, repositorio de Git o carpeta con muchos repositorios'
},
ko: {
Save: '저장',
@@ -305,7 +305,8 @@ export const LOCALE_VALUE_OVERRIDES = {
teams: '团队',
passing: '通过',
'opened this issue': '创建了此议题',
Open: '进行中',
// No blanket "Open" override: it is a verb on buttons (打开) and a state next to 已关闭 (开放),
// so a single value-wide mapping is wrong for half the call sites. Per-key catalog values decide.
'Join Discord': '加入 Discord',
'Pull request merged': '拉取请求已合并',
'Agent Skills': '代理技能',
+17 -16
View File
@@ -67,8 +67,8 @@ export const ZH_VALUE_OVERRIDES = {
Inline: '内联',
thread: '帖子串',
destructive: 'destructive',
sheet: 'sheet',
page: 'page',
sheet: '面板',
page: '页面',
Annotation: '批注',
'check #': '检查 #',
by: '由',
@@ -92,16 +92,18 @@ export const ZH_VALUE_OVERRIDES = {
'仓库操作方案。在此仓库自定义之前,将使用全局设置。',
'Adds action recipes for Source Control commit, pull request, branch-name, and fix actions.':
'为源代码管理的提交、拉取请求、分支命名和修复操作添加操作方案。',
Play: 'Play',
Flag: 'Flag',
Zinc: 'Zinc',
Rose: 'Rose',
Emerald: 'Emerald',
Amber: 'Amber',
Violet: 'Violet',
Sky: 'Sky',
Blue: 'Blue',
Neutral: 'Neutral',
// Workspace status icon and color swatch labels; siblings below are translated, so English
// pins here would leave the picker half-Chinese on the next catalog regeneration.
Play: '播放',
Flag: '标记',
Zinc: '锌灰',
Rose: '玫瑰',
Emerald: '翡翠',
Amber: '琥珀',
Violet: '紫罗兰',
Sky: '天蓝',
Blue: '蓝色',
Neutral: '中性',
Dashed: '虚线',
Dot: '圆点',
Circle: '圆形',
@@ -201,8 +203,7 @@ export const ZH_VALUE_OVERRIDES = {
'每个已连接的 Linear 工作区都有一个由活动运行时存储的密钥。全权限密钥可覆盖密钥所有者可访问的所有团队;受限密钥可随时更换。',
'Show Linear in the Tasks source picker and sidebar shortcuts.':
'在任务源选择器和侧边栏快捷方式中显示 Linear。',
'Local project, Git repo, or folder with many repos':
'本地项目、Git repo 或包含多个 repos 的文件夹',
'Local project, Git repo, or folder with many repos': '本地项目、Git 仓库或包含多个仓库的文件夹',
'Staged Changes': '已暂存的更改',
Changes: '更改',
'Untracked Files': '未跟踪文件',
@@ -210,8 +211,8 @@ export const ZH_VALUE_OVERRIDES = {
'Split Down': '向下拆分',
'Split Left': '向左拆分',
'Split Right': '向右拆分',
'Split Terminal Down': '向下拆分 Terminal',
'Split Terminal Right': '向右拆分 Terminal',
'Split Terminal Down': '向下拆分终端',
'Split Terminal Right': '向右拆分终端',
'Optional account switching for Claude while preserving shared chat context.':
'Claude 的可选账户切换,同时保留共享聊天上下文。',
'Countdown timer showing time until prompt cache expires (Claude agents).':
@@ -227,12 +227,79 @@ describe('orchestration skill guidance', () => {
expect(agentGuidance).toContain('After sending `worker_done`, end your turn')
expect(agentGuidance).toContain('idle at the agent prompt')
expect(agentGuidance).toContain('Do not poll or keep calling `orca orchestration check`')
expect(agentGuidance).toContain(
'do not start more work, poll, or attempt to close the terminal yourself'
)
expect(agentGuidance).toContain('fresh preamble + TASK block delivered as new terminal input')
expect(skill).not.toContain('post-completion polling messages')
expect(skill).not.toContain('every 2 minutes')
})
it('makes settled worker terminal release an explicit coordinator step', () => {
const skill = readSkill()
const workerLoop = getSection(skill, 'Preferred Supervised Worker Loop')
const agentGuidance = getSection(skill, 'Agent Guidance')
const nextAction = getSection(skill, 'Next Action')
expect(workerLoop).toContain(
'# Process every message. For each accepted worker_done that is not immediately reused:\n' +
'orca orchestration worker-release --dispatch <dispatch_id> --json'
)
expect(workerLoop).toContain(
'Acknowledge only after every message and required release decision is handled'
)
expect(workerLoop).toContain(
'read the `worker.agent_terminal_handle` field of `worker-show --dispatch <dispatch_id> --json`'
)
expect(workerLoop).toContain(
'orca orchestration worker-start --task <next_task_id> --terminal <handle> --json` so Orca ' +
'transfers cleanup ownership to the new Dispatch'
)
expect(workerLoop).toContain(
'Run `worker-release` after both succeeded and failed `worker_done` reports unless the user ' +
'explicitly asked to keep that worker live.'
)
expect(workerLoop).toContain('Release is post-completion cleanup, not cancellation')
expect(workerLoop).toContain('orca orchestration worker-retain --dispatch <dispatch_id> --json')
expect(workerLoop).toContain(
'the same Dispatch can be passed to `worker-release`, which clears the requested retention'
)
expect(agentGuidance).toContain(
'Coordinators must account for every settled worker terminal before waiting again or ending ' +
'the turn'
)
expect(agentGuidance).toContain('released workers remain readable through `worker-read`')
expect(nextAction).toContain(
'After every accepted `worker_done`, either transfer the exact terminal to an immediate ' +
'follow-up Dispatch or run `worker-release` before the next wait.'
)
})
it('never authorizes release from idle, timeout, or worker-side triggers', () => {
const skill = readSkill()
const workerLoop = getSection(skill, 'Preferred Supervised Worker Loop')
const agentGuidance = getSection(skill, 'Agent Guidance')
// The prohibition sentence is the guard the negative patterns below rely on.
expect(workerLoop).toContain(
'Do not release a worker because of a timeout, TUI idle state, heartbeat, status, question, ' +
'escalation, or rejected/stale `worker_done`.'
)
expect(workerLoop).toContain(
'do not substitute `terminal close`; follow the exact recovery action in the receipt'
)
expect(skill).not.toMatch(
/release[^.]*\bon (?:a |the )?(?:tui-?idle|idle|timeout|heartbeat|question|escalation)\b/iu
)
expect(skill).not.toMatch(
/\b(?:after|on|upon) (?:a |the )?(?:tui-?idle|idle state|timeout|heartbeat)\b[^.]*\brelease/iu
)
expect(agentGuidance).toContain(
'do not start more work, poll, or attempt to close the terminal yourself'
)
expect(agentGuidance).not.toMatch(/worker-release[^.]*\byourself\b/iu)
})
it('documents @grok in the Messaging group address list', () => {
const skill = readSkill()
const messaging = getSection(skill, 'Messaging')
@@ -85,3 +85,91 @@ describe('plain Node entry guard', () => {
)
})
})
// Why (#11161): Electron's module is not registered on worker threads either —
// require("electron") throws "Cannot find module 'electron'" inside a
// main-process worker and kills it at startup. The worker entries carried only
// hand-written "must stay electron-free" comments, and the port-scan worker sits
// one import away from a client that deliberately does require electron.
describe('worker thread entry guard', () => {
function runWorkerWriteBundle(plugin: Plugin, bundle: Rollup.OutputBundle): void {
const hook = plugin.writeBundle
if (typeof hook !== 'function') {
throw new Error('Expected writeBundle hook')
}
hook.call(
{ meta: { watchMode: false } } as never,
{ dir: createOutputDir() } as Rollup.NormalizedOutputOptions,
bundle
)
}
function workerChunk(name: string, code: string, imports: string[] = []): Rollup.OutputChunk {
return {
type: 'chunk',
code,
dynamicImports: [],
fileName: `${name}.js`,
imports,
isEntry: true,
name
} as Rollup.OutputChunk
}
it('rejects an Electron require reachable from a worker entry', () => {
const plugin = createPlainNodeEntryGuardPlugin()
const bundle = {
'port-scan-command-worker-entry.js': workerChunk(
'port-scan-command-worker-entry',
'require("electron")'
)
} as Rollup.OutputBundle
expect(() => runWorkerWriteBundle(plugin, bundle)).toThrow('requires electron')
})
it('names the worker-thread runtime so the failure is actionable', () => {
const plugin = createPlainNodeEntryGuardPlugin()
const bundle = {
'stt-worker.js': workerChunk('stt-worker', 'require("electron")')
} as Rollup.OutputBundle
expect(() => runWorkerWriteBundle(plugin, bundle)).toThrow('runs as a worker thread')
})
// The real risk is transitive: a worker entry importing a shared chunk that
// reaches the electron-requiring client, not a direct import anyone would spot.
it('follows shared chunks out of a worker entry', () => {
const plugin = createPlainNodeEntryGuardPlugin()
const bundle = {
'session-scanner-opencode-sqlite-worker-entry.js': workerChunk(
'session-scanner-opencode-sqlite-worker-entry',
'require("./chunks/shared.js")',
['chunks/shared.js']
),
'chunks/shared.js': {
type: 'chunk',
code: 'require("electron")',
dynamicImports: [],
fileName: 'chunks/shared.js',
imports: [],
isEntry: false,
name: 'shared'
} as Rollup.OutputChunk
} as Rollup.OutputBundle
expect(() => runWorkerWriteBundle(plugin, bundle)).toThrow('chunks/shared.js')
})
it('passes a clean worker entry', () => {
const plugin = createPlainNodeEntryGuardPlugin()
const bundle = {
'warp-theme-parser-worker.js': workerChunk(
'warp-theme-parser-worker',
'require("node:worker_threads")'
)
} as Rollup.OutputBundle
expect(() => runWorkerWriteBundle(plugin, bundle)).not.toThrow()
})
})
@@ -39,14 +39,23 @@ else
# Why the features are off: this repo holds releases and nothing else. Leaving
# issues open invites bug reports against a branch build in a repo nobody
# watches, where they are simply lost.
#
# Why --add-readme in a repo with no source: publishing a release creates a tag,
# and a tag needs a commit. Empty repo = "Repository is empty" 25 minutes in.
gh repo create "$ADHOC_REPO" \
--public \
--description "Adhoc macOS dev builds of Orca, cut from unlanded branches. Not a source repo." \
--add-readme \
--disable-issues \
--disable-wiki ||
fail "Could not create $ADHOC_REPO."
fi
# Also checked outside the create branch: a repo made before --add-readme is here.
if ! gh api "repos/$ADHOC_REPO/commits" --jq 'length' >/dev/null 2>&1; then
fail "$ADHOC_REPO has no commits — releases cannot be tagged. Add any file to it first."
fi
echo
echo "Granting $APP_SLUG access to $ADHOC_REPO..."
+62 -1
View File
@@ -6,6 +6,9 @@ import process from 'node:process'
// TypeScript 7 is a native CLI; AST consumers still need the legacy JavaScript API.
import ts from 'typescript-api'
import { canonicalGenericRenderings } from './locale-generic-ui-terms.mjs'
import { repairTranslatedValue } from './locale-translation-policy.mjs'
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts'])
const SKIP_PATH_PARTS = new Set(['.git', 'dist', 'node_modules', 'out', '__snapshots__', 'assets'])
const LOCALIZATION_FUNCTION_NAMES = new Set(['t', 'translate', 'translateMain'])
@@ -282,9 +285,51 @@ function applyMissingEnglishEntries(catalog, missing) {
return changed
}
// Why: #12113 — parity checks pass while repair-locale-catalog rewrites translated generic terms
// back to English, so drift only surfaces when someone regenerates the catalog.
export function collectGenericTermRegressions(enEntries, localeEntries, localeName) {
const renderings = canonicalGenericRenderings(localeName)
if (renderings.length === 0) {
return []
}
const regressions = []
for (const [key, enValue] of enEntries) {
const localeValue = localeEntries.get(key)
if (typeof enValue !== 'string' || typeof localeValue !== 'string') {
continue
}
const repaired = repairTranslatedValue({ key, enValue, localeValue, locale: localeName })
if (repaired === localeValue) {
continue
}
// Why: {{agent}} is an interpolation name, not English copy the reader sees.
const repairedCopy = repaired.replace(PLACEHOLDER_RE, '')
for (const { form, terms } of renderings) {
if (!localeValue.includes(form) || repaired.includes(form)) {
continue
}
if (
terms.some((term) => new RegExp(`(^|[^A-Za-z])${term}($|[^A-Za-z])`).test(repairedCopy))
) {
regressions.push({ key, form, localeValue, repaired })
break
}
}
}
return regressions
}
function formatGenericTermRegressions(regressions) {
return regressions
.map((entry) => `${entry.key}: ${entry.form} -> English (${entry.repaired})`)
.join('\n')
}
function verifyLocaleCatalog(enCatalog, localeName, localeCatalog) {
const { enEntries, localeEntries, missingInLocale, extraInLocale, interpolationMismatches } =
collectLocaleParityIssues(enCatalog, localeCatalog)
const genericTermRegressions = collectGenericTermRegressions(enEntries, localeEntries, localeName)
// Why: feature PRs own English declarations; absent target leaves deliberately
// use i18next's existing English fallback until a localization PR supplies them.
@@ -292,7 +337,11 @@ function verifyLocaleCatalog(enCatalog, localeName, localeCatalog) {
`${localeName}.json coverage: ${enEntries.size - missingInLocale.length}/${enEntries.size} translated, ${missingInLocale.length} missing.`
)
if (extraInLocale.length > 0 || interpolationMismatches.length > 0) {
if (
extraInLocale.length > 0 ||
interpolationMismatches.length > 0 ||
genericTermRegressions.length > 0
) {
console.error(`Locale catalog validation failed for ${localeName}.json.`)
if (extraInLocale.length > 0) {
console.error('')
@@ -310,6 +359,18 @@ function verifyLocaleCatalog(enCatalog, localeName, localeCatalog) {
console.error(`...and ${interpolationMismatches.length - 20} more interpolation mismatches`)
}
}
if (genericTermRegressions.length > 0) {
console.error('')
console.error(
'repair-locale-catalog would rewrite these translated terms back to English.',
'Treat the term as generic in config/scripts/locale-generic-ui-terms.mjs',
'instead of listing its translation as a mistranslation.'
)
console.error(formatGenericTermRegressions(genericTermRegressions.slice(0, 20)))
if (genericTermRegressions.length > 20) {
console.error(`...and ${genericTermRegressions.length - 20} more generic term regressions`)
}
}
return 1
}
@@ -4,7 +4,10 @@ import path from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { main as verifyLocalizationCatalog } from './verify-localization-catalog.mjs'
import {
collectGenericTermRegressions,
main as verifyLocalizationCatalog
} from './verify-localization-catalog.mjs'
function writeJson(filePath, value) {
writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8')
@@ -123,4 +126,47 @@ describe('verify-localization-catalog', () => {
report.mockRestore()
}
})
// Why: #12113 — parity checks passed while the repair policy rewrote translated terms to English.
it('flags catalog values the repair policy would rewrite back to English', () => {
const enEntries = new Map([['auto.example.commitLabel', 'Commit message']])
expect(
collectGenericTermRegressions(
enEntries,
new Map([['auto.example.commitLabel', 'mensaje de confirmación']]),
'es'
)
).toEqual([])
// A locale whose committed value is the English term is stable, not a regression.
expect(
collectGenericTermRegressions(
enEntries,
new Map([['auto.example.commitLabel', 'mensaje de Commit']]),
'es'
)
).toEqual([])
// 'Comprometerse' is a real mistranslation, so reverting it to Latin is expected.
expect(
collectGenericTermRegressions(
enEntries,
new Map([['auto.example.commitLabel', 'mensaje de Comprometerse']]),
'es'
)
).toEqual([])
})
it('ignores interpolation names when looking for English rewrites', () => {
expect(
collectGenericTermRegressions(
new Map([
['components.agentSessionContinuation.originalAgent', 'Original agent: {{agent}}']
]),
new Map([['components.agentSessionContinuation.originalAgent', '原智能体:{{agent}}']]),
'zh'
)
).toEqual([])
})
})
+2
View File
@@ -17,6 +17,8 @@ export default defineConfig({
environment: 'node',
// Why: Node 26's undefined Web Storage globals prevent Vitest from installing happy-dom's.
execArgv: ['--no-experimental-webstorage'],
// Why: happy-dom drops MutationObserver callbacks on GC; keep them alive like a browser does.
setupFiles: [resolve('config/scripts/happy-dom-mutation-observer-retention.ts')],
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: 13m">
<title>downloads: 13m</title>
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 14m">
<title>downloads: 14m</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">13m</text>
<text x="90" y="14">13m</text>
<text x="90" y="15" fill="#010101" fill-opacity=".3">14m</text>
<text x="90" y="14">14m</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 935 B

After

Width:  |  Height:  |  Size: 935 B

+43 -1
View File
@@ -155,6 +155,8 @@ display `:99` when no display exists:
Description=Orca runtime server
After=network-online.target
Wants=network-online.target
StartLimitIntervalSec=300
StartLimitBurst=5
[Service]
Type=simple
@@ -165,6 +167,7 @@ ExecStart=/opt/orca/orca-linux.AppImage serve --port 6768 --pairing-address 100.
StandardOutput=journal
StandardError=journal
Restart=on-failure
RestartPreventExitStatus=3
RestartSec=5
[Install]
@@ -174,6 +177,19 @@ WantedBy=multi-user.target
Replace `100.64.1.20` with the LAN, Tailscale, tunnel, or public hostname that
clients should use.
Exit status `3` means another process already owns this userData profile, so
`RestartPreventExitStatus=3` stops the unit instead of retrying a launch that
cannot succeed. Any other permanent startup fault is capped at 5 starts per
5 minutes; systemd's defaults (10s window, 5 starts) can never trip at
`RestartSec=5`, which is how one bad launch could restart thousands of times.
The start limit counts operator-initiated starts too, so once it trips systemd
refuses a plain `systemctl start` until the 5-minute window rolls over. Run
`sudo systemctl reset-failed orca-serve.service` first to clear it — the
[Upgrade](#upgrade-steps) and [Roll back](#roll-back) scripts already do.
On systemd older than 230 those two directives are spelled
`StartLimitInterval=`/`StartLimitBurst=` and belong in `[Service]`; Ubuntu
20.04, Orca's oldest supported base, ships systemd 245.
Enable the service:
```bash
@@ -228,6 +244,8 @@ Then add the display dependency to the Orca service:
Description=Orca runtime server
After=network-online.target orca-xvfb.service
Wants=network-online.target orca-xvfb.service
StartLimitIntervalSec=300
StartLimitBurst=5
[Service]
Type=simple
@@ -237,6 +255,7 @@ Environment=DISPLAY=:99
Environment=LIBGL_ALWAYS_SOFTWARE=1
ExecStart=/opt/orca/orca-linux.AppImage serve --port 6768 --pairing-address 100.64.1.20
Restart=on-failure
RestartPreventExitStatus=3
RestartSec=5
[Install]
@@ -415,6 +434,8 @@ recover_failed_upgrade() {
sudo rm -f /opt/orca/orca-linux.AppImage.recovering \
/opt/orca/VERSION.recovering
if ((recovery_ok)); then
# A tripped StartLimitBurst refuses a plain start
sudo systemctl reset-failed orca-serve.service || true
sudo systemctl start orca-serve.service || true
else
echo 'Upgrade recovery failed; service remains stopped' >&2
@@ -482,6 +503,8 @@ sudo mv "$ORCA_ROLLBACK_NEW" "$ORCA_ROLLBACK"
ORCA_BINARY_PROMOTED=1
sudo mv -f /opt/orca/orca-linux.AppImage.new /opt/orca/orca-linux.AppImage
sudo mv -f /opt/orca/VERSION.new /opt/orca/VERSION
# Clears a start-limit hit left by the version being replaced
sudo systemctl reset-failed orca-serve.service
sudo systemctl start orca-serve.service
ORCA_SERVICE_STOPPED=0
trap - EXIT
@@ -614,6 +637,8 @@ restart_after_rollback_error() {
fi
fi
if ((recovery_ok)); then
# A tripped StartLimitBurst refuses a plain start
sudo systemctl reset-failed orca-serve.service || true
sudo systemctl start orca-serve.service || true
else
echo 'Rollback recovery failed; service remains stopped' >&2
@@ -714,6 +739,8 @@ if ((ORCA_ROLLBACK_HAS_VERSION)); then
else
sudo rm -f /opt/orca/VERSION
fi
# The crash-looping build you are rolling back from tripped StartLimitBurst
sudo systemctl reset-failed orca-serve.service
sudo systemctl start orca-serve.service
ORCA_SERVICE_STOPPED=0
sudo rm -rf -- "$ORCA_RESTORE"
@@ -805,9 +832,24 @@ refuse to run there and print the command to run on the machine you want.
`orca` user and that `/opt/orca` is readable by that user.
- Clients cannot connect: make sure `--pairing-address` is an address reachable
from the client, and make sure firewalls allow the selected `--port`.
- Journal shows `Another Orca instance is already running for this userData
profile` and the unit exits `3`: another process already owns the profile, so
`RestartPreventExitStatus=3` leaves the unit `failed` on purpose. Find the
owner with `systemctl status orca-serve` and `pgrep -af orca`. Stop it (or
keep it and leave the unit down), then run
`sudo systemctl reset-failed orca-serve && sudo systemctl start orca-serve` —
`reset-failed` clears the failed state and any start-limit counter. If no owner
exists, the lock is stale (Chromium recorded a pid that
has since been reused): remove `SingletonLock` and `SingletonSocket` from the
userData directory and start again. If an earlier crash-loop already leaked
AppImage mounts, list them with `findmnt -rn -t fuse.orca-linux.AppImage` and
release only the ones with no live owner using `fusermount -uz <target>` (or
`umount -l <target>`), leaving the running instance's mount alone.
- Service crash-loops right after an upgrade: use [Roll back](#roll-back) with
the pre-upgrade `.ready` bundle. Do not rerun the upgrade first; doing so would
make the crashing version the next rollback binary.
make the crashing version the next rollback binary. The loop trips
`StartLimitBurst`, so any manual `systemctl start` outside that script needs
`sudo systemctl reset-failed orca-serve.service` first.
- Diagnosing other missing libraries: extract the AppImage without launching it
with `./orca-linux.AppImage --appimage-extract`, then run
`ldd squashfs-root/orca` to list any shared libraries the host is missing.
+86
View File
@@ -0,0 +1,86 @@
# Windows setup-runner shell
On native Windows, Orca writes the `orca.yaml` setup script (and the issue command) to a generated
runner file and types a launch command into a terminal. The runner is a **`.cmd` batch file by
default**, exactly as it has been since setup hooks shipped.
A script opts into bash by starting with a `#!` interpreter line:
```yaml
scripts:
setup: |
#!/usr/bin/env bash
[ -f .env ] || cp .env.example .env
pnpm install
```
Without that line the script keeps running under `cmd.exe`:
```yaml
scripts:
setup: |
copy .env.example .env
xcopy /E assets dist
```
## Why the script declares it, not the terminal preference
`terminalWindowsShell` says which shell *interactive terminals* open in. It says nothing about the
language a project's setup script is written in. Deriving the runner from it had two consequences:
- Windows users with batch-syntax setup scripts silently switched to bash on upgrade, so `copy`,
`xcopy`, `set VAR=value`, and `if errorlevel 1` stopped working.
- Two people on the same repo got different interpreters for the same `orca.yaml`, so no project
could write a setup script that worked for all of its Windows contributors.
A `#!` line is per-project, explicit, and identical for everyone who checks the repo out.
The same rule applies to the per-user setup command in **Settings → repository hooks**
(`repo.hookSettings.scripts.setup`): it is merged into the same script that reaches the runner, so a
POSIX one-liner stored there needs its own `#!` line to run under bash on Windows.
## What the `#!` line does and does not select
The generated runner is always executed by bash (`bash <runner>`; Git Bash on native Windows), on
every platform. The `#!` line therefore does two things:
- It declares the script is written for a POSIX shell, which is what selects the bash runner.
- Its option flags are replayed with `set`, so `#!/usr/bin/env -S bash -euo pipefail` really does
get `pipefail`. Without that replay the flags would be silently dropped, because `bash <runner>`
never parses the interpreter line. Only the flags `set` itself accepts
(`[--abefhkmnptuvxBCHP] [-o option]`) are replayed; invocation-only ones such as `-l` are
dropped, because `set -l` exits 2 and would abort the runner before its first line.
The interpreter name itself is not honored beyond "is this a POSIX shell": `#!/bin/sh` and
`#!/bin/zsh` scripts run under bash, exactly as they already did on macOS and Linux.
## Requirements for the bash runner
A `#!` line only takes effect when Orca can actually launch bash from the configured terminal — the
terminal shell must resolve to Git Bash (`resolveWindowsGitBashShellPath`). The generated runner
uses MSYS `/c/...` paths, which Cygwin and the WSL shim do not accept, and the launch command is
typed into whatever shell the terminal opened with.
When bash is not available (a PowerShell/cmd terminal, or an SSH-to-Windows host, which always uses
the remote's `.cmd` runner) the `#!` script is **not** executed under cmd. The generated `.cmd`
runner prints why and exits 1, because running the interpreter-agnostic prefix of a bash script
(`pnpm install`, `git submodule update`) and only failing at the first bash-only line leaves a
half-set-up worktree that looks finished.
## Launching a `.cmd` runner from a Git Bash terminal
The runner format and the shell that types the launch command are independent: a Git Bash terminal
with a batch-syntax setup script gets a `.cmd` runner launched from a bash pane. `cmd.exe /c
"C:\..."` cannot be used there — MSYS rewrites the bare `/c` switch into a drive path, so cmd opens
interactively and the runner never executes (issue #6896). Those launches reuse the PowerShell
`ProcessStartInfo` launcher (`buildWindowsCmdRunnerDelayedLaunchCommand`), which carries the switch
and the runner path outside the command line. `WorktreeSetupLaunch.shell` therefore describes the
launching pane; the runner file's `.cmd`/`.sh` extension describes the format.
The `wait-for-setup` gate follows the same split. The pane types the gate and already quoted the
agent startup command for itself, so a `.cmd` runner launched from a Git Bash pane still gets the
bash gate — PowerShell's `Invoke-Expression` cannot parse POSIX `'\''` escaping. The gate wraps the
same `ProcessStartInfo` launcher, so the batch runner is never handed to bash.
WSL worktrees and non-Windows platforms are unaffected: they always use the bash runner. SSH hosts
choose their runner from the remote path format, never from local Windows preferences.
+5
View File
@@ -216,6 +216,11 @@ export const electronViteConfig: UserConfig = {
'session-scanner-opencode-sqlite-worker-entry': resolve(
'src/main/ai-vault/session-scanner-opencode-sqlite-worker-entry.ts'
),
// Why: libuv spawns processes inline on the calling loop, so the port
// scan's probe commands run on a worker thread instead of the UI one.
'port-scan-command-worker-entry': resolve(
'src/main/ports/port-scan-command-worker-entry.ts'
),
// Why: forked with ELECTRON_RUN_AS_NODE so @parcel/watcher faults
// can't take down the main process (issue #7547).
'parcel-watcher-process-entry': resolve('src/main/ipc/parcel-watcher-process-entry.ts'),
+1
View File
@@ -1,5 +1,6 @@
node_modules/
src/terminal/terminal-webview-engine.generated.ts
src/components/pr-sidebar/mermaid-webview-engine.generated.ts
.expo/
dist/
/android/
+2 -2
View File
@@ -2,7 +2,7 @@
"expo": {
"name": "Orca",
"slug": "orca-mobile",
"version": "0.0.36",
"version": "0.0.37",
"orientation": "default",
"icon": "./assets/icon.png",
"userInterfaceStyle": "automatic",
@@ -75,7 +75,7 @@
"allowBackup": false,
"permissions": ["RECORD_AUDIO", "MODIFY_AUDIO_SETTINGS"],
"package": "com.stably.orca.mobile",
"versionCode": 9
"versionCode": 10
},
"plugins": [
"expo-router",
+9 -2
View File
@@ -164,7 +164,8 @@ export function HostScreen({
const [filters, setFilters] = useState<FilterState>({
filterRepoIds: new Set(),
hideSleeping: false,
hideDefaultBranch: false
hideDefaultBranch: false,
alwaysShowDefaultBranch: true
})
const [groupMode, setGroupMode] = useState<MobileGroupMode>('repo')
const [workspaceStatuses, setWorkspaceStatuses] = useState<readonly WorkspaceStatusDefinition[]>(
@@ -195,6 +196,7 @@ export function HostScreen({
sortMode: 'recent',
hideSleeping: false,
hideDefaultBranch: false,
alwaysShowDefaultBranch: true,
filterRepoIds: [],
collapsedGroups: [],
workspaceStatuses: DEFAULT_MOBILE_WORKSPACE_STATUSES
@@ -206,6 +208,7 @@ export function HostScreen({
sortMode,
hideSleeping: filters.hideSleeping,
hideDefaultBranch: filters.hideDefaultBranch,
alwaysShowDefaultBranch: filters.alwaysShowDefaultBranch !== false,
filterRepoIds: [...filters.filterRepoIds],
collapsedGroups: [...collapsedGroups],
workspaceStatuses
@@ -222,7 +225,8 @@ export function HostScreen({
setFilters({
filterRepoIds: new Set(next.filterRepoIds),
hideSleeping: next.hideSleeping,
hideDefaultBranch: next.hideDefaultBranch
hideDefaultBranch: next.hideDefaultBranch,
alwaysShowDefaultBranch: next.alwaysShowDefaultBranch
})
}, [])
@@ -234,6 +238,9 @@ export function HostScreen({
if (!client) {
return
}
// alwaysShowDefaultBranchWorkspace is deliberately absent: mobile reads it
// but has no toggle, so echoing its local default would silently revert a
// desktop opt-out on the first filter tap before ui.get lands (#8873).
const payload: WorkspaceViewSettings = {
groupBy: groupModeToDesktop(next.groupMode),
sortBy: next.sortMode,
+78 -100
View File
@@ -107,6 +107,7 @@ import type {
TerminalWebViewHandle
} from '../../../../src/terminal/terminal-webview-contract'
import { isTerminalOscLinkRanges } from '../../../../src/terminal/terminal-osc-link-ranges'
import { computeActiveTerminalKeyboardLift } from '../../../../src/terminal/terminal-keyboard-avoidance-lift'
import { useTerminalViewportRefit } from '../../../../src/terminal/terminal-viewport-refit'
import {
getDefaultTerminalAccessoryBuiltInIds,
@@ -195,7 +196,7 @@ import { MobileTerminalLiveInputStatus } from '../../../../src/session/MobileTer
import { MobileTerminalInputActions } from '../../../../src/session/MobileTerminalInputActions'
import { resolveMobileFileTabDoc } from '../../../../src/files/mobile-file-tab-doc'
import { captureMobileFileMutationOwnership } from '../../../../src/files/mobile-file-mutation-ownership'
import { openMobileTerminalFileTap } from '../../../../src/session/mobile-terminal-file-tap-open'
import { useMobileFileTapHandlers } from '../../../../src/session/use-mobile-file-tap-handlers'
import { useLiveWorktreeName } from '../../../../src/session/use-live-worktree-name'
import {
acceptSessionSnapshot,
@@ -235,6 +236,11 @@ import {
} from '../../../../src/session/mobile-terminal-prune-decision'
import { useMobileNativeChatTerminalStream } from '../../../../src/session/use-mobile-native-chat-terminal-stream'
import { subscribeMobileTerminalSafely } from '../../../../src/session/mobile-terminal-stream-subscribe'
import {
TerminalViewportResubscribeBudget,
readTerminalViewportDims,
runTerminalViewportFitPass
} from '../../../../src/session/mobile-terminal-viewport-resubscribe'
import { activateMobileSessionTab } from '../../../../src/session/mobile-session-tab-activation'
import { MobileTerminalDiagnostics } from '../../../../src/session/mobile-terminal-diagnostics'
import { runAcceptedMobileSessionTabsEffects } from '../../../../src/session/mobile-session-tabs-accepted-effects'
@@ -1030,6 +1036,8 @@ export default function SessionScreen() {
const subscribingHandlesRef = useRef<Set<string>>(new Set())
const initializedHandlesRef = useRef<Set<string>>(new Set())
const terminalDiagnosticsRef = useRef(new MobileTerminalDiagnostics())
// Why: bounds the scrollback→resubscribe fit loop per handle (STA-3337).
const viewportResubscribeBudgetRef = useRef(new TerminalViewportResubscribeBudget())
// Why: don't subscribe until the WebView fires web-ready — iOS may defer JS in hidden WebViews and init() messages would queue unrendered.
const webReadyHandlesRef = useRef<Set<string>>(new Set())
const activeHandleRef = useRef<string | null>(null)
@@ -1376,6 +1384,7 @@ export default function SessionScreen() {
subscribingHandlesRef.current.clear()
initializedHandlesRef.current.clear()
terminalDiagnosticsRef.current.clearTerminalCache()
viewportResubscribeBudgetRef.current.clear()
webReadyHandlesRef.current.clear()
subscribeSeqRef.current.clear()
layoutSeqRef.current.clear()
@@ -1505,10 +1514,11 @@ export default function SessionScreen() {
return
}
updateTerminalCwdFromStreamEvent(handle, data, terminalCwdRef.current)
const cols = (data.cols as number) || 80
const rows = (data.rows as number) || 24
const scrollbackCols = cols
const scrollbackRows = rows
const { hostCols, hostRows } = readTerminalViewportDims(data)
// Why: absent host dims must not be coerced into a comparable size — 80x24
// never equals a phone viewport and armed a zero-delay resubscribe loop (STA-3337).
const cols = hostCols ?? viewportRef.current?.cols ?? 80
const rows = hostRows ?? viewportRef.current?.rows ?? 24
const initialData =
typeof data.serialized === 'string' && data.serialized.length > 0
? data.serialized
@@ -1527,46 +1537,36 @@ export default function SessionScreen() {
ref.init(cols, rows, initialData, false, oscLinks)
initializedHandlesRef.current.add(handle)
if (data.displayMode) {
const displayMode = data.displayMode as MobileDisplayMode
// Why: same-mode frames must keep the Map identity, or every stream pass re-renders the whole route.
setTerminalModes((prev) =>
new Map(prev).set(handle, data.displayMode as MobileDisplayMode)
prev.get(handle) === displayMode ? prev : new Map(prev).set(handle, displayMode)
)
}
// Why: cold-start refit — init()'s fit can run against a transient scrollWidth, so re-fire against a settled DOM.
scheduleDelayedAction(() => getTerminalRef(handle)?.resetZoom(), 200)
// Why: first subscribe has no viewport (xterm not loaded yet), so measure after init and resubscribe so the server can phone-fit.
const needsResubscribe =
!viewportMeasuredRef.current ||
(viewportRef.current != null &&
(scrollbackCols !== viewportRef.current.cols ||
scrollbackRows !== viewportRef.current.rows))
if (needsResubscribe) {
void (async () => {
// Why: wait for init()'s rAF chain before measuring, else the measure races ahead and returns null (log dump 2026-05-06).
await getTerminalRef(handle)?.awaitReady()
if (subscribeSeqRef.current.get(handle) !== seq) {
return
}
const dims = await getTerminalRef(handle)?.measureFitDimensions(
terminalFrameHeightRef.current || undefined
)
// Why: re-check seq — the awaits may have let a newer subscribe cycle arm; tearing it down would resubscribe a stale generation.
if (subscribeSeqRef.current.get(handle) !== seq) {
return
}
if (!getTerminalRef(handle)) {
return
}
// Why: scrollback came back at cols=80 (server's null-viewport fallback), so this subscriber record has no viewport — resubscribe so the server stores it.
if (dims) {
diagnostics.streamResubscribing(handle, seq, dims)
viewportRef.current = dims
viewportMeasuredRef.current = true
unsubscribeTerminal(handle)
initializedHandlesRef.current.delete(handle)
subscribeToTerminal(handle)
}
})()
}
// Why: first subscribe has no viewport (xterm not loaded yet), so measure after init
// and resubscribe so the server can phone-fit — bounded per handle so a
// non-converging host degrades visibly instead of hot-looping (STA-3337).
runTerminalViewportFitPass({
handle,
seq,
hostCols,
hostRows,
budget: viewportResubscribeBudgetRef.current,
diagnostics,
viewportRef,
viewportMeasuredRef,
subscribeSeqRef,
initializedHandlesRef,
terminalUnsubsRef,
terminalFrameHeightRef,
getTerminalRef,
unsubscribeTerminal,
subscribeToTerminal,
scheduleDelayedAction,
showToast
})
} else if (data.type === 'metadata') {
updateTerminalCwdFromStreamEvent(handle, data, terminalCwdRef.current)
} else if (data.type === 'data') {
@@ -1591,8 +1591,12 @@ export default function SessionScreen() {
} else if (data.type === 'resized') {
updateTerminalCwdFromStreamEvent(handle, data, terminalCwdRef.current)
// Server resize: reinit xterm on a full-buffer snapshot (width reflow rewraps scrollback), else just resize geometry.
const cols = (data.cols as number) || 80
const rows = (data.rows as number) || 24
const viewport = viewportMeasuredRef.current ? viewportRef.current : null
const [cols, rows] = viewportResubscribeBudgetRef.current.observeResize(
handle,
data,
viewport
)
const serialized = typeof data.serialized === 'string' ? data.serialized : null
diagnostics.streamResized(handle, seq, eventSeq, data, getTerminalRef(handle) != null)
const oscLinks = isTerminalOscLinkRanges(data.oscLinks) ? data.oscLinks : undefined
@@ -1602,8 +1606,10 @@ export default function SessionScreen() {
getTerminalRef(handle)?.resize(cols, rows)
}
if (data.displayMode) {
const displayMode = data.displayMode as MobileDisplayMode
// Why: same-mode frames must keep the Map identity, or every stream pass re-renders the whole route.
setTerminalModes((prev) =>
new Map(prev).set(handle, data.displayMode as MobileDisplayMode)
prev.get(handle) === displayMode ? prev : new Map(prev).set(handle, displayMode)
)
}
scheduleDelayedAction(() => getTerminalRef(handle)?.resetZoom(), 200)
@@ -1619,7 +1625,7 @@ export default function SessionScreen() {
}
subscribingHandlesRef.current.delete(handle)
},
[client, getTerminalRef, markNativeChatInputLeaseReady, scheduleDelayedAction]
[client, getTerminalRef, markNativeChatInputLeaseReady, scheduleDelayedAction, showToast]
)
const nativeChatStream = useMobileNativeChatTerminalStream({
@@ -1720,12 +1726,16 @@ export default function SessionScreen() {
unsubscribeTerminal(handle)
terminalRefs.current.delete(handle)
initializedHandlesRef.current.delete(handle)
viewportResubscribeBudgetRef.current.forget(handle)
clearTerminalLiveInputDefault(handle)
}
setTerminalKeyboardMetrics((prev) => pruneTerminalKeyboardMetrics(prev, shouldPrune))
// Why: a chat-covered handle the host reports again refills its rearm budget,
// so an exhausted rearm can't lock the composer until leave-chat.
nativeChatStream.notifyListedHandles(liveHandles)
// Why: same absence-gated refill for the viewport-fit budget — a handle that
// left the list and returned may converge now, so it earns fresh attempts.
viewportResubscribeBudgetRef.current.notifyListedHandles(liveHandles)
lastKnownTerminalCountRef.current = result.terminals.length
// Why: dedupe duplicate handles (rename/split race) to avoid a React duplicate-key throw; keep first for tab-strip order.
const seen = new Set<string>()
@@ -3182,44 +3192,23 @@ export default function SessionScreen() {
})
}, [])
// Tap a terminal file path → resolve on host, open as file tab (mirrors desktop Cmd/Ctrl-click); silent on a miss.
const handleFileTapActivationSeqRef = useRef(0)
const handleFileTap = useCallback(
(handle: string, pathText: string, line: number | null, column: number | null) => {
if (handle !== activeHandleRef.current || !client) {
return
}
const activationSeq = ++handleFileTapActivationSeqRef.current
openMobileTerminalFileTap<MobileSessionTab>({
client,
hostId,
worktreeId,
worktreeName: routeWorktreeName,
terminalHandle: handle,
pathText,
cwd: terminalCwdRef.current.get(handle) ?? null,
line,
column,
pushPreviewRoute: (href) => router.push(href),
openBrowser: (url) => void handleCreateBrowserRef.current?.(url),
triggerOpenFeedback: triggerSelection,
fetchSessionTabs,
getSessionTabs: () => sessionTabsRef.current,
getActiveSessionTabId: () => activeSessionTabIdRef.current,
getActivationState: (activated) => ({
activated,
activationSeq,
latestActivationSeq: handleFileTapActivationSeqRef.current,
sourceTerminalHandle: handle,
activeTerminalHandle: activeHandleRef.current,
activeTabType: activeSessionTabTypeRef.current
}),
switchSessionTab: (tab) => switchSessionTabRef.current?.(tab),
scheduleDelayedAction
})
},
[client, fetchSessionTabs, hostId, routeWorktreeName, router, scheduleDelayedAction, worktreeId]
)
// Tap a terminal or chat file path → resolve on host, open as file tab/preview.
const { handleFileTap, handleNativeChatFileTap } = useMobileFileTapHandlers<MobileSessionTab>({
client,
hostId,
worktreeId,
worktreeName: routeWorktreeName,
activeHandleRef,
terminalCwdRef,
openBrowser: (url) => void handleCreateBrowserRef.current?.(url),
fetchSessionTabs,
getSessionTabs: () => sessionTabsRef.current,
getActiveSessionTabId: () => activeSessionTabIdRef.current,
getActiveSessionTabType: () => activeSessionTabTypeRef.current,
switchSessionTab: (tab) => switchSessionTabRef.current?.(tab),
scheduleDelayedAction,
reportChatTapFailure: nativeChatSendError.show
})
const handleOpenedFileDiffActivationSeqRef = useRef(0)
// Capture active tab at tap time; reading it after openDiff would misread a mid-RPC switch and let the retry steal focus.
@@ -3590,6 +3579,7 @@ export default function SessionScreen() {
if (
current &&
current.cursorY === metrics.cursorY &&
current.contentBottomRow === metrics.contentBottomRow &&
current.rows === metrics.rows &&
current.altScreen === metrics.altScreen
) {
@@ -4271,24 +4261,11 @@ export default function SessionScreen() {
? Math.max(0, keyboardHeight - insets.bottom)
: keyboardHeight
: 0
const activeTerminalKeyboardLift = (() => {
if (keyboardLift <= 0 || !activeHandle) {
return 0
}
const metrics = terminalKeyboardMetrics.get(activeHandle)
if (!metrics || metrics.rows <= 0 || terminalFrameHeightRef.current <= 0) {
return keyboardLift
}
if (metrics.altScreen) {
return keyboardLift
}
const rowHeight = terminalFrameHeightRef.current / metrics.rows
const cursorBottom = (metrics.cursorY + 1) * rowHeight
const dockTop = terminalFrameHeightRef.current - keyboardLift
const margin = rowHeight
// Why: only move the terminal when the cursor would sit under the raised input dock; short top output stays put.
return Math.min(keyboardLift, Math.max(0, cursorBottom + margin - dockTop))
})()
const activeTerminalKeyboardLift = computeActiveTerminalKeyboardLift({
keyboardLift,
metrics: activeHandle ? terminalKeyboardMetrics.get(activeHandle) : undefined,
terminalFrameHeight: terminalFrameHeightRef.current
})
const toastAnimatedStyle = {
opacity: toastOpacityRef.current,
transform: [{ translateY: -keyboardLift }]
@@ -4752,6 +4729,7 @@ export default function SessionScreen() {
))}
<MobileNativeChatOverlay
controller={nativeChatController}
onOpenFile={handleNativeChatFileTap}
images={nativeChatImages}
onMicPress={handleDictationToggle}
micActive={dictation.isRecording}
+78 -127
View File
@@ -16,16 +16,13 @@ import {
} from '../src/components/AccountUsage'
import AsyncStorage from '@react-native-async-storage/async-storage'
import { loadHosts } from '../src/transport/host-store'
import { navigateToMobileHostEdit } from '../src/transport/host-edit-navigation'
import { useOpenMobileHostEdit } from '../src/transport/use-open-mobile-host-edit'
import { removeHostAndCloseClient } from '../src/transport/host-removal-lifecycle'
import { pickResumeWorktree } from '../src/worktree/resume-worktree'
import { WORKTREE_PS_FULL_LIMIT } from '../src/worktree/worktree-catalog-snapshot-client'
import {
markHomeWorktreeCatalogUnavailable,
type HomeWorktreeSummary,
type HostWorktreeInfo
} from '../src/worktree/home-worktree-info'
import { fetchHomeHostWorktreeInfo } from '../src/worktree/home-host-worktree-fetch'
import { totalHomeStats, type HomeStatsSummary } from '../src/stats/home-stats-total'
import type { HomeWorktreeSummary, HostWorktreeInfo } from '../src/worktree/home-worktree-info'
import type { RpcClient } from '../src/transport/rpc-client'
import { createHostConnectRefetchGate } from '../src/transport/host-connect-refetch-gate'
import { sendSingleFlightRequest } from '../src/transport/request-single-flight'
import {
useAllHostClients,
@@ -68,13 +65,6 @@ function endpointLabel(endpoint: string): string {
}
}
type StatsSummary = {
totalAgentsSpawned: number
totalPRsCreated: number
totalAgentTimeMs: number
firstEventAt: number | null
}
type HomeTaskSettings = {
visibleTaskProviders?: unknown
}
@@ -123,7 +113,9 @@ function clientKey(client: RpcClient): number {
function fetchStats(
client: RpcClient,
hostId: string,
setStats: (s: StatsSummary) => void,
setStats: (
updater: (prev: Record<string, HomeStatsSummary>) => Record<string, HomeStatsSummary>
) => void,
disposed: () => boolean
) {
sendSingleFlightRequest(client, hostId, 'stats.summary')
@@ -132,61 +124,13 @@ function fetchStats(
return
}
if (response.ok) {
setStats(response.result as StatsSummary)
// Keyed by host: the header totals every desktop instead of showing whoever replied last.
setStats((prev) => ({ ...prev, [hostId]: response.result as HomeStatsSummary }))
}
})
.catch(() => {})
}
function fetchWorktreeInfo(
client: RpcClient,
hostId: string,
setInfo: (
updater: (prev: Record<string, HostWorktreeInfo>) => Record<string, HostWorktreeInfo>
) => void,
disposed: () => boolean
) {
const markUnavailable = () => {
setInfo((prev) => {
const current = prev[hostId]
const next = markHomeWorktreeCatalogUnavailable(current, hostId)
return next === current ? prev : { ...prev, [hostId]: next }
})
}
sendSingleFlightRequest(client, hostId, 'worktree.ps', { limit: WORKTREE_PS_FULL_LIMIT })
.then((response) => {
if (disposed()) {
return
}
if (response.ok) {
const result = response.result as { worktrees: HomeWorktreeSummary[] }
const worktrees = result.worktrees ?? []
setCachedWorktrees(hostId, worktrees)
const activeStatuses = new Set(['working', 'active', 'permission'])
const active = worktrees.filter((w) => w.status && activeStatuses.has(w.status))
// Mirror the desktop's focused workspace (see pickResumeWorktree).
const lastActive = pickResumeWorktree(worktrees)
setInfo((prev) => ({
...prev,
[hostId]: {
hostId,
totalWorktrees: worktrees.length,
activeCount: active.length,
lastActiveWorktree: lastActive
}
}))
} else {
markUnavailable()
}
})
.catch(() => {
if (!disposed()) {
markUnavailable()
}
})
}
function fetchAccountsSnapshot(
client: RpcClient,
hostId: string,
@@ -262,6 +206,7 @@ function repoColor(name: string): string {
export default function HomeScreen() {
const router = useRouter()
const openMobileHostEdit = useOpenMobileHostEdit()
const openMobileTasks = useOpenMobileTasks()
const insets = useSafeAreaInsets()
// Why: cap/center content on wide/tablet canvases so cards don't stretch edge-to-edge on iPad.
@@ -272,7 +217,7 @@ export default function HomeScreen() {
const [hostStates, setHostStates] = useState<Record<string, ConnectionState>>({})
const [hostAttempts, setHostAttempts] = useState<Record<string, number>>({})
const [hostLastConnected, setHostLastConnected] = useState<Record<string, number | null>>({})
const [stats, setStats] = useState<StatsSummary | null>(null)
const [statsByHost, setStatsByHost] = useState<Record<string, HomeStatsSummary>>({})
const [worktreeInfo, setWorktreeInfo] = useState<Record<string, HostWorktreeInfo>>({})
const [accountsByHost, setAccountsByHost] = useState<Record<string, AccountsSnapshot>>({})
const [taskProvidersByHost, setTaskProvidersByHost] = useState<Record<string, TaskProvider[]>>({})
@@ -285,6 +230,8 @@ export default function HomeScreen() {
// Why: shared clients from the per-host store, not N independent WebSockets. See docs/mobile-shared-client-per-host.md.
const hostIds = useMemo(() => hosts.map((h) => h.id), [hosts])
// Why: scoped to the paired hosts so an unpaired desktop's cached reply leaves the header total.
const stats = useMemo(() => totalHomeStats(statsByHost, hostIds), [statsByHost, hostIds])
const allClients = useAllHostClients(hostIds)
const hostPaths = useMemo(
() => Object.fromEntries(allClients.map(({ hostId, path }) => [hostId, path])),
@@ -375,8 +322,8 @@ export default function HomeScreen() {
})
for (const entry of allClientsRef.current) {
if (entry.client.getState() === 'connected') {
fetchStats(entry.client, entry.hostId, setStats, () => stale)
fetchWorktreeInfo(entry.client, entry.hostId, setWorktreeInfo, () => stale)
fetchStats(entry.client, entry.hostId, setStatsByHost, () => stale)
void fetchHomeHostWorktreeInfo(entry.client, entry.hostId, setWorktreeInfo, () => stale)
fetchAccountsSnapshot(entry.client, entry.hostId, setAccountsByHost, () => stale)
fetchTaskProviders(entry.client, entry.hostId, setTaskProvidersByHost, () => stale)
}
@@ -457,60 +404,70 @@ export default function HomeScreen() {
})
}, [allClients, hosts])
// Per-host notif/accounts subs + one-shot stats on 'connected'; re-runs per (hostId, client) pair, socket stays open so it's cheap.
useEffect(() => {
const cleanups: Array<() => void> = []
for (const entry of allClients) {
let unsubNotif: (() => void) | null = null
let unsubAccounts: (() => void) | null = null
let statsFetched = false
const wireUp = (state: ConnectionState) => {
if (state === 'connected') {
if (!unsubNotif) {
unsubNotif = subscribeToDesktopNotifications(entry.client, entry.hostId)
}
if (!unsubAccounts) {
unsubAccounts = entry.client.subscribe('accounts.subscribe', null, (payload) => {
if (!payload || typeof payload !== 'object') {
return
// Notif/accounts subs + a snapshot read per connect for one host. Lives outside the effect body
// because react-doctor's effect-needs-cleanup false-positives on `subscribe` inside one; the
// returned disposer owns every handle allocated here.
const wireHostSubscriptions = (entry: {
hostId: string
client: RpcClient
state: ConnectionState
}) => {
let unsubNotif: (() => void) | null = null
let unsubAccounts: (() => void) | null = null
const refetchGate = createHostConnectRefetchGate()
const wireUp = (state: ConnectionState) => {
const reconnected = refetchGate.observe(state)
if (state === 'connected') {
if (!unsubNotif) {
unsubNotif = subscribeToDesktopNotifications(entry.client, entry.hostId)
}
if (!unsubAccounts) {
unsubAccounts = entry.client.subscribe('accounts.subscribe', null, (payload) => {
if (!payload || typeof payload !== 'object') {
return
}
const evt = payload as { type?: string; snapshot?: unknown }
if (evt.type === 'ready' || evt.type === 'snapshot') {
try {
const snapshot = decodeAccountsSnapshot(evt.snapshot)
setAccountsByHost((prev) => ({ ...prev, [entry.hostId]: snapshot }))
} catch {
// Keep the last proven snapshot; malformed remote data must
// not enter render state or crash the home host cards.
}
const evt = payload as { type?: string; snapshot?: unknown }
if (evt.type === 'ready' || evt.type === 'snapshot') {
try {
const snapshot = decodeAccountsSnapshot(evt.snapshot)
setAccountsByHost((prev) => ({ ...prev, [entry.hostId]: snapshot }))
} catch {
// Keep the last proven snapshot; malformed remote data must
// not enter render state or crash the home host cards.
}
}
})
}
if (!statsFetched) {
statsFetched = true
fetchStats(entry.client, entry.hostId, setStats, () => false)
fetchWorktreeInfo(entry.client, entry.hostId, setWorktreeInfo, () => false)
fetchTaskProviders(entry.client, entry.hostId, setTaskProvidersByHost, () => false)
}
} else {
if (unsubNotif) {
unsubNotif()
unsubNotif = null
}
if (unsubAccounts) {
unsubAccounts()
unsubAccounts = null
}
}
})
}
// Why: the socket survives backgrounding/handoffs by reconnecting, so re-read the host
// snapshot on every reconnect — a one-shot latch left the card on stale data forever.
if (reconnected) {
fetchStats(entry.client, entry.hostId, setStatsByHost, () => false)
void fetchHomeHostWorktreeInfo(entry.client, entry.hostId, setWorktreeInfo, () => false)
fetchTaskProviders(entry.client, entry.hostId, setTaskProvidersByHost, () => false)
}
} else {
if (unsubNotif) {
unsubNotif()
unsubNotif = null
}
if (unsubAccounts) {
unsubAccounts()
unsubAccounts = null
}
}
wireUp(entry.state)
const unsubState = entry.client.onStateChange(wireUp)
cleanups.push(() => {
unsubState()
unsubNotif?.()
unsubAccounts?.()
})
}
wireUp(entry.state)
const unsubState = entry.client.onStateChange(wireUp)
return () => {
unsubState()
unsubNotif?.()
unsubAccounts?.()
}
}
// Re-runs per (hostId, client) pair; the socket stays open so it's cheap.
useEffect(() => {
const cleanups = allClients.map((entry) => wireHostSubscriptions(entry))
return () => {
for (const c of cleanups) {
c()
@@ -750,7 +707,6 @@ export default function HomeScreen() {
const state = hostStates[item.id] ?? 'connecting'
const attempts = hostAttempts[item.id] ?? 0
const lastConnectedAt = hostLastConnected[item.id] ?? null
const info = worktreeInfo[item.id]
const verdict = classifyConnection({
state,
reconnectAttempts: attempts,
@@ -763,12 +719,7 @@ export default function HomeScreen() {
state={state}
verdict={verdict}
path={hostPaths[item.id] ?? 'lan'}
worktreeCounts={
info && !info.catalogUnavailable
? { total: info.totalWorktrees, active: info.activeCount }
: undefined
}
worktreeCountsUnavailable={info?.catalogUnavailable === true}
worktreeInfo={worktreeInfo[item.id]}
onPress={() => router.push(`/h/${item.id}`)}
onLongPress={() => {
triggerMediumImpact()
@@ -956,7 +907,7 @@ export default function HomeScreen() {
onDismiss: () => setActionTarget(null),
onReconnect: (hostId) => void forceReconnectHost(hostId),
onDisconnect: closeHostClient,
onEdit: (hostId) => navigateToMobileHostEdit(router, hostId),
onEdit: openMobileHostEdit,
onRemove: setConfirmRemove
})}
onClose={() => setActionTarget(null)}
+2 -1
View File
@@ -7,7 +7,7 @@
"start": "node scripts/start-expo.mjs",
"android": "expo run:android",
"ios": "expo run:ios",
"postinstall": "node scripts/build-terminal-webview-engine.mjs",
"postinstall": "node scripts/build-terminal-webview-engine.mjs && node scripts/build-mermaid-webview-engine.mjs",
"test": "vitest run",
"typecheck": "tsc --noEmit",
"lint": "oxlint",
@@ -48,6 +48,7 @@
"expo-status-bar": "^55.0.6",
"lowlight": "^3.3.0",
"lucide-react-native": "^1.14.0",
"mermaid": "11.16.0",
"react": "^19.2.6",
"react-dom": "19.2.6",
"react-native": "^0.83.9",
+792
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,42 @@
import { readFile, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { createRequire } from 'node:module'
const require = createRequire(import.meta.url)
const scriptDir = import.meta.dirname
const mobileRoot = path.resolve(scriptDir, '..')
const outputPath = path.join(
mobileRoot,
'src',
'components',
'pr-sidebar',
'mermaid-webview-engine.generated.ts'
)
// Why: unlike the terminal engine (esbuilt from source for old-WebView shims),
// mermaid's prebuilt UMD bundle is embedded verbatim — it is the exact artifact
// the diagram WebView previously fetched from a CDN, now pinned and
// integrity-checked through the lockfile and available offline.
async function main() {
const packageJsonPath = require.resolve('mermaid/package.json')
const [packageJson, bundleJs] = await Promise.all([
readFile(packageJsonPath, 'utf8').then(JSON.parse),
readFile(path.join(path.dirname(packageJsonPath), 'dist', 'mermaid.min.js'), 'utf8')
])
// A literal </script> inside the bundle would close the inline script tag it
// is embedded in; \/ is identical to / in JS strings, regexes, and comments.
const inlineSafeJs = bundleJs.replace(/<\/script/gi, '<\\/script')
const source = [
'// Generated by scripts/build-mermaid-webview-engine.mjs.',
`// Package: mermaid@${packageJson.version} (dist/mermaid.min.js, embedded verbatim).`,
'// Do not edit by hand; regenerate via pnpm postinstall.',
`export const MERMAID_ENGINE_JS = ${JSON.stringify(inlineSafeJs)}`,
''
].join('\n')
await writeFile(outputPath, source)
}
await main()
+3 -16
View File
@@ -5,25 +5,12 @@
// WebSocket reconnects and the first responses come back.
import AsyncStorage from '@react-native-async-storage/async-storage'
import type { AccountsSnapshot } from '../components/AccountUsage'
// Why: the canonical shape, so persisted counts keep carrying countsProvenAt — the home card
// needs it to know whether a rehydrated count is minutes or days old.
import type { HostWorktreeInfo } from '../worktree/home-worktree-info'
const STORAGE_KEY = 'orca:home-snapshot:v1'
type WorktreeSummary = {
worktreeId: string
repo: string
branch: string
displayName: string
liveTerminalCount: number
status?: 'working' | 'active' | 'permission' | 'done' | 'inactive'
}
type HostWorktreeInfo = {
hostId: string
totalWorktrees: number
activeCount: number
lastActiveWorktree: WorktreeSummary | null
}
export type HomeSnapshot = {
worktreeInfo: Record<string, HostWorktreeInfo>
accountsByHost: Record<string, AccountsSnapshot>
@@ -0,0 +1,93 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ConnectionVerdict } from '../transport/connection-health'
import type { HostProfile } from '../transport/types'
import {
markHomeWorktreeCatalogUnavailable,
type HostWorktreeInfo
} from '../worktree/home-worktree-info'
import { MobileHostCard } from './MobileHostCard'
vi.mock('react-native', () => ({
Pressable: 'Pressable',
StyleSheet: { create: <T,>(styles: T) => styles },
Text: 'Text',
View: 'View'
}))
vi.mock('lucide-react-native', () => ({ ChevronRight: 'ChevronRight', Monitor: 'Monitor' }))
vi.mock('./StatusDot', () => ({ StatusDot: 'StatusDot' }))
const host: HostProfile = {
id: 'host-1',
name: 'Studio',
endpoint: 'ws://studio.local:8765',
deviceToken: 'token',
publicKeyB64: 'key',
lastConnected: 0
}
const verdict: ConnectionVerdict = { kind: 'normal', label: 'Connected' }
const loaded: HostWorktreeInfo = {
hostId: 'host-1',
totalWorktrees: 12,
activeCount: 2,
lastActiveWorktree: null,
countsProvenAt: Date.now()
}
describe('MobileHostCard', () => {
let renderer: ReactTestRenderer | null = null
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
})
async function renderCard(worktreeInfo: HostWorktreeInfo | undefined): Promise<string[]> {
await act(async () => {
renderer = create(
createElement(MobileHostCard, {
host,
state: 'connected',
verdict,
path: 'lan',
worktreeInfo,
onPress: () => {},
onLongPress: () => {}
})
)
})
return renderer!.root
.findAllByType('Text')
.flatMap((node) => node.children.filter((child) => typeof child === 'string'))
}
it('renders the counts the host proved', async () => {
expect(await renderCard(loaded)).toContain('12 worktrees · 2 active')
})
it('keeps rendering the last proven counts after a failed refresh', async () => {
// The regression this card shipped once: the caller dropped the counts the
// failure path deliberately preserved.
expect(await renderCard(markHomeWorktreeCatalogUnavailable(loaded, 'host-1'))).toContain(
'Last known: 12 worktrees · 2 active'
)
})
it('never asserts a count for a catalog that failed with nothing proven', async () => {
expect(await renderCard(markHomeWorktreeCatalogUnavailable(undefined, 'host-1'))).toContain(
'Worktree list unavailable'
)
})
it('shows no worktree line before the first read lands', async () => {
const lines = await renderCard(undefined)
expect(lines).not.toContain('0 worktrees')
expect(lines).not.toContain('Worktree list unavailable')
})
})
+5 -9
View File
@@ -6,6 +6,7 @@ import { mobileConnectionPathLabel } from '../transport/mobile-connection-path-l
import type { MobileConnectionPath } from '../transport/stable-logical-rpc-client'
import type { ConnectionState, HostProfile } from '../transport/types'
import { colors, radii, spacing } from '../theme/mobile-theme'
import { homeHostWorktreeSummary, type HostWorktreeInfo } from '../worktree/home-worktree-info'
import { StatusDot } from './StatusDot'
export function MobileHostCard(props: {
@@ -13,20 +14,15 @@ export function MobileHostCard(props: {
state: ConnectionState
verdict: ConnectionVerdict
path: MobileConnectionPath
worktreeCounts?: { total: number; active: number }
// Why (STA-3123): the host is connected but its worktree catalog request failed,
// so the card must say "unavailable" instead of asserting a count of zero.
worktreeCountsUnavailable?: boolean
// Why: the card owns the fresh/stale/unavailable wording so no caller can re-gate the counts
// away (STA-3123 shipped that bug once already).
worktreeInfo?: HostWorktreeInfo
onPress: () => void
onLongPress: () => void
}) {
const connected = props.state === 'connected'
const isError = ['warning', 'unreachable', 'auth-failed'].includes(props.verdict.kind)
const worktreeSummary = props.worktreeCounts
? `${props.worktreeCounts.total} worktree${props.worktreeCounts.total === 1 ? '' : 's'}${props.worktreeCounts.active > 0 ? ` · ${props.worktreeCounts.active} active` : ''}`
: props.worktreeCountsUnavailable
? 'Worktree list unavailable'
: null
const worktreeSummary = homeHostWorktreeSummary(props.worktreeInfo)
return (
<Pressable
style={({ pressed }) => [styles.card, pressed && styles.cardPressed]}
@@ -0,0 +1,133 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer, type ReactTestInstance } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { MobileMarkdown } from './MobileMarkdown'
const openURL = vi.fn(() => Promise.resolve())
vi.mock('react-native', () => ({
Linking: { openURL: (url: string) => openURL(url) },
Pressable: 'Pressable',
ScrollView: 'ScrollView',
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 },
Text: 'Text',
View: 'View'
}))
vi.mock('./pr-sidebar/MermaidDiagram', () => ({ MermaidDiagram: 'MermaidDiagram' }))
function flattenText(node: ReactTestInstance): string {
return node.children
.map((child) => (typeof child === 'string' ? child : flattenText(child)))
.join('')
}
function pressables(renderer: ReactTestRenderer): ReactTestInstance[] {
return renderer.root.findAll(
(node) => node.type === ('Text' as never) && typeof node.props.onPress === 'function'
)
}
function pressByText(renderer: ReactTestRenderer, text: string): void {
const target = pressables(renderer).find((node) => flattenText(node) === text)
expect(target, `no pressable text ${JSON.stringify(text)}`).toBeDefined()
target!.props.onPress()
}
describe('MobileMarkdown file links', () => {
let renderer: ReactTestRenderer | null = null
const onOpenFile = vi.fn()
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
onOpenFile.mockClear()
openURL.mockClear()
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
})
function render(content: string): ReactTestRenderer {
act(() => {
renderer = create(createElement(MobileMarkdown, { content, onOpenFile }))
})
return renderer!
}
it('opens a tapped POSIX absolute path in prose', () => {
pressByText(render('Edit /Users/me/wt/src/app.tsx now'), '/Users/me/wt/src/app.tsx')
expect(onOpenFile).toHaveBeenCalledWith('/Users/me/wt/src/app.tsx')
})
it('opens a tapped path:line citation in prose', () => {
pressByText(render('see src/foo.ts:42 for the fix'), 'src/foo.ts:42')
expect(onOpenFile).toHaveBeenCalledWith('src/foo.ts:42')
})
it('routes a relative markdown href to the file opener with its #L line', () => {
pressByText(render('read [the plan](docs/plan.md#L7) first'), 'the plan')
expect(onOpenFile).toHaveBeenCalledWith('docs/plan.md:7')
expect(openURL).not.toHaveBeenCalled()
})
it('routes a file: href to the file opener', () => {
pressByText(render('[artifact](file:///tmp/out/result.json)'), 'artifact')
expect(onOpenFile).toHaveBeenCalledWith('/tmp/out/result.json')
})
it('keeps web links on the system browser', () => {
pressByText(render('go to [site](https://example.com/docs)'), 'site')
expect(openURL).toHaveBeenCalledWith('https://example.com/docs')
expect(onOpenFile).not.toHaveBeenCalled()
})
it('drops unknown-scheme hrefs without opening anything', () => {
pressByText(render('[ide](editor://file/x.ts)'), 'ide')
expect(openURL).not.toHaveBeenCalled()
expect(onOpenFile).not.toHaveBeenCalled()
})
it('keeps snake_case paths whole instead of shredding them as emphasis', () => {
const rendered = render('compare src/foo_bar.ts and src/baz_qux.ts now')
pressByText(rendered, 'src/foo_bar.ts')
pressByText(rendered, 'src/baz_qux.ts')
expect(onOpenFile).toHaveBeenNthCalledWith(1, 'src/foo_bar.ts')
expect(onOpenFile).toHaveBeenNthCalledWith(2, 'src/baz_qux.ts')
})
it('keeps markdown links between snake_case paths tappable', () => {
pressByText(
render('Updated src/foo_bar.py; see [the PR](https://example.com/x) before src/baz_qux.py'),
'the PR'
)
expect(openURL).toHaveBeenCalledWith('https://example.com/x')
})
it('opens a dunder path as one link', () => {
pressByText(render('see a/__tests__/x.ts now'), 'a/__tests__/x.ts')
expect(onOpenFile).toHaveBeenCalledExactlyOnceWith('a/__tests__/x.ts')
})
it('detects paths inside bold spans', () => {
pressByText(render('changed **src/foo.ts** heavily'), 'src/foo.ts')
expect(onOpenFile).toHaveBeenCalledWith('src/foo.ts')
})
it('excludes trailing sentence punctuation from autolinks', () => {
pressByText(render('see https://example.com/a.'), 'https://example.com/a')
expect(openURL).toHaveBeenCalledWith('https://example.com/a')
})
it('opens inline-code path:line citations', () => {
pressByText(render('fix `src/foo.ts:42` now'), 'src/foo.ts:42')
expect(onOpenFile).toHaveBeenCalledWith('src/foo.ts:42')
})
it('renders paths as plain text without onOpenFile', () => {
act(() => {
renderer = create(createElement(MobileMarkdown, { content: 'Edit src/app/Main.tsx now' }))
})
expect(pressables(renderer!)).toHaveLength(0)
})
})
@@ -1,8 +1,40 @@
import { describe, expect, it } from 'vitest'
import { isMobileMermaidLanguage } from './mobile-mermaid-language'
import { normalizeMobileMarkdownPreviewHtml } from './mobile-markdown-preview-html'
import { parseMobileMarkdown } from './mobile-markdown-parser'
describe('isMobileMermaidLanguage', () => {
it('matches mermaid case-insensitively after trim', () => {
expect(isMobileMermaidLanguage('mermaid')).toBe(true)
expect(isMobileMermaidLanguage('Mermaid')).toBe(true)
expect(isMobileMermaidLanguage(' MERMAID ')).toBe(true)
})
it('rejects non-mermaid languages and missing language', () => {
expect(isMobileMermaidLanguage(undefined)).toBe(false)
expect(isMobileMermaidLanguage('')).toBe(false)
expect(isMobileMermaidLanguage('ts')).toBe(false)
expect(isMobileMermaidLanguage('mermaidx')).toBe(false)
})
})
describe('parseMobileMarkdown', () => {
it('parses mermaid fences as code blocks with language mermaid', () => {
expect(parseMobileMarkdown('```mermaid\ngraph TD; A-->B\n```')).toEqual([
{ type: 'code', text: 'graph TD; A-->B', language: 'mermaid', closed: true }
])
expect(isMobileMermaidLanguage('mermaid')).toBe(true)
})
it('marks an unterminated fence as not closed while it streams', () => {
expect(parseMobileMarkdown('```mermaid\ngraph TD; A-->B')).toEqual([
{ type: 'code', text: 'graph TD; A-->B', language: 'mermaid', closed: false }
])
expect(parseMobileMarkdown('```mermaid\ngraph TD; A-->B\n```')[0]).toMatchObject({
closed: true
})
})
it('parses GFM tables into table blocks', () => {
expect(parseMobileMarkdown('| Name | State |\n| --- | --- |\n| Orca | Open |')).toEqual([
{
+69 -24
View File
@@ -7,7 +7,14 @@ import {
isFilePathCodeSpan,
normalizeFilePath
} from './markdown-file-path-detection'
import { routeMarkdownHref } from './markdown-href-routing'
import {
isIntrawordUnderscoreToken,
trimAutolinkTrailingPunctuation
} from './markdown-inline-token-rules'
import { isMobileMermaidLanguage } from './mobile-mermaid-language'
import { parseMobileMarkdown } from './mobile-markdown-parser'
import { MermaidDiagram } from './pr-sidebar/MermaidDiagram'
type Props = {
content?: string
@@ -15,19 +22,28 @@ type Props = {
/** Multiplier for prose font size (paragraphs, lists, quotes). Defaults to 1;
* the chat view passes >1 so agent prose reads larger than the compact base. */
textScale?: number
/** When provided, detected file-path tokens render as tappable and invoke this
* with the worktree-relative path. Omitted on screens with no file viewer, where
/** When provided, detected file paths and file-target hrefs render as tappable
* and invoke this with the path text (worktree-relative or absolute, with an
* optional :line(:col) suffix). Omitted on screens with no file viewer, where
* paths render as plain text (no behavior change). */
onOpenFile?: (relativePath: string) => void
onOpenFile?: (pathText: string) => void
}
const MAX_TABLE_ROWS = 40
const MAX_TABLE_COLUMNS = 8
/** Prose base size — passed to MermaidDiagram fallback mono text. */
const MERMAID_BASE = 13
function openMarkdownUrl(url: string): void {
const trimmed = url.trim()
if (/^(https?:|mailto:)/i.test(trimmed)) {
void Linking.openURL(trimmed).catch(() => {})
// Web/mail hrefs open the system handler; file-target hrefs (file: URIs and
// scheme-less paths — the entire desktop file-link contract) go to onOpenFile.
function openMarkdownHref(href: string, onOpenFile?: (pathText: string) => void): void {
const route = routeMarkdownHref(href)
if (route.kind === 'web') {
void Linking.openURL(route.url).catch(() => {})
return
}
if (route.kind === 'file' && onOpenFile) {
onOpenFile(route.pathText)
}
}
@@ -36,7 +52,7 @@ function openMarkdownUrl(url: string): void {
function renderTextRun(
text: string,
keyPrefix: string,
onOpenFile?: (relativePath: string) => void
onOpenFile?: (pathText: string) => void
): ReactNode {
if (!onOpenFile) {
return text
@@ -61,39 +77,54 @@ function renderTextRun(
})
}
function renderInline(text: string, onOpenFile?: (relativePath: string) => void): ReactNode[] {
function renderInline(text: string, onOpenFile?: (pathText: string) => void): ReactNode[] {
const parts: ReactNode[] = []
const pattern =
/(!\[[^\]]*\]\([^)]+\)|`[^`]+`|~~[^~]+~~|\*\*[^*]+\*\*|__[^_]+__|\*[^*\n]+\*|_[^_\n]+_|\[[^\]]+\]\([^)]+\)|https?:\/\/[^\s<]+)/g
let lastIndex = 0
let pendingStart = 0
let match: RegExpExecArray | null
while ((match = pattern.exec(text))) {
if (match.index > lastIndex) {
parts.push(renderTextRun(text.slice(lastIndex, match.index), `t${lastIndex}`, onOpenFile))
}
const token = match[0]
// Intraword `_` runs (snake_case, dunder tails) are literal text per
// CommonMark; leaving them unflushed keeps surrounding file paths whole
// for detection in the eventual text run.
if (token.startsWith('_') && isIntrawordUnderscoreToken(text, match.index, token)) {
// Resume after the opener so real tokens inside the rejected span are still scanned.
pattern.lastIndex = match.index + 1
continue
}
if (match.index > pendingStart) {
parts.push(
renderTextRun(text.slice(pendingStart, match.index), `t${pendingStart}`, onOpenFile)
)
}
pendingStart = pattern.lastIndex
const key = `${match.index}:${token}`
const image = token.match(/^!\[([^\]]*)\]\(([^)]+)\)$/)
const link = token.match(/^\[([^\]]+)\]\(([^)]+)\)$/)
if (image) {
parts.push(
<Text key={key} style={styles.link} onPress={() => openMarkdownUrl(image[2]!)}>
<Text key={key} style={styles.link} onPress={() => openMarkdownHref(image[2]!, onOpenFile)}>
{image[1] || 'image'}
</Text>
)
} else if (link) {
parts.push(
<Text key={key} style={styles.link} onPress={() => openMarkdownUrl(link[2]!)}>
<Text key={key} style={styles.link} onPress={() => openMarkdownHref(link[2]!, onOpenFile)}>
{link[1]}
</Text>
)
} else if (/^https?:\/\//i.test(token)) {
const { url, trailing } = trimAutolinkTrailingPunctuation(token)
parts.push(
<Text key={key} style={styles.link} onPress={() => openMarkdownUrl(token)}>
{token}
<Text key={key} style={styles.link} onPress={() => openMarkdownHref(url, onOpenFile)}>
{url}
</Text>
)
if (trailing) {
parts.push(<Fragment key={`${key}p`}>{trailing}</Fragment>)
}
} else if (token.startsWith('`')) {
const code = token.slice(1, -1)
if (onOpenFile && isFilePathCodeSpan(code)) {
@@ -116,27 +147,26 @@ function renderInline(text: string, onOpenFile?: (relativePath: string) => void)
} else if (token.startsWith('~~')) {
parts.push(
<Text key={key} style={styles.strike}>
{token.slice(2, -2)}
{renderTextRun(token.slice(2, -2), `${key}i`, onOpenFile)}
</Text>
)
} else if (token.startsWith('**') || token.startsWith('__')) {
parts.push(
<Text key={key} style={styles.bold}>
{token.slice(2, -2)}
{renderTextRun(token.slice(2, -2), `${key}i`, onOpenFile)}
</Text>
)
} else {
parts.push(
<Text key={key} style={styles.italic}>
{token.slice(1, -1)}
{renderTextRun(token.slice(1, -1), `${key}i`, onOpenFile)}
</Text>
)
}
lastIndex = pattern.lastIndex
}
if (lastIndex < text.length) {
parts.push(renderTextRun(text.slice(lastIndex), `t${lastIndex}`, onOpenFile))
if (pendingStart < text.length) {
parts.push(renderTextRun(text.slice(pendingStart), `t${pendingStart}`, onOpenFile))
}
return parts
}
@@ -153,6 +183,7 @@ function MobileMarkdownInner({ content, fallback = '', textScale = 1, onOpenFile
if (!text) {
return fallback ? <Text style={styles.paragraph}>{fallback}</Text> : null
}
const mermaidSourceOccurrences = new Map<string, number>()
return (
<View style={styles.root}>
@@ -175,6 +206,20 @@ function MobileMarkdownInner({ content, fallback = '', textScale = 1, onOpenFile
)
}
if (block.type === 'code') {
// Mermaid fences render as diagrams (WebView), not as raw code — same as PR sidebar.
// Unclosed fences are still streaming: mounting the WebView per tick would
// reload its document up to 20x/sec, so they stay raw code until terminated.
if (isMobileMermaidLanguage(block.language) && block.closed) {
const occurrence = mermaidSourceOccurrences.get(block.text) ?? 0
mermaidSourceOccurrences.set(block.text, occurrence + 1)
return (
<MermaidDiagram
key={`${block.text}:${occurrence}`}
source={block.text}
base={MERMAID_BASE}
/>
)
}
return (
<View key={index} style={styles.codeBlock}>
{block.language ? <Text style={styles.codeLanguage}>{block.language}</Text> : null}
@@ -187,7 +232,7 @@ function MobileMarkdownInner({ content, fallback = '', textScale = 1, onOpenFile
<Pressable
key={index}
style={styles.imageFrame}
onPress={() => openMarkdownUrl(block.url)}
onPress={() => openMarkdownHref(block.url, onOpenFile)}
>
<Text style={styles.link}>{block.alt || 'Open image'}</Text>
<Text style={styles.imageCaption} numberOfLines={1}>
@@ -2,7 +2,8 @@ import { describe, expect, it } from 'vitest'
import {
detectFilePathSegments,
isFilePathCodeSpan,
normalizeFilePath
normalizeFilePath,
splitFilePathLineSuffix
} from './markdown-file-path-detection'
describe('detectFilePathSegments', () => {
@@ -64,6 +65,69 @@ describe('detectFilePathSegments', () => {
])
})
it('detects POSIX absolute paths', () => {
expect(detectFilePathSegments('Wrote /Users/me/wt/src/app.tsx today')).toEqual([
{ type: 'text', value: 'Wrote ' },
{ type: 'file', value: '/Users/me/wt/src/app.tsx', path: '/Users/me/wt/src/app.tsx' },
{ type: 'text', value: ' today' }
])
expect(detectFilePathSegments('/repo/src/index.ts')).toEqual([
{ type: 'file', value: '/repo/src/index.ts', path: '/repo/src/index.ts' }
])
expect(detectFilePathSegments('/root.ts')).toEqual([
{ type: 'file', value: '/root.ts', path: '/root.ts' }
])
})
it('detects files directly under explicit Windows and relative roots', () => {
expect(detectFilePathSegments(String.raw`C:\root.ts`)).toEqual([
{ type: 'file', value: String.raw`C:\root.ts`, path: String.raw`C:\root.ts` }
])
expect(detectFilePathSegments('./root.ts')).toEqual([
{ type: 'file', value: './root.ts', path: 'root.ts' }
])
expect(detectFilePathSegments('../root.ts')).toEqual([
{ type: 'file', value: '../root.ts', path: '../root.ts' }
])
})
it('detects paths with :line and :line:col suffixes', () => {
expect(detectFilePathSegments('see src/foo.ts:42 here')).toEqual([
{ type: 'text', value: 'see ' },
{ type: 'file', value: 'src/foo.ts:42', path: 'src/foo.ts:42' },
{ type: 'text', value: ' here' }
])
expect(
detectFilePathSegments('/wt/src/app.tsx:120:7 and C:\\repo\\a.ts:3').filter(
(s) => s.type === 'file'
)
).toEqual([
{ type: 'file', value: '/wt/src/app.tsx:120:7', path: '/wt/src/app.tsx:120:7' },
{ type: 'file', value: 'C:\\repo\\a.ts:3', path: 'C:\\repo\\a.ts:3' }
])
})
it('keeps a non-line colon tail out of the match', () => {
expect(detectFilePathSegments('edit src/foo.ts: then run')).toEqual([
{ type: 'text', value: 'edit ' },
{ type: 'file', value: 'src/foo.ts', path: 'src/foo.ts' },
{ type: 'text', value: ': then run' }
])
})
it('does not partially parse numeric-looking non-line tails', () => {
expect(detectFilePathSegments('log src/app.ts:1e3 oops')).toEqual([
{ type: 'text', value: 'log ' },
{ type: 'file', value: 'src/app.ts', path: 'src/app.ts' },
{ type: 'text', value: ':1e3 oops' }
])
expect(detectFilePathSegments('coverage src/app.ts:80% of lines')).toEqual([
{ type: 'text', value: 'coverage ' },
{ type: 'file', value: 'src/app.ts', path: 'src/app.ts' },
{ type: 'text', value: ':80% of lines' }
])
})
it('does not match bare filenames without a slash', () => {
expect(detectFilePathSegments('open Main.tsx please')).toEqual([
{ type: 'text', value: 'open Main.tsx please' }
@@ -74,6 +138,15 @@ describe('detectFilePathSegments', () => {
expect(detectFilePathSegments('https://example.com/path/file.ts')).toEqual([
{ type: 'text', value: 'https://example.com/path/file.ts' }
])
expect(detectFilePathSegments('see https://example.com/path/file.ts:42 now')).toEqual([
{ type: 'text', value: 'see https://example.com/path/file.ts:42 now' }
])
})
it('does not match protocol-relative URLs', () => {
expect(detectFilePathSegments('load //cdn.example.com/lib/app.js')).toEqual([
{ type: 'text', value: 'load //cdn.example.com/lib/app.js' }
])
})
it('does not match version numbers', () => {
@@ -171,12 +244,61 @@ describe('isFilePathCodeSpan', () => {
expect(isFilePathCodeSpan('node_modules/@scope/pkg/file.ts')).toBe(true)
})
it('accepts POSIX absolute paths and :line citations', () => {
expect(isFilePathCodeSpan('/Users/me/wt/src/app.tsx')).toBe(true)
expect(isFilePathCodeSpan('src/foo.ts:42')).toBe(true)
expect(isFilePathCodeSpan('src/foo.ts:42:7')).toBe(true)
expect(isFilePathCodeSpan('MobileNativeChatComposer.tsx:23')).toBe(true)
expect(isFilePathCodeSpan(String.raw`C:\repo\Main.tsx:12`)).toBe(true)
})
it('rejects emails and git URLs with a mid-token @', () => {
expect(isFilePathCodeSpan('git@github.com:user/repo.git')).toBe(false)
expect(isFilePathCodeSpan('user@host.com/path/file.txt')).toBe(false)
})
})
describe('splitFilePathLineSuffix', () => {
it('splits :line and :line:col suffixes', () => {
expect(splitFilePathLineSuffix('src/foo.ts:42')).toEqual({
path: 'src/foo.ts',
line: 42,
column: null
})
expect(splitFilePathLineSuffix('src/foo.ts:42:7')).toEqual({
path: 'src/foo.ts',
line: 42,
column: 7
})
})
it('keeps Windows drive colons intact', () => {
expect(splitFilePathLineSuffix(String.raw`C:\repo\a.ts`)).toEqual({
path: String.raw`C:\repo\a.ts`,
line: null,
column: null
})
expect(splitFilePathLineSuffix(String.raw`C:\repo\a.ts:12`)).toEqual({
path: String.raw`C:\repo\a.ts`,
line: 12,
column: null
})
})
it('ignores non-numeric and zero suffixes', () => {
expect(splitFilePathLineSuffix('src/foo.ts')).toEqual({
path: 'src/foo.ts',
line: null,
column: null
})
expect(splitFilePathLineSuffix('src/foo.ts:0')).toEqual({
path: 'src/foo.ts:0',
line: null,
column: null
})
})
})
describe('normalizeFilePath', () => {
it('strips a leading ./', () => {
expect(normalizeFilePath('./a/b.ts')).toBe('a/b.ts')
@@ -83,9 +83,11 @@ const FILE_EXTENSIONS = [
const EXTENSION_SET = new Set<string>(FILE_EXTENSIONS)
// Accept the host's native separator because transcript paths originate on the
// connected runtime, which may be Windows even when the phone is not.
// connected runtime, which may be Windows even when the phone is not. Leading
// alternatives cover Windows drives, UNC, and POSIX absolute roots; the optional
// tail captures only bounded agent-style :line(:col) citations.
const CANDIDATE_PATTERN =
/(?:[A-Za-z]:[\\/]|\\\\)?(?:\.{1,2}[\\/])?(?:[\w.@~+-]+[\\/])+[\w.@+-]+\.[A-Za-z0-9]+/g
/(?:(?:[A-Za-z]:[\\/]|\\\\|[\\/]|\.{1,2}[\\/])(?:[\w.@~+-]+[\\/])*|(?:[\w.@~+-]+[\\/])+)[\w.@+-]+\.[A-Za-z0-9]+(?::[1-9]\d*(?::[1-9]\d*)?(?![\w@%]))?/g
// A path candidate in chat prose is short; a much longer run can't hold one worth
// linkifying but can push CANDIDATE_PATTERN into super-linear backtracking, so we
@@ -99,7 +101,31 @@ function hasMidTokenAt(candidate: string): boolean {
return /[^\\/]@/.test(candidate)
}
function isOpenablePath(candidate: string): boolean {
const LINE_SUFFIX_PATTERN = /^(.+?):([1-9]\d*)(?::([1-9]\d*))?$/
/**
* Split an agent-style `path:line(:col)` citation into its parts. Windows drive
* colons are safe: only a trailing all-digit suffix is treated as a line ref.
*/
export function splitFilePathLineSuffix(pathText: string): {
path: string
line: number | null
column: number | null
} {
const match = LINE_SUFFIX_PATTERN.exec(pathText)
if (!match) {
return { path: pathText, line: null, column: null }
}
return {
path: match[1]!,
line: Number.parseInt(match[2]!, 10),
column: match[3] ? Number.parseInt(match[3], 10) : null
}
}
function isOpenablePath(pathText: string): boolean {
// A :line(:col) tail is part of the citation, not the file name.
const { path: candidate } = splitFilePathLineSuffix(pathText)
// Reject anything URL-ish or scheme-bearing — those are handled as web links.
if (candidate.includes('://') || hasMidTokenAt(candidate)) {
return false
@@ -153,10 +179,11 @@ export function detectFilePathSegments(text: string): FilePathSegment[] {
while ((match = CANDIDATE_PATTERN.exec(text))) {
const candidate = match[0]
// Skip candidates that are part of a URL (preceded by a scheme colon or an
// alphanumeric/host char that would make this a domain tail, not a path).
// Skip candidates that are part of a URL: a scheme colon, a domain tail, or
// a preceding slash (the leading slash of an absolute path is part of the
// match itself, so prev '/' means a '://' or '//' remainder, not a path).
const prev = match.index > 0 ? text[match.index - 1]! : ''
if (prev === ':' || prev === '/' || /[\w.@]/.test(prev)) {
if (prev === ':' || prev === '/' || prev === '\\' || /[\w.@]/.test(prev)) {
continue
}
if (!isOpenablePath(candidate)) {
@@ -195,16 +222,18 @@ export function isFilePathCodeSpan(code: string): boolean {
if (isOpenablePath(trimmed)) {
return true
}
// Separator-less code span: accept a clean name.ext with a known extension.
if (/[\\/]/.test(trimmed)) {
// Separator-less code span: accept a clean name.ext (with an optional
// :line(:col) citation tail) and a known extension.
const { path } = splitFilePathLineSuffix(trimmed)
if (/[\\/]/.test(path)) {
return false
}
const dot = trimmed.lastIndexOf('.')
const dot = path.lastIndexOf('.')
if (dot <= 0) {
return false
}
const name = trimmed.slice(0, dot)
const ext = trimmed.slice(dot + 1).toLowerCase()
const name = path.slice(0, dot)
const ext = path.slice(dot + 1).toLowerCase()
if (/[^\w.@+-]/.test(name)) {
return false
}
@@ -0,0 +1,79 @@
import { describe, expect, it } from 'vitest'
import { routeMarkdownHref } from './markdown-href-routing'
describe('routeMarkdownHref', () => {
it('routes web and mail links to the system handler', () => {
expect(routeMarkdownHref('https://example.com/docs')).toEqual({
kind: 'web',
url: 'https://example.com/docs'
})
expect(routeMarkdownHref('http://localhost:3000/')).toEqual({
kind: 'web',
url: 'http://localhost:3000/'
})
expect(routeMarkdownHref(' mailto:dev@example.com ')).toEqual({
kind: 'web',
url: 'mailto:dev@example.com'
})
})
it('routes relative hrefs to the file opener', () => {
expect(routeMarkdownHref('src/foo.ts')).toEqual({ kind: 'file', pathText: 'src/foo.ts' })
expect(routeMarkdownHref('./docs/plan.md')).toEqual({
kind: 'file',
pathText: './docs/plan.md'
})
})
it('carries a #L fragment as a :line suffix', () => {
expect(routeMarkdownHref('docs/plan.md#L42')).toEqual({
kind: 'file',
pathText: 'docs/plan.md:42'
})
expect(routeMarkdownHref('docs/plan.md?plain=1#line-7')).toEqual({
kind: 'file',
pathText: 'docs/plan.md:7'
})
expect(routeMarkdownHref('docs/plan.md#usage')).toEqual({
kind: 'file',
pathText: 'docs/plan.md'
})
})
it('decodes percent-encoded href paths', () => {
expect(routeMarkdownHref('docs/release%20notes.md')).toEqual({
kind: 'file',
pathText: 'docs/release notes.md'
})
})
it('routes file: URIs to the file opener', () => {
expect(routeMarkdownHref('file:///Users/me/wt/src/app.tsx')).toEqual({
kind: 'file',
pathText: '/Users/me/wt/src/app.tsx'
})
expect(routeMarkdownHref('file:///Users/me/wt/src/app.tsx#L12')).toEqual({
kind: 'file',
pathText: '/Users/me/wt/src/app.tsx:12'
})
expect(routeMarkdownHref('file:///C:/repo/src/index.ts')).toEqual({
kind: 'file',
pathText: 'C:/repo/src/index.ts'
})
})
it('keeps Windows drive paths out of the scheme filter', () => {
expect(routeMarkdownHref(String.raw`C:\repo\src\index.ts`)).toEqual({
kind: 'file',
pathText: String.raw`C:\repo\src\index.ts`
})
})
it('drops anchors, unknown schemes, and empty hrefs', () => {
expect(routeMarkdownHref('#section')).toEqual({ kind: 'none' })
expect(routeMarkdownHref('')).toEqual({ kind: 'none' })
expect(routeMarkdownHref('editor://file/x.ts')).toEqual({ kind: 'none' })
expect(routeMarkdownHref('javascript:alert(1)')).toEqual({ kind: 'none' })
expect(routeMarkdownHref('data:text/plain,hi')).toEqual({ kind: 'none' })
})
})
@@ -0,0 +1,18 @@
import { routeNativeChatHref } from '../../../src/shared/native-chat-href-routing'
export type MarkdownHrefRoute =
| { kind: 'web'; url: string }
| { kind: 'file'; pathText: string }
| { kind: 'none' }
function withLineSuffix(pathText: string, line: number | null): string {
return line === null ? pathText : `${pathText}:${line}`
}
export function routeMarkdownHref(href: string): MarkdownHrefRoute {
const route = routeNativeChatHref(href)
if (route.kind !== 'file') {
return route
}
return { kind: 'file', pathText: withLineSuffix(route.pathText, route.line) }
}
@@ -0,0 +1,73 @@
import { describe, expect, it } from 'vitest'
import {
isIntrawordUnderscoreToken,
trimAutolinkTrailingPunctuation
} from './markdown-inline-token-rules'
describe('isIntrawordUnderscoreToken', () => {
it('rejects snake_case emphasis spans', () => {
const text = 'src/foo_bar.ts and src/baz_qux.ts'
const index = text.indexOf('_')
const token = text.slice(index, text.lastIndexOf('_') + 1)
expect(isIntrawordUnderscoreToken(text, index, token)).toBe(true)
})
it('keeps standalone emphasis', () => {
expect(isIntrawordUnderscoreToken('say _hello_ now', 4, '_hello_')).toBe(false)
expect(isIntrawordUnderscoreToken('_hello_.', 0, '_hello_')).toBe(false)
})
it('rejects emphasis closed against a following word', () => {
expect(isIntrawordUnderscoreToken('_foo_s bar', 0, '_foo_')).toBe(true)
})
it('rejects dunder emphasis inside a path', () => {
expect(isIntrawordUnderscoreToken('src/__init__.py', 4, '__init__')).toBe(true)
expect(isIntrawordUnderscoreToken(String.raw`src\__init__.py`, 4, '__init__')).toBe(true)
})
it('ignores non-underscore tokens', () => {
expect(isIntrawordUnderscoreToken('a*b*c', 1, '*b*')).toBe(false)
})
})
describe('trimAutolinkTrailingPunctuation', () => {
it('splits sentence punctuation off the URL', () => {
expect(trimAutolinkTrailingPunctuation('https://x.com/a.')).toEqual({
url: 'https://x.com/a',
trailing: '.'
})
expect(trimAutolinkTrailingPunctuation('https://x.com/a,')).toEqual({
url: 'https://x.com/a',
trailing: ','
})
expect(trimAutolinkTrailingPunctuation('https://x.com/a?!')).toEqual({
url: 'https://x.com/a',
trailing: '?!'
})
})
it('keeps balanced parens and strips unbalanced ones', () => {
expect(trimAutolinkTrailingPunctuation('https://x.com/a_(b)')).toEqual({
url: 'https://x.com/a_(b)',
trailing: ''
})
expect(trimAutolinkTrailingPunctuation('https://x.com/a).')).toEqual({
url: 'https://x.com/a',
trailing: ').'
})
})
it('handles long unmatched closing-parenthesis tails', () => {
const url = 'https://x.com/a_(b)'
const trailing = ')'.repeat(4096)
expect(trimAutolinkTrailingPunctuation(`${url}${trailing}`)).toEqual({ url, trailing })
})
it('leaves clean URLs untouched', () => {
expect(trimAutolinkTrailingPunctuation('https://x.com/a')).toEqual({
url: 'https://x.com/a',
trailing: ''
})
})
})
@@ -0,0 +1,55 @@
// Post-checks for inline markdown tokens that a single-pass tokenizer regex
// cannot express on its own.
const INTRAWORD_FLANK_PATTERN = /[\w\\/]/
/**
* True when a `_…_` / `__…__` token sits inside a word (snake_case, dunder
* tails). CommonMark treats intraword underscores as literal text; path
* separators count as flanks so dunder path segments also stay whole.
*/
export function isIntrawordUnderscoreToken(text: string, index: number, token: string): boolean {
if (!token.startsWith('_')) {
return false
}
const prev = index > 0 ? text[index - 1]! : ''
const next = text[index + token.length] ?? ''
return INTRAWORD_FLANK_PATTERN.test(prev) || INTRAWORD_FLANK_PATTERN.test(next)
}
/**
* Split sentence punctuation off an autolinked URL tail ("see https://x.com/a."),
* keeping a trailing ')' only when the URL itself opened a paren.
*/
export function trimAutolinkTrailingPunctuation(url: string): { url: string; trailing: string } {
let end = url.length
let parenthesisCountsReady = false
let openParentheses = 0
let closeParentheses = 0
while (end > 0) {
const char = url[end - 1]!
if ('.,;:!?'.includes(char)) {
end--
continue
}
if (char === ')') {
if (!parenthesisCountsReady) {
for (let index = 0; index < end; index++) {
if (url[index] === '(') {
openParentheses++
} else if (url[index] === ')') {
closeParentheses++
}
}
parenthesisCountsReady = true
}
if (closeParentheses > openParentheses) {
end--
closeParentheses--
continue
}
}
break
}
return { url: url.slice(0, end), trailing: url.slice(end) }
}
@@ -0,0 +1,80 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { MobileMarkdown } from './MobileMarkdown'
vi.mock('react-native', () => ({
Linking: { openURL: vi.fn() },
Pressable: 'Pressable',
ScrollView: 'ScrollView',
StyleSheet: { create: <T>(styles: T) => styles, hairlineWidth: 1 },
Text: 'Text',
View: 'View'
}))
vi.mock('./pr-sidebar/MermaidDiagram', () => ({ MermaidDiagram: 'MermaidDiagram' }))
let renderer: ReactTestRenderer | undefined
afterEach(() => {
renderer?.unmount()
renderer = undefined
vi.restoreAllMocks()
})
function render(content: string): ReactTestRenderer {
act(() => {
renderer = create(createElement(MobileMarkdown, { content }))
})
return renderer!
}
function mermaidCount(tree: ReactTestRenderer): number {
return tree.root.findAllByType('MermaidDiagram' as never).length
}
function firstMermaid(tree: ReactTestRenderer) {
return tree.root.findByType('MermaidDiagram' as never)
}
describe('MobileMarkdown mermaid routing', () => {
it('routes a closed mermaid fence to MermaidDiagram', () => {
const tree = render('```mermaid\ngraph TD; A-->B\n```')
expect(mermaidCount(tree)).toBe(1)
})
it('keeps a streaming (unterminated) mermaid fence as raw code', () => {
const tree = render('```mermaid\ngraph TD; A-->B')
expect(mermaidCount(tree)).toBe(0)
})
it('keeps identical sibling diagrams uniquely keyed', () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const diagram = '```mermaid\ngraph TD; A-->B\n```'
const tree = render(`${diagram}\n\n${diagram}`)
const warnings = consoleError.mock.calls.flat().join(' ')
expect(mermaidCount(tree)).toBe(2)
expect(warnings).not.toContain('same key')
})
it('preserves a completed diagram while later prose streams', () => {
const diagram = '```mermaid\ngraph TD; A-->B\n```'
const tree = render(diagram)
const initial = firstMermaid(tree)
act(() => tree.update(createElement(MobileMarkdown, { content: `${diagram}\n\nNext` })))
expect(firstMermaid(tree)).toBe(initial)
})
it('remounts a diagram when its source changes', () => {
const tree = render('```mermaid\ngraph TD; A-->B\n```')
const initial = firstMermaid(tree)
act(() =>
tree.update(createElement(MobileMarkdown, { content: '```mermaid\ngraph TD; A-->C\n```' }))
)
expect(firstMermaid(tree)).not.toBe(initial)
})
it('does not route other code fences to MermaidDiagram', () => {
const tree = render('```ts\nconst a = 1\n```')
expect(mermaidCount(tree)).toBe(0)
})
})
@@ -2,7 +2,7 @@ export type MobileMarkdownBlock =
| { type: 'paragraph'; text: string }
| { type: 'heading'; level: number; text: string }
| { type: 'quote'; text: string }
| { type: 'code'; text: string; language?: string }
| { type: 'code'; text: string; language?: string; closed: boolean }
| { type: 'list'; ordered: boolean; items: Array<{ text: string; checked?: boolean }> }
| { type: 'image'; alt: string; url: string }
| { type: 'table'; headers: string[]; rows: string[][] }
@@ -42,10 +42,12 @@ export function parseMobileMarkdown(content: string): MobileMarkdownBlock[] {
code.push(lines[index] ?? '')
index += 1
}
if (index < lines.length) {
// closed=false means the fence is still streaming in (no terminator yet).
const closed = index < lines.length
if (closed) {
index += 1
}
blocks.push({ type: 'code', text: code.join('\n'), language: fence[1] })
blocks.push({ type: 'code', text: code.join('\n'), language: fence[1], closed })
continue
}
@@ -0,0 +1,4 @@
/** True when a fenced code block language should render as a Mermaid diagram. */
export function isMobileMermaidLanguage(language?: string): boolean {
return language?.trim().toLowerCase() === 'mermaid'
}
@@ -1,7 +1,8 @@
import { useMemo, useState } from 'react'
import { memo, useMemo, useState } from 'react'
import { ScrollView, StyleSheet, Text, View } from 'react-native'
import { WebView } from 'react-native-webview'
import { colors, radii, spacing, typography } from '../../theme/mobile-theme'
import { MERMAID_ENGINE_JS } from './mermaid-webview-engine.generated'
type Props = {
source: string
@@ -9,11 +10,15 @@ type Props = {
}
// Renders a ```mermaid fence as a diagram via a sandboxed WebView (mermaid has no
// native RN renderer). Mermaid is loaded from a CDN inside the WebView HTML, the
// SVG is themed dark to match the sidebar, and the WebView posts back its rendered
// height so we can size to content. On any failure (no network, parse error,
// render error) we fall back to the raw source in a labeled mono code box.
export function MermaidDiagram({ source, base }: Props) {
// native RN renderer). Mermaid ships inside the app as a generated bundle embedded
// in the WebView HTML — no network — the SVG is themed dark to match the sidebar,
// and the WebView posts back its rendered height so we can size to content. On any
// failure (parse error, render error) we fall back to the raw source in a labeled
// mono code box.
// memo: both props are primitives; without it every mounted diagram re-renders
// per frame during pinch-to-zoom (textScale updates), marshalling the full HTML
// string across the Fabric boundary each time.
export const MermaidDiagram = memo(function MermaidDiagram({ source, base }: Props) {
const [height, setHeight] = useState(0)
const [failed, setFailed] = useState(false)
const html = useMemo(() => buildHtml(source), [source])
@@ -58,7 +63,7 @@ export function MermaidDiagram({ source, base }: Props) {
/>
</View>
)
}
})
function MermaidFallback({ source, base }: Props) {
return (
@@ -73,21 +78,33 @@ function MermaidFallback({ source, base }: Props) {
)
}
// Self-contained HTML: load mermaid from CDN, render the graph, post the body
// JSON.stringify escapes quotes and control chars but leaves `<`, `>`, `&`, and
// the U+2028/U+2029 line separators raw — so a source containing `</script>`
// would close this inline <script> and let the rest execute as markup. Diagram
// source is untrusted (agent output, PR/chat content), so escape those to \uXXXX;
// the literal still parses back to the exact original string inside the WebView.
function encodeSourceForScript(source: string): string {
return JSON.stringify(source).replace(
/[<>&\u2028\u2029]/g,
(char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`
)
}
// Self-contained HTML: embedded mermaid bundle, render the graph, post the body
// height (or "error") back to RN. Theme variables match the dark sidebar palette.
function buildHtml(source: string): string {
// JSON.stringify safely escapes the user's diagram source for embedding.
const encoded = JSON.stringify(source)
export function buildHtml(source: string): string {
const encoded = encodeSourceForScript(source)
return `<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob:; font-src data:; base-uri 'none'; form-action 'none'" />
<style>
html, body { margin: 0; padding: 0; background: ${colors.bgRaised}; }
#c { padding: 8px; }
#c svg { max-width: 100%; height: auto; }
</style>
<script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script>
<script>${MERMAID_ENGINE_JS}</script>
</head>
<body>
<div id="c"><pre class="mermaid"></pre></div>
@@ -114,7 +131,7 @@ function buildHtml(source: string): string {
}
});
mermaid.run({ querySelector: '.mermaid' })
.then(reportHeight)
.then(function () { reportHeight(); })
.catch(function () { post('error'); });
} catch (e) {
post('error');
@@ -0,0 +1,60 @@
import { describe, expect, it, vi } from 'vitest'
import { buildHtml } from './MermaidDiagram'
import { MERMAID_ENGINE_JS } from './mermaid-webview-engine.generated'
vi.mock('react-native', () => ({
ScrollView: 'ScrollView',
StyleSheet: { create: <T>(styles: T) => styles, hairlineWidth: 1 },
Text: 'Text',
View: 'View'
}))
vi.mock('react-native-webview', () => ({ WebView: 'WebView' }))
// The diagram source is untrusted (agent output, PR/chat content). It is embedded
// inside an inline <script>, so it must not be able to close that script element.
describe('buildHtml source escaping', () => {
it('does not let a </script> payload break out of the inline script', () => {
const payload = 'graph TD; A-->B</script><script>window.evil=1</script>'
const countClosers = (html: string) => (html.match(/<\/script>/gi) ?? []).length
// The payload's two </script> must add zero raw closers over a benign render —
// they were neutralized to \u003c instead of closing our inline script.
const benign = countClosers(buildHtml('graph TD; A-->B'))
expect(countClosers(buildHtml(payload))).toBe(benign)
expect(buildHtml(payload)).toContain('\\u003c/script')
})
it('escapes the U+2028/U+2029 line separators that would break the JS literal', () => {
const payload = `a${String.fromCharCode(0x2028)}b${String.fromCharCode(0x2029)}c`
const html = buildHtml(payload)
expect(html).toContain('\\u2028')
expect(html).toContain('\\u2029')
expect(html.includes(String.fromCharCode(0x2028))).toBe(false)
expect(html.includes(String.fromCharCode(0x2029))).toBe(false)
})
it('still round-trips ordinary source to the exact original string', () => {
const payload = 'graph LR\n A["node & <tag>"] --> B'
const html = buildHtml(payload)
const match = html.match(/\.textContent = (".*?");\n {4}mermaid\.initialize/s)
expect(match).not.toBeNull()
expect(JSON.parse(match![1]!)).toBe(payload)
})
// Same gate as the terminal document: mermaid is embedded from the lockfile-pinned
// package, so the document itself must load nothing external (offline-safe, no CDN
// supply-chain exposure). The engine's internal URL literals (xmlns, docs links)
// are inert data, so the gate checks the document with the engine stripped out.
it('embeds the mermaid engine and loads no external resource', () => {
const html = buildHtml('graph TD; A-->B')
expect(html).toContain(MERMAID_ENGINE_JS)
expect(html.replace(MERMAID_ENGINE_JS, '')).not.toMatch(/\bhttps?:\/\//)
})
it('blocks external resources requested by diagram syntax', () => {
const html = buildHtml('flowchart LR\nA@{ img: "https://example.com/pixel.png" }')
const policy = html.match(/Content-Security-Policy" content="([^"]+)"/)?.[1]
expect(policy).toContain("default-src 'none'")
expect(policy).toContain('img-src data: blob:')
expect(policy).not.toMatch(/https?:/)
})
})
@@ -5,8 +5,7 @@ const homeSource = readFileSync(new URL('../app/index.tsx', import.meta.url), 'u
describe('Home host edit navigation wiring', () => {
it('uses the cold-navigator-safe edit transition', () => {
expect(homeSource).toMatch(
/onEdit:\s*\(hostId\)\s*=>\s*navigateToMobileHostEdit\(router,\s*hostId\)/
)
expect(homeSource).toMatch(/const openMobileHostEdit = useOpenMobileHostEdit\(\)/)
expect(homeSource).toMatch(/onEdit:\s*openMobileHostEdit/)
})
})
+2 -2
View File
@@ -282,6 +282,7 @@ const styles = StyleSheet.create({
},
option: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
padding: spacing.sm,
borderRadius: radii.card,
@@ -299,8 +300,7 @@ const styles = StyleSheet.create({
borderWidth: 1.5,
borderColor: colors.textMuted,
alignItems: 'center',
justifyContent: 'center',
marginTop: 1
justifyContent: 'center'
},
checkCircle: {
borderRadius: 9
@@ -0,0 +1,193 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import { MobileNativeChatOverlay } from './MobileNativeChatOverlay'
import type { MobileNativeChatController } from './use-mobile-native-chat-controller'
vi.mock('react-native', () => ({
StyleSheet: { create: (styles: unknown) => styles, absoluteFillObject: {} },
View: 'View'
}))
vi.mock('./MobileNativeChatView', () => ({ MobileNativeChatView: 'ChatView' }))
function assistantTurn(id: string, text: string): NativeChatMessage {
return { id, role: 'assistant', blocks: [{ type: 'text', text }], timestamp: 0, source: 'hook' }
}
function suppressRendererWarning(): () => void {
const original = console.error
const spy = vi.spyOn(console, 'error').mockImplementation((...args) => {
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
return
}
original(...args)
})
return () => spy.mockRestore()
}
/** One render of the route: chat visible or not, the transcript it currently
* holds, and the agent-status stream behind it. */
type Tick = {
show?: boolean
messages?: NativeChatMessage[]
streamingText?: string
streamLive?: boolean
identity?: string
}
function overlayElement(tick: Tick): ReturnType<typeof createElement> {
const controller = {
showNativeChat: tick.show ?? true,
nativeChatSession: { messages: tick.messages ?? [], status: 'ready' },
nativeChatAgent: 'claude',
nativeChatAgentWorking: tick.streamLive ?? false,
nativeChatStreamingText: tick.streamingText,
nativeChatStreamLive: tick.streamLive ?? false,
nativeChatStreamScopeKey: tick.identity ?? 'tab-a',
chatPending: [],
chatComposerText: '',
setChatComposerText: vi.fn()
} as unknown as MobileNativeChatController
return createElement(MobileNativeChatOverlay, {
controller,
images: {} as never,
onMicPress: vi.fn(),
micActive: false,
dictationMode: 'toggle',
onMicPressIn: vi.fn(),
onMicPressOut: vi.fn(),
inputLockReason: null,
sendErrorMessage: null,
onClearSendError: vi.fn(),
keyboardInset: 0
})
}
describe('MobileNativeChatOverlay streaming gate', () => {
let renderer: ReactTestRenderer | null = null
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
})
async function render(tick: Tick): Promise<void> {
const restore = suppressRendererWarning()
try {
await act(async () => {
renderer = create(overlayElement(tick))
})
} finally {
restore()
}
}
async function update(tick: Tick): Promise<void> {
await act(async () => {
renderer?.update(overlayElement(tick))
})
}
/** The bubble text handed to the chat list, or `'hidden'` when chat is off. */
function streaming(): string | null | 'hidden' {
const views = renderer!.root.findAll((node) => node.type === 'ChatView')
return views.length === 0 ? 'hidden' : (views[0].props.streaming as string | null)
}
it('keeps streaming a reply that repeats the previous turn as a prefix', async () => {
const prior = [assistantTurn('a1', 'The tests pass.')]
await render({ messages: prior })
expect(streaming()).toBeNull()
await update({ messages: prior, streamingText: 'The tests', streamLive: true })
expect(streaming()).toBe('The tests')
})
it('drops the streaming bubble once the reply lands as its own turn', async () => {
const prior = [assistantTurn('a1', 'Done.')]
await render({ messages: prior })
await update({ messages: prior, streamingText: 'Done.', streamLive: true })
expect(streaming()).toBe('Done.')
await update({
messages: [...prior, assistantTurn('a2', 'Done.')],
streamingText: 'Done.',
streamLive: true
})
expect(streaming()).toBeNull()
})
it('keeps the bubble across a peek at the terminal view', async () => {
// Toggling to the terminal unmounts the chat list and unsubscribes its
// transcript. The gate lives above that boundary, so the baseline survives
// and the repeated-prefix reply keeps streaming on the way back.
const prior = [assistantTurn('a1', 'Done.')]
await render({ messages: prior })
await update({ messages: prior, streamingText: 'Done.', streamLive: true })
expect(streaming()).toBe('Done.')
await update({ show: false, messages: [], streamLive: true })
expect(streaming()).toBe('hidden')
// Back on chat the session withholds its transcript until a fresh read
// settles, so the throttled stream text returns a round trip ahead of it.
await update({ messages: [], streamLive: true })
await update({ messages: [], streamingText: 'Done.', streamLive: true })
await update({ messages: prior, streamingText: 'Done.', streamLive: true })
expect(streaming()).toBe('Done.')
})
it('keeps the bubble across a peek at the terminal taken between turns', async () => {
// Same toggle, but taken while idle: the transcript empties before the next
// turn starts, so the gate has to reject that empty tail as a baseline.
const prior = [assistantTurn('a1', 'Done.')]
await render({ messages: prior })
await update({ show: false, messages: [] })
await update({ show: false, messages: [], streamLive: true })
await update({ messages: [], streamLive: true })
await update({ messages: [], streamingText: 'Done.', streamLive: true })
await update({ messages: prior, streamingText: 'Done.', streamLive: true })
expect(streaming()).toBe('Done.')
})
it('hides a repeated part whose own turn landed during a mid-turn gap', async () => {
// Between parts the status frame carries no assistant text (a tool call), so
// the stream goes textless while the turn is still live and the part that
// just finished lands in the transcript. Re-anchoring on that tick would
// adopt it as history and render it a second time.
const prior = [assistantTurn('a1', 'Done.')]
await render({ messages: prior })
await update({ messages: prior, streamingText: 'Done.', streamLive: true })
expect(streaming()).toBe('Done.')
const landed = [...prior, assistantTurn('a2', 'Done.')]
await update({ messages: landed, streamLive: true })
await update({ messages: landed, streamingText: 'Done.', streamLive: true })
expect(streaming()).toBeNull()
})
it("does not carry one chat's baseline into another stream identity", async () => {
const prior = [assistantTurn('a1', 'Shared answer text')]
await render({ messages: prior, identity: 'tab-a' })
await update({
messages: prior,
streamingText: 'Shared answer',
streamLive: true,
identity: 'tab-b'
})
expect(streaming()).toBeNull()
})
})
+21 -4
View File
@@ -1,10 +1,16 @@
import { useMemo } from 'react'
import { StyleSheet, View } from 'react-native'
import { MobileNativeChatView, type MobileNativeChatInputLockReason } from './MobileNativeChatView'
import { foldMobileNativeChatMessages } from './mobile-native-chat-render-data'
import type { MobileNativeChatImageAttachments } from './use-mobile-native-chat-image-attachments'
import type { MobileNativeChatController } from './use-mobile-native-chat-controller'
import { useMobileNativeChatStreamingBubble } from './use-mobile-native-chat-streaming-bubble'
type Props = {
controller: MobileNativeChatController
/** Opens a tapped file reference (worktree-relative or absolute, optional
* :line(:col) suffix) through the shared tap-to-open flow. */
onOpenFile: (pathText: string) => void
/** Native-chat image attachments: picking adds a composer chip, and sending
* rides the pending images along with the message text (desktop parity). */
images: MobileNativeChatImageAttachments
@@ -22,9 +28,12 @@ type Props = {
}
/** Keeps the terminal mounted underneath chat so its PTY subscription survives
* view toggles while the native surface owns the visible composer. */
* view toggles while the native surface owns the visible composer. Also owns
* the streaming gate: this component stays mounted across those toggles, while
* the chat list below it does not. */
export function MobileNativeChatOverlay({
controller,
onOpenFile,
images,
onMicPress,
micActive,
@@ -36,19 +45,27 @@ export function MobileNativeChatOverlay({
onClearSendError,
keyboardInset
}: Props): React.JSX.Element | null {
const session = controller.nativeChatSession
const folded = useMemo(() => foldMobileNativeChatMessages(session.messages), [session.messages])
const streaming = useMobileNativeChatStreamingBubble(
folded,
controller.nativeChatStreamingText,
controller.nativeChatStreamScopeKey,
controller.nativeChatStreamLive
)
if (!controller.showNativeChat) {
return null
}
const session = controller.nativeChatSession
return (
<View style={styles.overlay}>
<MobileNativeChatView
messages={session.messages}
folded={folded}
status={session.status}
error={session.error}
agent={controller.nativeChatAgent}
agentWorking={controller.nativeChatAgentWorking}
streamingText={controller.nativeChatStreamingText}
streaming={streaming}
onStop={controller.handleNativeChatStop}
ask={controller.nativeChatAsk}
onAnswerAsk={controller.handleNativeChatAnswerAsk}
@@ -57,7 +74,7 @@ export function MobileNativeChatOverlay({
onAnswerQuestion={controller.handleNativeChatQuestionAnswer}
permission={controller.nativeChatPermission}
onRespondPermission={controller.handleNativeChatRespondPermission}
onOpenFile={controller.handleNativeChatOpenFile}
onOpenFile={onOpenFile}
hasMore={session.hasMore}
loadingEarlier={session.loadingEarlier}
onLoadEarlier={session.loadEarlier}
+48 -12
View File
@@ -1,6 +1,7 @@
import { createElement } from 'react'
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import { MobileNativeChatView } from './MobileNativeChatView'
vi.mock('react-native', () => ({
@@ -58,6 +59,9 @@ vi.mock('./MobileNativeChatComposer', async () => {
})
type Overrides = {
messages?: Parameters<typeof MobileNativeChatView>[0]['messages']
folded?: Parameters<typeof MobileNativeChatView>[0]['folded']
streaming?: string | null
sendErrorMessage?: string | null
onClearSendError?: () => void
inputLockReason?: 'disconnected' | 'waiting' | null
@@ -75,7 +79,25 @@ function suppressRendererWarning(): () => void {
return () => spy.mockRestore()
}
describe('MobileNativeChatView send-error banner', () => {
function assistantTurn(id: string, text: string): NativeChatMessage {
return { id, role: 'assistant', blocks: [{ type: 'text', text }], timestamp: 0, source: 'hook' }
}
function chatViewElement(overrides: Overrides): ReturnType<typeof createElement> {
return createElement(MobileNativeChatView, {
messages: [],
folded: [],
status: 'ready',
streaming: null,
onSend: vi.fn().mockResolvedValue(true),
pending: [],
composerText: '',
onComposerTextChange: vi.fn(),
...overrides
})
}
describe('MobileNativeChatView', () => {
let renderer: ReactTestRenderer | null = null
beforeEach(() => {
@@ -91,23 +113,25 @@ describe('MobileNativeChatView send-error banner', () => {
const restore = suppressRendererWarning()
try {
await act(async () => {
renderer = create(
createElement(MobileNativeChatView, {
messages: [],
status: 'ready',
onSend: overrides.onSend ?? vi.fn().mockResolvedValue(true),
pending: [],
composerText: '',
onComposerTextChange: vi.fn(),
...overrides
})
)
renderer = create(chatViewElement(overrides))
})
} finally {
restore()
}
}
async function update(overrides: Overrides = {}): Promise<void> {
await act(async () => {
renderer?.update(chatViewElement(overrides))
})
}
/** Ids of the rows the list is currently rendering. */
function listIds(): string[] {
const list = renderer!.root.find((node) => node.type === 'FlatList')
return (list.props.data as { id: string }[]).map((row) => row.id)
}
function banners(): ReactTestInstance[] {
return renderer!.root.findAll((node) => node.props.accessibilityRole === 'alert')
}
@@ -161,4 +185,16 @@ describe('MobileNativeChatView send-error banner', () => {
expect(onClearSendError).toHaveBeenCalledOnce()
})
// The gate that decides `streaming` lives in MobileNativeChatOverlay, which
// outlives this view; see MobileNativeChatOverlay.test.ts.
it('appends the gated streaming bubble after the folded transcript', async () => {
const folded = [assistantTurn('a1', 'The tests pass.')]
await render({ folded })
expect(listIds()).toEqual(['a1'])
await update({ folded, streaming: 'The tests' })
expect(listIds()).toEqual(['a1', 'streaming'])
})
})
+13 -8
View File
@@ -16,7 +16,6 @@ import { colors } from '../theme/mobile-theme'
import { styles } from './mobile-native-chat-view-styles'
import {
buildMobileNativeChatTransientData,
foldMobileNativeChatMessages,
mobileNativeChatEmptyState,
type MobileNativeChatPendingItem
} from './mobile-native-chat-render-data'
@@ -40,7 +39,10 @@ import type { MobileNativeChatStatus } from './use-mobile-native-chat-session'
export type MobileNativeChatInputLockReason = 'disconnected' | 'waiting'
type Props = {
/** Raw transcript, only for telling "still loading" from "loaded and empty". */
messages: NativeChatMessage[]
/** `messages` with noise stripped and tool turns folded in, from the overlay. */
folded: NativeChatMessage[]
status: MobileNativeChatStatus
error?: string
/** Resolved agent for this chat; names the empty-state copy (desktop parity). */
@@ -48,9 +50,9 @@ type Props = {
agentWorking?: boolean
/** Interrupt the agent mid-turn (shown as a Stop button on the working bar). */
onStop?: () => void
/** Live partial assistant text while a turn is still streaming (from the agent
* status hook). Shown as an in-progress bubble until the transcript catches up. */
streamingText?: string
/** Live partial assistant text to show as an in-progress bubble, already gated
* by the overlay against the transcript catching up. */
streaming: string | null
hasMore?: boolean
loadingEarlier?: boolean
onLoadEarlier?: () => void
@@ -105,12 +107,13 @@ type Props = {
export function MobileNativeChatView({
messages,
folded,
status,
error,
agent,
agentWorking,
onStop,
streamingText,
streaming,
hasMore,
loadingEarlier,
onLoadEarlier,
@@ -169,10 +172,9 @@ export function MobileNativeChatView({
// `data` is the list source: folded transcript + synthetic streaming bubble +
// route-owned optimistic queued messages. Memoize on the same deps so the
// downstream autoscroll effects/`renderItem` keep referential stability.
const foldedMessages = useMemo(() => foldMobileNativeChatMessages(messages), [messages])
const { data } = useMemo(
() => buildMobileNativeChatTransientData({ folded: foldedMessages, streamingText, pending }),
[foldedMessages, streamingText, pending]
() => buildMobileNativeChatTransientData({ folded, streaming, pending }),
[folded, streaming, pending]
)
// Follow the tail as the conversation grows and keep the newest message above
@@ -276,6 +278,9 @@ export function MobileNativeChatView({
keyExtractor={(item) => item.id}
renderItem={renderItem}
contentContainerStyle={styles.listContent}
// Let link/file taps land while the composer keyboard is up
// instead of being swallowed by the dismiss gesture.
keyboardShouldPersistTaps="handled"
onScroll={onScroll}
scrollEventThrottle={32}
onContentSizeChange={() => {
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { openMobileTerminalFileTap } from './mobile-terminal-file-tap-open'
import { openMobileFileTap } from './mobile-file-tap-open'
function ok(result: unknown) {
return { ok: true, result, _meta: { runtimeId: 'runtime-1' } }
@@ -22,7 +22,7 @@ function activeTerminalState(activated: boolean) {
}
}
describe('openMobileTerminalFileTap', () => {
describe('openMobileFileTap', () => {
it('opens absolute terminal artifacts through the grant-backed preview route', async () => {
const client = createClient([
ok({
@@ -42,7 +42,7 @@ describe('openMobileTerminalFileTap', () => {
const pushPreviewRoute = vi.fn()
const triggerOpenFeedback = vi.fn()
openMobileTerminalFileTap({
openMobileFileTap({
client,
hostId: 'host-1',
worktreeId: 'wt-1',
@@ -106,7 +106,7 @@ describe('openMobileTerminalFileTap', () => {
const openedTab = { id: 'tab-2', relativePath: 'src/index.ts' }
const switchSessionTab = vi.fn()
openMobileTerminalFileTap({
openMobileFileTap({
client,
hostId: 'host-1',
worktreeId: 'wt-1',
@@ -135,6 +135,56 @@ describe('openMobileTerminalFileTap', () => {
expect(switchSessionTab).toHaveBeenCalledWith(openedTab)
})
it('opens a sibling terminal path through the resolved owning worktree', async () => {
const client = createClient([
ok({
worktree: 'wt-2',
relativePath: 'docs/readme.md',
absolutePath: '/repo-b/docs/readme.md',
exists: true,
isDirectory: false,
openTarget: {
kind: 'worktree-file',
provider: 'local',
relativePath: 'docs/readme.md',
absolutePath: '/repo-b/docs/readme.md'
}
})
])
const pushPreviewRoute = vi.fn()
openMobileFileTap({
client,
hostId: 'host-1',
worktreeId: 'wt-1',
pathText: '/repo-b/docs/readme.md',
line: null,
column: null,
pushPreviewRoute,
openBrowser: vi.fn(),
triggerOpenFeedback: vi.fn(),
fetchSessionTabs: vi.fn(),
getSessionTabs: () => [],
getActiveSessionTabId: () => null,
getActivationState: activeTerminalState,
switchSessionTab: vi.fn(),
scheduleDelayedAction: vi.fn()
})
await Promise.resolve()
await Promise.resolve()
expect(pushPreviewRoute).toHaveBeenCalledWith({
pathname: '/h/[hostId]/files/preview/[worktreeId]',
params: expect.objectContaining({
hostId: 'host-1',
worktreeId: 'wt-2',
source: 'worktree',
relativePath: 'docs/readme.md'
})
})
expect(client.sendRequest).not.toHaveBeenCalledWith('files.open', expect.anything())
})
it('opens worktree-contained line references through the preview route', async () => {
const client = createClient([
ok({
@@ -154,7 +204,7 @@ describe('openMobileTerminalFileTap', () => {
const pushPreviewRoute = vi.fn()
const triggerOpenFeedback = vi.fn()
openMobileTerminalFileTap({
openMobileFileTap({
client,
hostId: 'host-1',
worktreeId: 'wt-1',
@@ -208,7 +258,7 @@ describe('openMobileTerminalFileTap', () => {
])
const openBrowser = vi.fn()
openMobileTerminalFileTap({
openMobileFileTap({
client,
hostId: 'host-1',
worktreeId: 'wt-1',
@@ -249,7 +299,7 @@ describe('openMobileTerminalFileTap', () => {
ok({ opened: true })
])
openMobileTerminalFileTap({
openMobileFileTap({
client,
hostId: 'host-1',
worktreeId: 'wt-1',
@@ -296,7 +346,7 @@ describe('openMobileTerminalFileTap', () => {
])
const openBrowser = vi.fn()
openMobileTerminalFileTap({
openMobileFileTap({
client,
hostId: 'host-1',
worktreeId: 'wt-1',
@@ -337,7 +387,7 @@ describe('openMobileTerminalFileTap', () => {
let activeTerminalHandle: string | null = 'terminal-1'
const pushPreviewRoute = vi.fn()
openMobileTerminalFileTap({
openMobileFileTap({
client,
hostId: 'host-1',
worktreeId: 'wt-1',
@@ -381,6 +431,180 @@ describe('openMobileTerminalFileTap', () => {
expect(pushPreviewRoute).not.toHaveBeenCalled()
})
it('reports a failed files.open through onOpenFailed', async () => {
const client = createClient([
ok({
worktree: 'wt-1',
relativePath: 'src/index.ts',
absolutePath: '/repo/src/index.ts',
exists: true,
isDirectory: false,
openTarget: {
kind: 'worktree-file',
provider: 'local',
relativePath: 'src/index.ts',
absolutePath: '/repo/src/index.ts'
}
}),
{ ok: false, error: { message: 'nope' } }
])
const onOpenFailed = vi.fn()
openMobileFileTap({
client,
hostId: 'host-1',
worktreeId: 'wt-1',
pathText: 'src/index.ts',
line: null,
column: null,
pushPreviewRoute: vi.fn(),
openBrowser: vi.fn(),
triggerOpenFeedback: vi.fn(),
fetchSessionTabs: vi.fn(),
getSessionTabs: () => [],
getActiveSessionTabId: () => null,
getActivationState: activeTerminalState,
switchSessionTab: vi.fn(),
scheduleDelayedAction: vi.fn(),
onOpenFailed
})
await Promise.resolve()
await Promise.resolve()
await Promise.resolve()
expect(onOpenFailed).toHaveBeenCalledTimes(1)
})
it('reports an unsupported file when files.open declines it', async () => {
const client = createClient([
ok({
worktree: 'wt-1',
relativePath: 'dist/app.zip',
absolutePath: '/repo/dist/app.zip',
exists: true,
isDirectory: false,
openTarget: {
kind: 'worktree-file',
provider: 'local',
relativePath: 'dist/app.zip',
absolutePath: '/repo/dist/app.zip'
}
}),
ok({ worktree: 'wt-1', relativePath: 'dist/app.zip', kind: 'binary', opened: false })
])
const onOpenFailed = vi.fn()
const scheduleDelayedAction = vi.fn()
openMobileFileTap({
client,
hostId: 'host-1',
worktreeId: 'wt-1',
pathText: 'dist/app.zip',
line: null,
column: null,
pushPreviewRoute: vi.fn(),
openBrowser: vi.fn(),
triggerOpenFeedback: vi.fn(),
fetchSessionTabs: vi.fn(),
getSessionTabs: () => [],
getActiveSessionTabId: () => null,
getActivationState: activeTerminalState,
switchSessionTab: vi.fn(),
scheduleDelayedAction,
onOpenFailed
})
await Promise.resolve()
await Promise.resolve()
await Promise.resolve()
expect(onOpenFailed).toHaveBeenCalledTimes(1)
expect(scheduleDelayedAction).not.toHaveBeenCalled()
})
it('does not report a stale failure after a newer tap supersedes it', async () => {
const client = createClient([
ok({
worktree: 'wt-1',
relativePath: null,
absolutePath: null,
exists: false,
isDirectory: false
})
])
const onOpenFailed = vi.fn()
openMobileFileTap({
client,
hostId: 'host-1',
worktreeId: 'wt-1',
pathText: 'gone/missing.ts',
line: null,
column: null,
pushPreviewRoute: vi.fn(),
openBrowser: vi.fn(),
triggerOpenFeedback: vi.fn(),
fetchSessionTabs: vi.fn(),
getSessionTabs: () => [],
getActiveSessionTabId: () => null,
getActivationState: (activated) => ({
...activeTerminalState(activated),
latestActivationSeq: 2
}),
switchSessionTab: vi.fn(),
scheduleDelayedAction: vi.fn(),
onOpenFailed
})
await Promise.resolve()
await Promise.resolve()
expect(onOpenFailed).not.toHaveBeenCalled()
})
it('does not report a failure when the user left the source tab mid-resolve', async () => {
const client = createClient([
ok({
worktree: 'wt-1',
relativePath: 'src/index.ts',
absolutePath: '/repo/src/index.ts',
exists: true,
isDirectory: false,
openTarget: {
kind: 'worktree-file',
provider: 'local',
relativePath: 'src/index.ts',
absolutePath: '/repo/src/index.ts'
}
})
])
const onOpenFailed = vi.fn()
openMobileFileTap({
client,
hostId: 'host-1',
worktreeId: 'wt-1',
pathText: 'src/index.ts',
line: null,
column: null,
pushPreviewRoute: vi.fn(),
openBrowser: vi.fn(),
triggerOpenFeedback: vi.fn(),
fetchSessionTabs: vi.fn(),
getSessionTabs: () => [],
getActiveSessionTabId: () => null,
getActivationState: (activated) => ({
...activeTerminalState(activated),
activeTerminalHandle: 'terminal-2'
}),
switchSessionTab: vi.fn(),
scheduleDelayedAction: vi.fn(),
onOpenFailed
})
await Promise.resolve()
await Promise.resolve()
expect(onOpenFailed).not.toHaveBeenCalled()
})
it('does not activate a worktree file tab after a newer tap supersedes it', async () => {
const client = createClient([
ok({
@@ -402,7 +626,7 @@ describe('openMobileTerminalFileTap', () => {
const openedTab = { id: 'tab-2', relativePath: 'src/index.ts' }
const switchSessionTab = vi.fn()
openMobileTerminalFileTap({
openMobileFileTap({
client,
hostId: 'host-1',
worktreeId: 'wt-1',
@@ -1,4 +1,7 @@
import type { RuntimeTerminalPathResolution } from '../../../src/shared/runtime-types'
import type {
RuntimeFileOpenResult,
RuntimeTerminalPathResolution
} from '../../../src/shared/runtime-types'
import { filesystemPathToFileUri } from '../../../src/shared/file-uri-path'
import { createMobileFilePreviewHref } from '../files/mobile-file-preview-route'
import { classifyMobileArtifact } from './mobile-artifact-kind'
@@ -6,12 +9,12 @@ import type { RpcClient } from '../transport/rpc-client'
import type { RpcSuccess } from '../transport/types'
import { shouldActivateOpenedMobileSessionTab } from './opened-mobile-session-tab'
type TerminalFileTapSessionTab = {
export type FileTapSessionTab = {
id: string
relativePath?: string
}
type OpenMobileTerminalFileTapOptions<T extends TerminalFileTapSessionTab> = {
export type OpenMobileFileTapOptions<T extends FileTapSessionTab> = {
client: Pick<RpcClient, 'sendRequest'>
hostId: string
worktreeId: string
@@ -37,19 +40,34 @@ type OpenMobileTerminalFileTapOptions<T extends TerminalFileTapSessionTab> = {
}
switchSessionTab: (tab: T) => void
scheduleDelayedAction: (callback: () => void, delayMs: number) => unknown
/** Invoked when the tap cannot open anything (resolve miss, directory, or a
* failed open). Omitted on surfaces that keep the historical silent miss. */
onOpenFailed?: () => void
}
export function openMobileTerminalFileTap<T extends TerminalFileTapSessionTab>(
options: OpenMobileTerminalFileTapOptions<T>
export function openMobileFileTap<T extends FileTapSessionTab>(
options: OpenMobileFileTapOptions<T>
): void {
void openMobileTerminalFileTapAsync(options).catch(() => {
// Terminal file taps are best-effort: a failed host resolution should leave
// terminal focus/input untouched, matching the existing silent miss behavior.
void openMobileFileTapAsync(options).catch(() => {
// File taps are best-effort: a failed host resolution should leave terminal
// focus/input untouched. Surfaces that want feedback pass onOpenFailed.
reportOpenFailure(options)
})
}
async function openMobileTerminalFileTapAsync<T extends TerminalFileTapSessionTab>(
options: OpenMobileTerminalFileTapOptions<T>
function reportOpenFailure<T extends FileTapSessionTab>(
options: OpenMobileFileTapOptions<T>
): void {
if (
options.onOpenFailed &&
shouldActivateOpenedMobileSessionTab(options.getActivationState(false))
) {
options.onOpenFailed()
}
}
async function openMobileFileTapAsync<T extends FileTapSessionTab>(
options: OpenMobileFileTapOptions<T>
): Promise<void> {
const worktree = `id:${options.worktreeId}`
const response = await options.client.sendRequest(
@@ -65,22 +83,29 @@ async function openMobileTerminalFileTapAsync<T extends TerminalFileTapSessionTa
{ timeoutMs: 10_000 }
)
if (!response.ok) {
reportOpenFailure(options)
return
}
const resolved = (response as RpcSuccess).result as RuntimeTerminalPathResolution
if (!resolved.exists || resolved.isDirectory) {
reportOpenFailure(options)
return
}
// Not a failure: the user moved off the source tab mid-resolve.
if (!shouldActivateOpenedMobileSessionTab(options.getActivationState(false))) {
return
}
const resolvedWorktreeId = resolved.worktree?.trim() || options.worktreeId
const resolvedWorktree = `id:${resolvedWorktreeId}`
const resolvedWorktreeName =
resolvedWorktreeId === options.worktreeId ? options.worktreeName : undefined
if (resolved.openTarget?.kind === 'absolute-file') {
options.triggerOpenFeedback()
options.pushPreviewRoute(
createMobileFilePreviewHref({
hostId: options.hostId,
worktreeId: options.worktreeId,
worktreeId: resolvedWorktreeId,
source: 'terminalArtifact',
absolutePath: resolved.openTarget.absolutePath,
grantId: resolved.openTarget.grantId,
@@ -92,7 +117,7 @@ async function openMobileTerminalFileTapAsync<T extends TerminalFileTapSessionTa
name: displayNameFromPath(resolved.openTarget.absolutePath),
...(options.line !== null ? { line: String(options.line) } : {}),
...(options.column !== null ? { column: String(options.column) } : {}),
...(options.worktreeName ? { worktreeName: options.worktreeName } : {})
...(resolvedWorktreeName ? { worktreeName: resolvedWorktreeName } : {})
})
)
return
@@ -103,20 +128,25 @@ async function openMobileTerminalFileTapAsync<T extends TerminalFileTapSessionTa
? resolved.openTarget.relativePath
: resolved.relativePath
if (!openedPath) {
reportOpenFailure(options)
return
}
options.triggerOpenFeedback()
if (options.line !== null || options.column !== null) {
if (
resolvedWorktreeId !== options.worktreeId ||
options.line !== null ||
options.column !== null
) {
options.pushPreviewRoute(
createMobileFilePreviewHref({
hostId: options.hostId,
worktreeId: options.worktreeId,
worktreeId: resolvedWorktreeId,
source: 'worktree',
relativePath: openedPath,
name: displayNameFromPath(openedPath),
...(options.line !== null ? { line: String(options.line) } : {}),
...(options.column !== null ? { column: String(options.column) } : {}),
...(options.worktreeName ? { worktreeName: options.worktreeName } : {})
...(resolvedWorktreeName ? { worktreeName: resolvedWorktreeName } : {})
})
)
return
@@ -131,17 +161,23 @@ async function openMobileTerminalFileTapAsync<T extends TerminalFileTapSessionTa
}
const openResponse = await options.client.sendRequest(
'files.open',
{ worktree, relativePath: openedPath },
{ worktree: resolvedWorktree, relativePath: openedPath },
{ timeoutMs: 15_000 }
)
if (!openResponse.ok) {
reportOpenFailure(options)
return
}
const openResult = (openResponse as RpcSuccess).result as RuntimeFileOpenResult
if (!openResult.opened) {
reportOpenFailure(options)
return
}
scheduleOpenedWorktreeTabActivation(options, openedPath)
}
function scheduleOpenedWorktreeTabActivation<T extends TerminalFileTapSessionTab>(
options: OpenMobileTerminalFileTapOptions<T>,
function scheduleOpenedWorktreeTabActivation<T extends FileTapSessionTab>(
options: OpenMobileFileTapOptions<T>,
openedPath: string
): void {
let activated = false
@@ -1,93 +1,153 @@
import { describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import {
openMobileNativeChatFile,
resolveMobileNativeChatWorktreePath
} from './mobile-native-chat-open-file'
import { openMobileNativeChatFileTap } from './mobile-native-chat-open-file'
describe('resolveMobileNativeChatWorktreePath', () => {
it('resolves an absolute tool path to a worktree-relative open target', async () => {
const sendRequest = vi.fn().mockResolvedValue({
ok: true,
result: {
exists: true,
isDirectory: false,
openTarget: { kind: 'worktree-file', relativePath: 'src/app.ts' }
}
})
await expect(
resolveMobileNativeChatWorktreePath({
client: { sendRequest } as unknown as RpcClient,
worktreeId: 'worktree',
pathText: '/repo/src/app.ts',
terminal: 'terminal'
})
).resolves.toBe('src/app.ts')
expect(sendRequest).toHaveBeenCalledWith('files.resolveTerminalPath', {
worktree: 'id:worktree',
pathText: '/repo/src/app.ts',
terminal: 'terminal'
})
function ok(result: unknown) {
return { ok: true, result, _meta: { runtimeId: 'runtime-1' } }
}
function activationState(activated: boolean) {
return {
activated,
activationSeq: 1,
latestActivationSeq: 1,
sourceTerminalHandle: 'terminal-1',
activeTerminalHandle: 'terminal-1',
activeTabType: 'terminal'
}
}
function baseOptions(client: { sendRequest: ReturnType<typeof vi.fn> }) {
return {
client,
hostId: 'host-1',
worktreeId: 'wt-1',
pushPreviewRoute: vi.fn(),
openBrowser: vi.fn(),
triggerOpenFeedback: vi.fn(),
fetchSessionTabs: vi.fn(),
getSessionTabs: () => [],
getActiveSessionTabId: () => null,
getActivationState: activationState,
switchSessionTab: vi.fn(),
scheduleDelayedAction: vi.fn(),
onOpenFailed: vi.fn()
}
}
function worktreeFileResolution(relativePath: string) {
return ok({
worktree: 'wt-1',
relativePath,
absolutePath: `/repo/${relativePath}`,
exists: true,
isDirectory: false,
openTarget: {
kind: 'worktree-file',
provider: 'local',
relativePath,
absolutePath: `/repo/${relativePath}`
}
})
}
describe('openMobileNativeChatFileTap', () => {
it('resolves against the worktree root: no terminal handle and no cwd', async () => {
const sendRequest = vi.fn(async () => worktreeFileResolution('src/app.ts'))
const options = baseOptions({ sendRequest })
openMobileNativeChatFileTap({ ...options, pathText: 'src/app.ts' })
await Promise.resolve()
expect(sendRequest).toHaveBeenCalledWith(
'files.resolveTerminalPath',
{ worktree: 'id:wt-1', pathText: 'src/app.ts' },
{ timeoutMs: 10_000 }
)
})
it('opens only the resolved worktree-relative target', async () => {
const sendRequest = vi
.fn()
.mockResolvedValueOnce({
ok: true,
result: {
exists: true,
isDirectory: false,
openTarget: { kind: 'worktree-file', relativePath: 'src/app.ts' }
}
it('parses a :line:col citation and opens the mobile preview route', async () => {
const sendRequest = vi.fn(async () => worktreeFileResolution('src/app.ts'))
const options = baseOptions({ sendRequest })
openMobileNativeChatFileTap({ ...options, pathText: 'src/app.ts:120:7' })
await Promise.resolve()
expect(sendRequest).toHaveBeenCalledWith(
'files.resolveTerminalPath',
{ worktree: 'id:wt-1', pathText: 'src/app.ts' },
{ timeoutMs: 10_000 }
)
expect(options.triggerOpenFeedback).toHaveBeenCalledTimes(1)
expect(options.pushPreviewRoute).toHaveBeenCalledWith({
pathname: '/h/[hostId]/files/preview/[worktreeId]',
params: expect.objectContaining({
source: 'worktree',
relativePath: 'src/app.ts',
line: '120',
column: '7'
})
.mockResolvedValueOnce({ ok: true, result: {} })
await openMobileNativeChatFile({
client: { sendRequest } as unknown as RpcClient,
worktreeId: 'worktree',
pathText: '../repo/src/app.ts',
terminal: 'terminal'
})
expect(sendRequest).toHaveBeenLastCalledWith('files.open', {
worktree: 'id:worktree',
relativePath: 'src/app.ts'
})
expect(options.onOpenFailed).not.toHaveBeenCalled()
})
it('resolves null when the resolve request rejects', async () => {
const sendRequest = vi.fn().mockRejectedValue(new Error('Request timed out'))
await expect(
resolveMobileNativeChatWorktreePath({
client: { sendRequest } as unknown as RpcClient,
worktreeId: 'worktree',
pathText: 'src/app.ts',
terminal: null
it('surfaces a resolve miss instead of a silent no-op', async () => {
const sendRequest = vi.fn(async () =>
ok({
worktree: 'wt-1',
relativePath: null,
absolutePath: null,
exists: false,
isDirectory: false
})
).resolves.toBeNull()
)
const options = baseOptions({ sendRequest })
openMobileNativeChatFileTap({ ...options, pathText: 'gone/missing.ts' })
await Promise.resolve()
await Promise.resolve()
expect(options.onOpenFailed).toHaveBeenCalledTimes(1)
expect(options.pushPreviewRoute).not.toHaveBeenCalled()
expect(options.triggerOpenFeedback).not.toHaveBeenCalled()
})
it('does not reject when the open request fails', async () => {
const sendRequest = vi
.fn()
.mockResolvedValueOnce({
ok: true,
result: {
exists: true,
isDirectory: false,
openTarget: { kind: 'worktree-file', relativePath: 'src/app.ts' }
}
})
.mockRejectedValueOnce(new Error('connection interrupted'))
it('surfaces a rejected resolve request', async () => {
const sendRequest = vi.fn(async () => {
throw new Error('Request timed out')
})
const options = baseOptions({ sendRequest })
await expect(
openMobileNativeChatFile({
client: { sendRequest } as unknown as RpcClient,
worktreeId: 'worktree',
pathText: 'src/app.ts',
terminal: null
})
).resolves.toBeUndefined()
openMobileNativeChatFileTap({ ...options, pathText: 'src/app.ts' })
await Promise.resolve()
await Promise.resolve()
expect(options.onOpenFailed).toHaveBeenCalledTimes(1)
})
it('opens a plain path through files.open with tab activation', async () => {
const responses: unknown[] = [worktreeFileResolution('src/app.ts'), ok({ opened: true })]
const sendRequest = vi.fn(async () => responses.shift())
const openedTab = { id: 'tab-2', relativePath: 'src/app.ts' }
const switchSessionTab = vi.fn()
const options = {
...baseOptions({ sendRequest }),
getSessionTabs: () => [openedTab],
getActiveSessionTabId: () => 'terminal-tab',
switchSessionTab,
scheduleDelayedAction: vi.fn((callback: () => void) => callback())
}
openMobileNativeChatFileTap({ ...options, pathText: 'src/app.ts' })
await Promise.resolve()
await Promise.resolve()
await new Promise((resolve) => setTimeout(resolve, 0))
expect(sendRequest).toHaveBeenCalledWith(
'files.open',
{ worktree: 'id:wt-1', relativePath: 'src/app.ts' },
{ timeoutMs: 15_000 }
)
expect(switchSessionTab).toHaveBeenCalledWith(openedTab)
expect(options.onOpenFailed).not.toHaveBeenCalled()
})
})
@@ -1,51 +1,30 @@
import type { RuntimeTerminalPathResolution } from '../../../src/shared/runtime-types'
import type { RpcClient } from '../transport/rpc-client'
import { splitFilePathLineSuffix } from '../components/markdown-file-path-detection'
import {
openMobileFileTap,
type FileTapSessionTab,
type OpenMobileFileTapOptions
} from './mobile-file-tap-open'
export async function resolveMobileNativeChatWorktreePath(args: {
client: RpcClient
worktreeId: string
pathText: string
terminal: string | null
}): Promise<string | null> {
try {
const response = await args.client.sendRequest('files.resolveTerminalPath', {
worktree: `id:${args.worktreeId}`,
pathText: args.pathText,
...(args.terminal ? { terminal: args.terminal } : {})
})
if (!response.ok) {
return null
}
const resolved = response.result as RuntimeTerminalPathResolution
if (!resolved.exists || resolved.isDirectory) {
return null
}
return resolved.openTarget?.kind === 'worktree-file'
? resolved.openTarget.relativePath
: (resolved.relativePath ?? null)
} catch {
// Callers fire-and-forget file opens; a disconnect/timeout must not become
// an unhandled rejection.
return null
}
}
export type OpenMobileNativeChatFileTapOptions<T extends FileTapSessionTab> = Omit<
OpenMobileFileTapOptions<T>,
'terminalHandle' | 'cwd' | 'line' | 'column'
>
export async function openMobileNativeChatFile(args: {
client: RpcClient
worktreeId: string
pathText: string
terminal: string | null
}): Promise<void> {
const relativePath = await resolveMobileNativeChatWorktreePath(args)
if (relativePath) {
try {
await args.client.sendRequest('files.open', {
worktree: `id:${args.worktreeId}`,
relativePath
})
} catch {
// Best-effort open; failures surface as a no-op rather than an
// unhandled rejection.
}
}
/**
* Open a file reference tapped in native chat: same haptic / preview-route /
* tab-activation flow as terminal taps, but chat paths are worktree-root
* relative (or absolute), so resolution deliberately passes no terminal handle
* and no cwd — a terminal's live cwd (e.g. `<worktree>/mobile`) would misplace
* them. Agent-style `path:line(:col)` citations carry their location through.
*/
export function openMobileNativeChatFileTap<T extends FileTapSessionTab>(
options: OpenMobileNativeChatFileTapOptions<T>
): void {
const { path, line, column } = splitFilePathLineSuffix(options.pathText)
openMobileFileTap<T>({
...options,
pathText: path,
line,
column
})
}
@@ -13,6 +13,11 @@ import {
markMobileNativeChatInputStale,
resetMobileNativeChatStaleInputForTests
} from './mobile-native-chat-stale-input'
import {
acquireMobileNativeChatTerminalWrite,
releaseMobileNativeChatTerminalWrite,
resetMobileNativeChatTerminalWritesForTests
} from './mobile-native-chat-terminal-write-lock'
describe('sendMobileNativeChatPermissionResponse', () => {
it('writes an approval as raw bytes without appending Return', async () => {
@@ -64,6 +69,7 @@ describe('useMobileNativeChatPermissionSend', () => {
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
resetMobileNativeChatStaleInputForTests()
resetMobileNativeChatTerminalWritesForTests()
})
afterEach(() => {
@@ -102,4 +108,42 @@ describe('useMobileNativeChatPermissionSend', () => {
expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ text: '1', enter: false })
expect(isMobileNativeChatInputStale('terminal')).toBe(true)
})
it('rejects a choice while another composed write holds the terminal, then recovers', async () => {
const onSendError = vi.fn()
const sendRequest = vi.fn().mockResolvedValue({
ok: true,
result: { send: { handle: 'terminal', accepted: true, bytesWritten: 1 } }
})
function Harness(): null {
respond = useMobileNativeChatPermissionSend({
client: { sendRequest } as unknown as RpcClient,
enabled: true,
handleRef: { current: 'terminal' },
deviceTokenRef: { current: null },
onSendError
})
return null
}
act(() => {
renderer = create(createElement(Harness))
})
// An image paste sequence is mid-flight into the same PTY: the choice
// keystroke must not interleave into it.
expect(acquireMobileNativeChatTerminalWrite('terminal')).toBe(true)
await act(async () => {
await expect(respond?.('1')).resolves.toBe(false)
})
expect(sendRequest).not.toHaveBeenCalled()
expect(onSendError).toHaveBeenCalledWith('Response not sent')
releaseMobileNativeChatTerminalWrite('terminal')
await act(async () => {
await expect(respond?.('1')).resolves.toBe(true)
})
// The choice released its own hold on the way out.
expect(acquireMobileNativeChatTerminalWrite('terminal')).toBe(true)
releaseMobileNativeChatTerminalWrite('terminal')
})
})
@@ -4,6 +4,10 @@ import {
sendMobileNativeChatMessageWithOutcome,
type MobileNativeChatSendOutcome
} from './mobile-native-chat-send'
import {
acquireMobileNativeChatTerminalWrite,
releaseMobileNativeChatTerminalWrite
} from './mobile-native-chat-terminal-write-lock'
export function sendMobileNativeChatPermissionResponse(args: {
client: RpcClient
@@ -36,15 +40,26 @@ export function useMobileNativeChatPermissionSend(args: {
args.onSendError('Response not sent (disconnected)')
return false
}
// A choice keystroke must not interleave into a mid-flight composed write
// (image paste, paced answer) on the same PTY.
if (!acquireMobileNativeChatTerminalWrite(terminal)) {
args.onSendError('Response not sent')
return false
}
// No stale-input heal here (unlike the text/ask sends): a choice is an
// `enter: false` key for an active overlay that swallows the clear, so it
// would consume the marker still protecting the next real message.
const outcome = await sendMobileNativeChatPermissionResponse({
client: args.client,
terminal,
deviceToken: args.deviceTokenRef.current,
text
})
let outcome: MobileNativeChatSendOutcome
try {
outcome = await sendMobileNativeChatPermissionResponse({
client: args.client,
terminal,
deviceToken: args.deviceTokenRef.current,
text
})
} finally {
releaseMobileNativeChatTerminalWrite(terminal)
}
if (outcome === 'unknown') {
// Why: the response may have been delivered (ack lost / path cutover) —
// a definite "not sent" would invite a double answer.
@@ -1,7 +1,8 @@
import { describe, expect, it } from 'vitest'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import {
buildMobileNativeChatData,
buildMobileNativeChatTransientData,
foldMobileNativeChatMessages,
mobileNativeChatEmptyState
} from './mobile-native-chat-render-data'
@@ -50,13 +51,22 @@ describe('mobileNativeChatEmptyState', () => {
})
})
describe('buildMobileNativeChatData', () => {
/** Mirrors the view: fold the raw transcript, then assemble the list. */
function build(
messages: NativeChatMessage[],
streaming: string | null,
pending: Parameters<typeof buildMobileNativeChatTransientData>[0]['pending']
): NativeChatMessage[] {
return buildMobileNativeChatTransientData({
folded: foldMobileNativeChatMessages(messages),
streaming,
pending
}).data
}
describe('buildMobileNativeChatTransientData', () => {
it('appends pending optimistic messages at the tail as user turns', () => {
const { data } = buildMobileNativeChatData({
messages: [assistant('a1', 'hello')],
streamingText: undefined,
pending: [{ id: 'p1', text: 'queued' }]
})
const data = build([assistant('a1', 'hello')], null, [{ id: 'p1', text: 'queued' }])
const last = data[data.length - 1]
expect(last.id).toBe('p1')
expect(last.role).toBe('user')
@@ -64,10 +74,9 @@ describe('buildMobileNativeChatData', () => {
})
it('renders a pending send with images as text followed by image-ref thumbnails', () => {
const { data } = buildMobileNativeChatData({
messages: [],
pending: [{ id: 'p1', text: 'look', images: ['file:///a.jpg', 'file:///b.jpg'] }]
})
const data = build([], null, [
{ id: 'p1', text: 'look', images: ['file:///a.jpg', 'file:///b.jpg'] }
])
const last = data[data.length - 1]
expect(last.role).toBe('user')
expect(last.blocks).toEqual([
@@ -78,10 +87,7 @@ describe('buildMobileNativeChatData', () => {
})
it('renders an image-only pending send (no text) as just the thumbnail', () => {
const { data } = buildMobileNativeChatData({
messages: [],
pending: [{ id: 'p1', text: '', images: ['file:///a.jpg'] }]
})
const data = build([], null, [{ id: 'p1', text: '', images: ['file:///a.jpg'] }])
expect(data[data.length - 1].blocks).toEqual([{ type: 'image-ref', url: 'file:///a.jpg' }])
})
@@ -89,14 +95,15 @@ describe('buildMobileNativeChatData', () => {
// Claude records an attached image as `[Image: source: /path]` + an
// `[Image #1] `-prefixed caption turn; the fold must merge them into one
// user turn with an image-ref block instead of showing raw marker text.
const { data } = buildMobileNativeChatData({
messages: [
const data = build(
[
user('u1', '[Image: source: /tmp/a.png]'),
user('u2', '[Image #1] look at this'),
assistant('a1', 'nice photo')
],
pending: []
})
null,
[]
)
const merged = data.find((message) => message.role === 'user')
expect(merged?.blocks).toEqual([
{ type: 'image-ref', path: '/tmp/a.png' },
@@ -105,49 +112,20 @@ describe('buildMobileNativeChatData', () => {
})
it('renders a lone image marker turn (no caption) as an image-ref block', () => {
const { data } = buildMobileNativeChatData({
messages: [user('u1', '[Image: source: /tmp/a.png]')],
pending: []
})
const data = build([user('u1', '[Image: source: /tmp/a.png]')], null, [])
expect(data[0]?.blocks).toEqual([{ type: 'image-ref', path: '/tmp/a.png' }])
})
it('adds a synthetic streaming bubble while the partial text leads the transcript', () => {
const { streaming, data } = buildMobileNativeChatData({
messages: [user('u1', 'hi')],
streamingText: 'thinking out loud',
pending: []
})
expect(streaming).toBe('thinking out loud')
expect(data.some((m) => m.id === 'streaming')).toBe(true)
it('appends a synthetic bubble for gated streaming text, between transcript and pending', () => {
// Whether text streams at all is the gate's call
// (`mobile-native-chat-streaming-gate.test.ts`); this only places it.
const data = build([user('u1', 'hi')], 'thinking out loud', [{ id: 'p1', text: 'queued' }])
expect(data.map((message) => message.id)).toEqual(['u1', 'streaming', 'p1'])
expect(data[1].blocks).toEqual([{ type: 'text', text: 'thinking out loud' }])
})
it('shows a short new streaming reply even after a longer previous turn', () => {
// The last folded turn is a long completed reply; a short new stream must not
// be suppressed just for being shorter than the prior turn.
const { streaming, data } = buildMobileNativeChatData({
messages: [assistant('a1', 'This is a long completed previous answer that ran on a while')],
streamingText: 'Ok',
pending: []
})
expect(streaming).toBe('Ok')
expect(data.some((m) => m.id === 'streaming')).toBe(true)
})
it('drops the streaming bubble once the real assistant turn already contains it', () => {
const { streaming, data } = buildMobileNativeChatData({
messages: [assistant('a1', 'done answer')],
streamingText: 'done',
pending: []
})
expect(streaming).toBeNull()
expect(data.some((m) => m.id === 'streaming')).toBe(false)
})
it('returns no streaming bubble for empty/whitespace streaming text', () => {
expect(
buildMobileNativeChatData({ messages: [], streamingText: ' ', pending: [] }).streaming
).toBeNull()
expect(buildMobileNativeChatData({ messages: [], pending: [] }).streaming).toBeNull()
it('omits the bubble when the gate withheld the streaming text', () => {
const data = build([assistant('a1', 'done answer')], null, [])
expect(data.some((message) => message.id === 'streaming')).toBe(false)
})
})
@@ -43,41 +43,25 @@ export type MobileNativeChatPendingItem = {
images?: string[]
}
/** Derive the list data from the raw transcript: fold tool turns into the
* assistant turn, optionally append a synthetic streaming bubble, then the
* route-owned optimistic "queued" messages at the tail. Returns the
* intermediate `folded`/`streaming` so the caller can memoize on them. */
export function buildMobileNativeChatData({
messages,
streamingText,
pending
}: {
messages: NativeChatMessage[]
streamingText?: string
pending: MobileNativeChatPendingItem[]
}): { folded: NativeChatMessage[]; streaming: string | null; data: NativeChatMessage[] } {
const folded = foldMobileNativeChatMessages(messages)
return buildMobileNativeChatTransientData({ folded, streamingText, pending })
}
export function foldMobileNativeChatMessages(messages: NativeChatMessage[]): NativeChatMessage[] {
// Normalize first (desktop assembler parity): image marker turns fold into
// image-ref blocks instead of rendering as raw `[Image: …]` text.
return foldToolMessages(stripNoiseMessages(normalizeImageTranscriptMessages(messages)))
}
/** Assemble the list data the chat renders: the folded transcript, then a
* synthetic bubble for the streaming text the gate let through, then the
* route-owned optimistic "queued" messages at the tail. */
export function buildMobileNativeChatTransientData({
folded,
streamingText,
streaming,
pending
}: {
folded: NativeChatMessage[]
streamingText?: string
/** Streaming bubble text, already gated by `deriveMobileNativeChatStreaming`. */
streaming: string | null
pending: MobileNativeChatPendingItem[]
}): { folded: NativeChatMessage[]; streaming: string | null; data: NativeChatMessage[] } {
// Only show the streaming bubble while its text leads the transcript — once the
// real assistant turn lands with the same text, drop the synthetic one.
const streaming = deriveStreaming(folded, streamingText)
const data: NativeChatMessage[] = [
...folded,
...(streaming
@@ -106,26 +90,3 @@ export function buildMobileNativeChatTransientData({
]
return { folded, streaming, data }
}
function deriveStreaming(folded: NativeChatMessage[], streamingText?: string): string | null {
const text = streamingText?.trim()
if (!text) {
return null
}
const last = folded[folded.length - 1]
const lastText =
last?.role === 'assistant'
? last.blocks
.filter((b) => b.type === 'text')
.map((b) => (b.type === 'text' ? b.text : ''))
.join('')
.trim()
: ''
// Hide the synthetic bubble only once the real turn has landed leading with the
// streamed text. A bare length compare would suppress a short new reply behind a
// longer previous turn; a completed prior turn won't start with the new prefix.
if (lastText.startsWith(text)) {
return null
}
return text
}
@@ -0,0 +1,238 @@
import { describe, expect, it } from 'vitest'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import {
createMobileNativeChatStreamingGate,
deriveMobileNativeChatStreaming,
type MobileNativeChatStreamingGate
} from './mobile-native-chat-streaming-gate'
function assistant(id: string, text: string): NativeChatMessage {
return {
id,
role: 'assistant',
blocks: [{ type: 'text', text }],
timestamp: 0,
source: 'transcript'
}
}
/** Run a sequence of (folded, streamingText) ticks through one gate. */
function run(ticks: { folded: NativeChatMessage[]; text?: string; live?: boolean }[]): {
gate: MobileNativeChatStreamingGate
results: (string | null)[]
} {
let gate = createMobileNativeChatStreamingGate()
const results: (string | null)[] = []
for (const tick of ticks) {
const step = deriveMobileNativeChatStreaming(gate, tick.folded, tick.text, {
streamLive: tick.live
})
gate = step.gate
results.push(step.streaming)
}
return { gate, results }
}
describe('deriveMobileNativeChatStreaming', () => {
it('shows a genuine reply that repeats the previous turn as a prefix', () => {
const prior = [assistant('a1', 'The tests pass.')]
const { results } = run([
{ folded: prior }, // idle tick anchors the pre-stream tail
{ folded: prior, text: 'The' },
{ folded: prior, text: 'The tests' },
{ folded: prior, text: 'The tests pass.' }
])
expect(results).toEqual([null, 'The', 'The tests', 'The tests pass.'])
})
it('hides the bubble once the real turn lands leading with the streamed text', () => {
const prior = [assistant('a1', 'earlier turn')]
const landed = [...prior, assistant('a2', 'fresh answer with a tail')]
const { results } = run([
{ folded: prior },
{ folded: prior, text: 'fresh answer' },
{ folded: landed, text: 'fresh answer' }
])
expect(results).toEqual([null, 'fresh answer', null])
})
it('suppresses an identical repeated reply once its own turn lands', () => {
const prior = [assistant('a1', 'Done.')]
const landed = [...prior, assistant('a2', 'Done.')]
const { results } = run([
{ folded: prior },
{ folded: prior, text: 'Done.' }, // repeated-prefix reply stays visible
{ folded: landed, text: 'Done.' } // its own turn landed — hide
])
expect(results).toEqual([null, 'Done.', null])
})
it('keeps hiding for the rest of a segment after the turn lands', () => {
const prior = [assistant('a1', 'earlier')]
const landed = [...prior, assistant('a2', 'answer body')]
const { results } = run([
{ folded: prior },
{ folded: prior, text: 'answer' },
{ folded: landed, text: 'answer' },
{ folded: landed, text: 'answer bo' }
])
expect(results).toEqual([null, 'answer', null, null])
})
it('keeps the segment baseline through textless ticks while the turn is live', () => {
// Chat is hidden mid-stream: the transcript unsubscribes and the status
// stops reaching the gate, but the turn has not ended. Coming back, the
// stream text returns before the re-read transcript does.
const prior = [assistant('a1', 'Done.')]
const { results } = run([
{ folded: prior },
{ folded: prior, text: 'Done.', live: true },
{ folded: [], live: true },
{ folded: [], text: 'Done.', live: true },
{ folded: prior, text: 'Done.', live: true }
])
expect(results).toEqual([null, 'Done.', null, 'Done.', 'Done.'])
})
it('still hides after a hidden gap once the reply landed as its own turn', () => {
const prior = [assistant('a1', 'Done.')]
const landed = [...prior, assistant('a2', 'Done.')]
const { results } = run([
{ folded: prior },
{ folded: prior, text: 'Done.', live: true },
{ folded: [], live: true },
{ folded: landed, text: 'Done.', live: true }
])
expect(results).toEqual([null, 'Done.', null, null])
})
it('hides a reply whose own turn landed before its status text arrived', () => {
// The pane stays `working` past the reply (a subagent or a background task
// is still live), and the transcript push beats the throttled status text.
// Anchoring on that textless tick would adopt the reply as pre-stream
// history and render it a second time as a bubble.
const prior = [assistant('a1', 'Done.')]
const landed = [...prior, assistant('a2', 'Done.')]
const { results } = run([
{ folded: prior, live: true },
{ folded: landed, live: true },
{ folded: landed, text: 'Done.', live: true },
{ folded: landed, text: 'Done.', live: true }
])
expect(results).toEqual([null, null, null, null])
})
it('keeps the pre-stream baseline across a hidden gap taken between turns', () => {
// Peeking at the terminal while idle tears the transcript down to empty. An
// empty tail is not history: adopting it strands the baseline and swallows
// the repeated-prefix reply that arrives next.
const prior = [assistant('a1', 'Done.')]
const { results } = run([
{ folded: prior },
{ folded: [] },
{ folded: [], live: true },
{ folded: prior, text: 'Done.', live: true }
])
expect(results).toEqual([null, null, null, 'Done.'])
})
it('anchors on the first tail it sees when mounted mid-turn', () => {
// Opening a workspace whose agent is already working: that first textless
// tick is the only pre-stream history the gate will ever get.
const prior = [assistant('a1', 'Done.')]
const { results } = run([
{ folded: prior, live: true },
{ folded: prior, text: 'Done.', live: true }
])
expect(results).toEqual([null, 'Done.'])
})
it('anchors on a textless tick once the turn ends', () => {
const prior = [assistant('a1', 'first answer')]
const landed = [...prior, assistant('a2', 'second answer')]
const { results } = run([
{ folded: prior },
{ folded: prior, text: 'second answer', live: true },
{ folded: landed },
{ folded: landed, text: 'second answer', live: true }
])
expect(results).toEqual([null, 'second answer', null, 'second answer'])
})
it('does not treat the previous turn as a segment start after re-anchoring', () => {
// The textless anchor clears the remembered text too. Keeping it would read
// the next turn's opener as a new segment, re-anchor onto the reply that
// just landed, and render it a second time as a bubble.
const prior = [assistant('a1', 'context')]
const firstLanded = [...prior, assistant('a2', 'Alpha done')]
const secondLanded = [...firstLanded, assistant('a3', 'Beta reply')]
const { results } = run([
{ folded: prior },
{ folded: prior, text: 'Alpha', live: true },
{ folded: firstLanded }, // turn ended — re-anchor onto a2
{ folded: secondLanded, text: 'Beta', live: true } // a3 already landed
])
expect(results).toEqual([null, 'Alpha', null, null])
})
it('re-anchors when a new reply part replaces the stream mid-turn', () => {
const prior = [assistant('a1', 'context')]
const partOneLanded = [...prior, assistant('a2', 'part one full text')]
const { results } = run([
{ folded: prior },
{ folded: prior, text: 'part one' },
{ folded: partOneLanded, text: 'part one' }, // caught up — hide
// Part two is not an extension of part one: new segment, new baseline.
{ folded: partOneLanded, text: 'part' }
])
expect(results).toEqual([null, 'part one', null, 'part'])
})
it('falls back to suppress-on-prefix when text arrives on the first tick', () => {
// No tail ever observed before the text: a duplicate bubble is worse than
// briefly hiding a mount-coincident repeated reply.
const landed = [assistant('a1', 'flushed part still streaming in status')]
const { results } = run([{ folded: landed, text: 'flushed part' }])
expect(results).toEqual([null])
})
it('is idempotent for a repeated tick', () => {
const prior = [assistant('a1', 'The tests pass.')]
const first = run([{ folded: prior }, { folded: prior, text: 'The tests' }])
const again = deriveMobileNativeChatStreaming(first.gate, prior, 'The tests')
expect(again.streaming).toBe('The tests')
expect(again.gate).toBe(first.gate)
})
it('drops a prior chat baseline when the stream identity changes', () => {
// The other chat's tail must not license showing a bubble here — a swapped
// scope resets to the mid-stream fallback rather than reusing its baseline.
const repeatedId = [assistant('a1', 'new answer landed')]
let gate = createMobileNativeChatStreamingGate('tab-a')
gate = deriveMobileNativeChatStreaming(gate, repeatedId, undefined, { scopeKey: 'tab-a' }).gate
const switched = deriveMobileNativeChatStreaming(gate, repeatedId, 'new answer', {
scopeKey: 'tab-b'
})
expect(switched.streaming).toBeNull()
expect(switched.gate.scopeKey).toBe('tab-b')
expect(switched.gate.baselineTailId).toBeNull()
})
it('returns null for empty or whitespace streaming text', () => {
const prior = [assistant('a1', 'x')]
expect(run([{ folded: prior, text: ' ' }]).results).toEqual([null])
expect(run([{ folded: prior }]).results).toEqual([null])
})
it('shows the first reply of an empty chat and hides it once the turn lands', () => {
const landed = [assistant('a1', 'Hello there')]
const { results } = run([
{ folded: [] },
{ folded: [], text: 'Hello' },
{ folded: landed, text: 'Hello' }
])
expect(results).toEqual([null, 'Hello', null])
})
})
@@ -0,0 +1,93 @@
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
/** Decides whether the live streaming preview should render as a synthetic
* bubble. Text alone can't tell "the transcript caught up with this stream"
* from "a new reply happens to repeat the previous turn's prefix" — the old
* prefix test swallowed genuine repeated-prefix replies. The gate keeps the
* transcript tail observed when the current stream segment began: the bubble
* hides only when the tail MOVED during the segment and leads with the
* streamed text (the real turn landed), never for an older identical turn. */
export type MobileNativeChatStreamingGate = {
/** Chat/session identity this baseline belongs to. */
scopeKey: string | null
/** Streamed text seen on the previous tick ('' while idle). */
prevText: string
/** Folded tail message id when the current segment began; null while the
* gate has never observed a transcript tail (text arrived on its very first
* tick), where the legacy suppress-on-prefix rule applies. */
baselineTailId: string | null
}
export function createMobileNativeChatStreamingGate(
scopeKey: string | null = null
): MobileNativeChatStreamingGate {
return { scopeKey, prevText: '', baselineTailId: null }
}
function assistantTailText(tail: NativeChatMessage | undefined): string {
if (!tail || tail.role !== 'assistant') {
return ''
}
return tail.blocks
.filter((block) => block.type === 'text')
.map((block) => (block.type === 'text' ? block.text : ''))
.join('')
.trim()
}
// Reuses the incoming gate object when nothing moved, so a caller can detect
// "no change" by reference (and a render-time state adjustment can settle).
function advanceGate(
gate: MobileNativeChatStreamingGate,
prevText: string,
baselineTailId: string | null
): MobileNativeChatStreamingGate {
return gate.prevText === prevText && gate.baselineTailId === baselineTailId
? gate
: { ...gate, prevText, baselineTailId }
}
/** Advance the gate one tick and derive the visible streaming text (null hides
* the bubble). Pure and idempotent for a repeated (text, tail) pair, so a
* re-render without new data cannot flip the decision. */
export function deriveMobileNativeChatStreaming(
gate: MobileNativeChatStreamingGate,
folded: readonly NativeChatMessage[],
streamingText: string | undefined,
options: {
scopeKey?: string | null
/** Whether the agent is still mid-turn. A textless tick then means "no
* observation this render", not "the stream ended". */
streamLive?: boolean
} = {}
): { gate: MobileNativeChatStreamingGate; streaming: string | null } {
const scopeKey = options.scopeKey === undefined ? gate.scopeKey : options.scopeKey
const scopedGate =
gate.scopeKey === scopeKey ? gate : createMobileNativeChatStreamingGate(scopeKey)
const text = streamingText?.trim() ?? ''
const tail = folded.at(-1)
const tailId = tail?.id ?? null
if (!text) {
// Only a textless tick that carries a real tail and is outside a live turn
// is trustworthy pre-stream history. Mid-turn gaps (a tool call, a throttle
// lull, the transcript landing the reply before its status text) would
// otherwise adopt that reply as history and render it a second time as a
// bubble; a torn-down transcript carries no tail at all. The exception is a
// gate that has never anchored — mounted mid-turn, the first real tail it
// sees is the best pre-stream history it will ever get.
const canAnchor = tailId !== null && (!options.streamLive || scopedGate.baselineTailId === null)
return { gate: canAnchor ? advanceGate(scopedGate, '', tailId) : scopedGate, streaming: null }
}
// A stream that is not an extension of the previous tick is a new segment
// (next reply part); re-anchor to the tail that predates it.
const segmentStart = scopedGate.prevText !== '' && !text.startsWith(scopedGate.prevText)
const baselineTailId = segmentStart ? tailId : scopedGate.baselineTailId
const tailLeadsWithStream = assistantTailText(tail).startsWith(text)
// A null baseline (text on the very first tick, no tail ever seen) is unequal
// to every real tail id, so this degrades to the legacy suppress-on-prefix rule.
const caughtUp = tailLeadsWithStream && tailId !== baselineTailId
return {
gate: advanceGate(scopedGate, text, baselineTailId),
streaming: caughtUp ? null : text
}
}
@@ -0,0 +1,20 @@
import { afterEach, describe, expect, it } from 'vitest'
import {
acquireMobileNativeChatTerminalWrite,
releaseMobileNativeChatTerminalWrite,
resetMobileNativeChatTerminalWritesForTests
} from './mobile-native-chat-terminal-write-lock'
describe('mobile-native-chat-terminal-write-lock', () => {
afterEach(resetMobileNativeChatTerminalWritesForTests)
it('allows composed writes on different terminals to proceed concurrently', () => {
expect(acquireMobileNativeChatTerminalWrite('terminal-a')).toBe(true)
expect(acquireMobileNativeChatTerminalWrite('terminal-b')).toBe(true)
expect(acquireMobileNativeChatTerminalWrite('terminal-a')).toBe(false)
releaseMobileNativeChatTerminalWrite('terminal-a')
releaseMobileNativeChatTerminalWrite('terminal-b')
})
})
@@ -0,0 +1,25 @@
// Serializes composed native-chat write sequences (clear/paste/settle/submit,
// paced answer keystrokes) per HOST terminal. Two concurrent sequences into one
// PTY interleave their bytes; a second sender must be rejected up front, not
// woven in. Module scope for the same reason as the stale-input marker: the
// terminal outlives any one screen, and independent hooks share the same PTY.
const writeInFlightTerminals = new Set<string>()
/** Claim the terminal for one composed write sequence. False = another
* sequence is mid-flight; the caller must reject its send. */
export function acquireMobileNativeChatTerminalWrite(terminal: string): boolean {
if (writeInFlightTerminals.has(terminal)) {
return false
}
writeInFlightTerminals.add(terminal)
return true
}
export function releaseMobileNativeChatTerminalWrite(terminal: string): void {
writeInFlightTerminals.delete(terminal)
}
/** Test-only: module scope outlives a single test's hooks. */
export function resetMobileNativeChatTerminalWritesForTests(): void {
writeInFlightTerminals.clear()
}
@@ -141,12 +141,35 @@ export class MobileTerminalDiagnostics {
})
}
streamResubscribing(handle: string, seq: number, dims: { cols: number; rows: number }): void {
streamResubscribing(
handle: string,
seq: number,
dims: { cols: number; rows: number },
attempt: number,
delayMs: number
): void {
logMobileTerminalDiagnostic('stream-resubscribe-for-viewport', {
handle: shortenMobileTerminalDiagnosticId(handle),
seq,
cols: dims.cols,
rows: dims.rows
rows: dims.rows,
attempt,
delayMs
})
}
streamResubscribeHeld(handle: string, seq: number): void {
logMobileTerminalDiagnostic('stream-resubscribe-held-absent-dims', {
handle: shortenMobileTerminalDiagnosticId(handle),
seq
})
}
streamResubscribeExhausted(handle: string, seq: number, attempts: number): void {
logMobileTerminalDiagnostic('stream-resubscribe-exhausted', {
handle: shortenMobileTerminalDiagnosticId(handle),
seq,
attempts
})
}
@@ -0,0 +1,472 @@
import { describe, expect, it, vi } from 'vitest'
import {
MAX_TERMINAL_VIEWPORT_RESUBSCRIBE_ATTEMPTS,
TerminalViewportResubscribeBudget,
readTerminalViewportDims,
resolveTerminalViewportResubscribe,
runTerminalViewportFitPass,
shouldResubscribeAfterViewportMeasure,
type TerminalViewportFitPassArgs
} from './mobile-terminal-viewport-resubscribe'
const PHONE = { cols: 40, rows: 50 }
describe('readTerminalViewportDims', () => {
it('accepts only usable numeric host dimensions', () => {
expect(readTerminalViewportDims({ cols: 40, rows: 50 })).toEqual({
hostCols: 40,
hostRows: 50
})
expect(readTerminalViewportDims({ cols: Number.NaN, rows: 0 })).toEqual({
hostCols: null,
hostRows: null
})
})
})
describe('resolveTerminalViewportResubscribe', () => {
it('resubscribes immediately on the first pass when the viewport is unmeasured', () => {
expect(
resolveTerminalViewportResubscribe({
hostCols: 80,
hostRows: 24,
viewportMeasured: false,
viewport: null,
attempts: 0
})
).toEqual({ kind: 'resubscribe', delayMs: 0 })
})
it('caps even the unmeasured pass once the budget is spent', () => {
expect(
resolveTerminalViewportResubscribe({
hostCols: 80,
hostRows: 24,
viewportMeasured: false,
viewport: null,
attempts: MAX_TERMINAL_VIEWPORT_RESUBSCRIBE_ATTEMPTS
})
).toEqual({ kind: 'exhausted' })
})
it('holds on absent host dims instead of resubscribing (STA-3337 regression)', () => {
for (const [hostCols, hostRows] of [
[null, null],
[80, null],
[null, 24]
] as const) {
expect(
resolveTerminalViewportResubscribe({
hostCols,
hostRows,
viewportMeasured: true,
viewport: PHONE,
attempts: 0
})
).toEqual({ kind: 'hold' })
}
})
it('keeps holding on absent dims across repeated frames without spending budget', () => {
for (let frame = 0; frame < 50; frame += 1) {
expect(
resolveTerminalViewportResubscribe({
hostCols: null,
hostRows: null,
viewportMeasured: true,
viewport: PHONE,
attempts: 0
}).kind
).toBe('hold')
}
})
it('converges when host dims match the measured viewport', () => {
expect(
resolveTerminalViewportResubscribe({
hostCols: PHONE.cols,
hostRows: PHONE.rows,
viewportMeasured: true,
viewport: PHONE,
attempts: 2
})
).toEqual({ kind: 'converged' })
})
it('backs off across mismatch attempts and then exhausts', () => {
const delays = [0, 1, 2].map((attempts) => {
const decision = resolveTerminalViewportResubscribe({
hostCols: 80,
hostRows: 24,
viewportMeasured: true,
viewport: PHONE,
attempts
})
if (decision.kind !== 'resubscribe') {
throw new Error(`expected resubscribe at attempt ${attempts}, got ${decision.kind}`)
}
return decision.delayMs
})
expect(delays[0]).toBe(0)
expect(delays[1]).toBeGreaterThan(0)
expect(delays[2]).toBeGreaterThan(delays[1])
expect(
resolveTerminalViewportResubscribe({
hostCols: 80,
hostRows: 24,
viewportMeasured: true,
viewport: PHONE,
attempts: MAX_TERMINAL_VIEWPORT_RESUBSCRIBE_ATTEMPTS
})
).toEqual({ kind: 'exhausted' })
})
})
describe('shouldResubscribeAfterViewportMeasure', () => {
it('always resubscribes when the viewport was never measured (server must learn it)', () => {
expect(
shouldResubscribeAfterViewportMeasure({
hostCols: PHONE.cols,
hostRows: PHONE.rows,
measured: PHONE,
viewportWasMeasured: false
})
).toBe(true)
})
it('skips the resubscribe when the fresh measure already matches the host', () => {
expect(
shouldResubscribeAfterViewportMeasure({
hostCols: PHONE.cols,
hostRows: PHONE.rows,
measured: PHONE,
viewportWasMeasured: true
})
).toBe(false)
})
it('resubscribes when the host still disagrees with the fresh measure', () => {
expect(
shouldResubscribeAfterViewportMeasure({
hostCols: 80,
hostRows: 24,
measured: PHONE,
viewportWasMeasured: true
})
).toBe(true)
})
})
describe('TerminalViewportResubscribeBudget', () => {
const exhaust = (budget: TerminalViewportResubscribeBudget, handle: string) => {
for (let i = 0; i < MAX_TERMINAL_VIEWPORT_RESUBSCRIBE_ATTEMPTS; i += 1) {
budget.chargeAttempt(handle)
}
}
it('starts at zero and counts charged attempts per handle', () => {
const budget = new TerminalViewportResubscribeBudget()
expect(budget.attempts('t1')).toBe(0)
budget.chargeAttempt('t1')
budget.chargeAttempt('t1')
expect(budget.attempts('t1')).toBe(2)
expect(budget.attempts('t2')).toBe(0)
})
it('resets attempts and re-arms the announcement on convergence', () => {
const budget = new TerminalViewportResubscribeBudget()
exhaust(budget, 't1')
expect(budget.shouldAnnounceExhaustion('t1')).toBe(true)
budget.markConverged('t1')
expect(budget.attempts('t1')).toBe(0)
expect(budget.shouldAnnounceExhaustion('t1')).toBe(true)
})
it('announces exhaustion exactly once', () => {
const budget = new TerminalViewportResubscribeBudget()
exhaust(budget, 't1')
expect(budget.shouldAnnounceExhaustion('t1')).toBe(true)
expect(budget.shouldAnnounceExhaustion('t1')).toBe(false)
})
it('does not refill an exhausted handle that stayed listed', () => {
const budget = new TerminalViewportResubscribeBudget()
exhaust(budget, 't1')
for (let refresh = 0; refresh < 5; refresh += 1) {
budget.notifyListedHandles(new Set(['t1']))
}
expect(budget.attempts('t1')).toBe(MAX_TERMINAL_VIEWPORT_RESUBSCRIBE_ATTEMPTS)
})
it('refills only after the exhausted handle went absent and came back', () => {
const budget = new TerminalViewportResubscribeBudget()
exhaust(budget, 't1')
expect(budget.shouldAnnounceExhaustion('t1')).toBe(true)
budget.notifyListedHandles(new Set())
// Still exhausted while absent — the refill lands on the return.
expect(budget.attempts('t1')).toBe(MAX_TERMINAL_VIEWPORT_RESUBSCRIBE_ATTEMPTS)
budget.notifyListedHandles(new Set(['t1']))
expect(budget.attempts('t1')).toBe(0)
expect(budget.shouldAnnounceExhaustion('t1')).toBe(true)
})
it('leaves below-cap handles untouched by list refreshes', () => {
const budget = new TerminalViewportResubscribeBudget()
budget.chargeAttempt('t1')
budget.notifyListedHandles(new Set())
budget.notifyListedHandles(new Set(['t1']))
expect(budget.attempts('t1')).toBe(1)
})
it('forget clears one handle, clear clears everything', () => {
const budget = new TerminalViewportResubscribeBudget()
exhaust(budget, 't1')
exhaust(budget, 't2')
budget.forget('t1')
expect(budget.attempts('t1')).toBe(0)
expect(budget.attempts('t2')).toBe(MAX_TERMINAL_VIEWPORT_RESUBSCRIBE_ATTEMPTS)
budget.clear()
expect(budget.attempts('t2')).toBe(0)
})
})
describe('runTerminalViewportFitPass', () => {
const HANDLE = 't1'
function makeHarness(overrides: {
hostCols?: number | null
hostRows?: number | null
viewportMeasured?: boolean
viewport?: { cols: number; rows: number } | null
measured?: { cols: number; rows: number } | null
budget?: TerminalViewportResubscribeBudget
}) {
const budget = overrides.budget ?? new TerminalViewportResubscribeBudget()
const diagnostics = {
streamResubscribing: vi.fn(),
streamResubscribeHeld: vi.fn(),
streamResubscribeExhausted: vi.fn()
}
const terminalUnsubsRef = { current: new Map<string, () => void>([[HANDLE, () => {}]]) }
const scheduled: { fn: () => void; ms: number }[] = []
const webView = {
awaitReady: () => Promise.resolve(),
measureFitDimensions: () => Promise.resolve(overrides.measured ?? PHONE)
}
const unsubscribeTerminal = vi.fn((handle: string) => {
terminalUnsubsRef.current.delete(handle)
})
// Mimic the real subscribe path: arming registers an unsubscribe handle.
const subscribeToTerminal = vi.fn((handle: string) => {
terminalUnsubsRef.current.set(handle, () => {})
})
const showToast = vi.fn()
const args: TerminalViewportFitPassArgs = {
handle: HANDLE,
seq: 1,
hostCols: overrides.hostCols ?? null,
hostRows: overrides.hostRows ?? null,
budget,
diagnostics,
viewportRef: { current: overrides.viewport ?? null },
viewportMeasuredRef: { current: overrides.viewportMeasured ?? false },
subscribeSeqRef: { current: new Map([[HANDLE, 1]]) },
initializedHandlesRef: { current: new Set([HANDLE]) },
terminalUnsubsRef,
terminalFrameHeightRef: { current: 0 },
getTerminalRef: () => webView,
unsubscribeTerminal,
subscribeToTerminal,
scheduleDelayedAction: (fn, ms) => scheduled.push({ fn, ms }),
showToast
}
return {
args,
budget,
diagnostics,
scheduled,
subscribeToTerminal,
unsubscribeTerminal,
showToast
}
}
const settle = () => new Promise((resolve) => setTimeout(resolve, 0))
it('holds without any teardown when host dims are absent (STA-3337 regression)', async () => {
const h = makeHarness({ viewportMeasured: true, viewport: PHONE })
runTerminalViewportFitPass(h.args)
await settle()
expect(h.unsubscribeTerminal).not.toHaveBeenCalled()
expect(h.subscribeToTerminal).not.toHaveBeenCalled()
expect(h.diagnostics.streamResubscribeHeld).toHaveBeenCalledTimes(1)
})
it('measures and resubscribes immediately on the first pass, charging one attempt', async () => {
const h = makeHarness({ hostCols: 80, hostRows: 24 })
runTerminalViewportFitPass(h.args)
await settle()
expect(h.unsubscribeTerminal).toHaveBeenCalledTimes(1)
expect(h.subscribeToTerminal).toHaveBeenCalledTimes(1)
expect(h.args.viewportRef.current).toEqual(PHONE)
expect(h.args.viewportMeasuredRef.current).toBe(true)
expect(h.budget.attempts(HANDLE)).toBe(1)
})
it('treats an equal fresh measure as convergence instead of resubscribing', async () => {
const budget = new TerminalViewportResubscribeBudget()
budget.chargeAttempt(HANDLE)
// Stale cached viewport disagrees with the host, but the fresh measure matches it.
const h = makeHarness({
hostCols: PHONE.cols,
hostRows: PHONE.rows,
viewportMeasured: true,
viewport: { cols: 30, rows: 20 },
measured: PHONE,
budget
})
runTerminalViewportFitPass(h.args)
await settle()
expect(h.subscribeToTerminal).not.toHaveBeenCalled()
expect(h.budget.attempts(HANDLE)).toBe(0)
})
it('defers later attempts through the backoff scheduler while keeping the stream up', async () => {
const budget = new TerminalViewportResubscribeBudget()
budget.chargeAttempt(HANDLE)
const h = makeHarness({
hostCols: 80,
hostRows: 24,
viewportMeasured: true,
viewport: PHONE,
budget
})
runTerminalViewportFitPass(h.args)
await settle()
expect(h.scheduled).toHaveLength(1)
expect(h.scheduled[0].ms).toBeGreaterThan(0)
// The stream must still be up until the deferred retry fires.
expect(h.unsubscribeTerminal).not.toHaveBeenCalled()
h.scheduled[0].fn()
expect(h.unsubscribeTerminal).toHaveBeenCalledTimes(1)
expect(h.subscribeToTerminal).toHaveBeenCalledTimes(1)
expect(h.budget.attempts(HANDLE)).toBe(2)
})
it('drops a deferred retry whose subscribe generation went stale', async () => {
const budget = new TerminalViewportResubscribeBudget()
budget.chargeAttempt(HANDLE)
const h = makeHarness({
hostCols: 80,
hostRows: 24,
viewportMeasured: true,
viewport: PHONE,
budget
})
runTerminalViewportFitPass(h.args)
await settle()
expect(h.scheduled).toHaveLength(1)
h.args.subscribeSeqRef.current.set(HANDLE, 2)
h.scheduled[0].fn()
expect(h.unsubscribeTerminal).not.toHaveBeenCalled()
expect(h.budget.attempts(HANDLE)).toBe(1)
})
it('drops a deferred retry after the live stream converges', async () => {
const budget = new TerminalViewportResubscribeBudget()
budget.chargeAttempt(HANDLE)
const h = makeHarness({
hostCols: 80,
hostRows: 24,
viewportMeasured: true,
viewport: PHONE,
budget
})
runTerminalViewportFitPass(h.args)
await settle()
expect(h.scheduled).toHaveLength(1)
expect(h.budget.observeResize(HANDLE, PHONE, PHONE)).toEqual([PHONE.cols, PHONE.rows])
h.scheduled[0].fn()
expect(h.unsubscribeTerminal).not.toHaveBeenCalled()
expect(h.subscribeToTerminal).not.toHaveBeenCalled()
expect(h.budget.attempts(HANDLE)).toBe(0)
})
it('announces exhaustion once and stops touching the stream', async () => {
const budget = new TerminalViewportResubscribeBudget()
for (let i = 0; i < MAX_TERMINAL_VIEWPORT_RESUBSCRIBE_ATTEMPTS; i += 1) {
budget.chargeAttempt(HANDLE)
}
const h = makeHarness({
hostCols: 80,
hostRows: 24,
viewportMeasured: true,
viewport: PHONE,
budget
})
runTerminalViewportFitPass(h.args)
runTerminalViewportFitPass(h.args)
await settle()
expect(h.unsubscribeTerminal).not.toHaveBeenCalled()
expect(h.subscribeToTerminal).not.toHaveBeenCalled()
expect(h.showToast).toHaveBeenCalledTimes(1)
expect(h.diagnostics.streamResubscribeExhausted).toHaveBeenCalledTimes(2)
})
})
describe('STA-3337 stream shapes', () => {
it('empty scrollback with absent dims settles after a single register pass', () => {
const budget = new TerminalViewportResubscribeBudget()
// Pass 1: no viewport yet — measure and resubscribe so the server learns it.
const first = resolveTerminalViewportResubscribe({
hostCols: null,
hostRows: null,
viewportMeasured: false,
viewport: null,
attempts: budget.attempts('t1')
})
expect(first).toEqual({ kind: 'resubscribe', delayMs: 0 })
budget.chargeAttempt('t1')
// Pass 2+: host still reports no dims — the stream must be left alone.
for (let frame = 0; frame < 10; frame += 1) {
expect(
resolveTerminalViewportResubscribe({
hostCols: null,
hostRows: null,
viewportMeasured: true,
viewport: PHONE,
attempts: budget.attempts('t1')
}).kind
).toBe('hold')
}
expect(budget.attempts('t1')).toBe(1)
})
it('non-converging numeric dims degrade after the bounded backoff run', () => {
const budget = new TerminalViewportResubscribeBudget()
const kinds: string[] = []
for (let frame = 0; frame < 6; frame += 1) {
const decision = resolveTerminalViewportResubscribe({
hostCols: 80,
hostRows: 24,
viewportMeasured: frame > 0,
viewport: frame > 0 ? PHONE : null,
attempts: budget.attempts('t1')
})
kinds.push(decision.kind)
if (decision.kind === 'resubscribe') {
budget.chargeAttempt('t1')
}
}
expect(kinds).toEqual([
'resubscribe',
'resubscribe',
'resubscribe',
'exhausted',
'exhausted',
'exhausted'
])
expect(budget.shouldAnnounceExhaustion('t1')).toBe(true)
expect(budget.shouldAnnounceExhaustion('t1')).toBe(false)
})
})
@@ -0,0 +1,293 @@
/** Bounds the scrollback→measure→resubscribe fit loop (STA-3337): a host whose
* frame dims can never equal the phone viewport must not re-arm the stream
* forever — it broke gesture recognition and drained battery at ~25 cycles/s. */
import type { MobileTerminalDiagnostics } from './mobile-terminal-diagnostics'
export const MAX_TERMINAL_VIEWPORT_RESUBSCRIBE_ATTEMPTS = 3
/** Attempt-indexed teardown delay. Attempt 0 is the ordinary first fit pass
* (server learns the viewport) and must stay immediate; later attempts mean
* the server answered with non-matching dims, so probe at a decaying rate. */
const TERMINAL_VIEWPORT_RESUBSCRIBE_BACKOFF_MS = [0, 750, 3000] as const
export type TerminalViewportDims = { readonly cols: number; readonly rows: number }
function readPositiveDimension(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null
}
export function readTerminalViewportDims(data: Readonly<Record<string, unknown>>): {
readonly hostCols: number | null
readonly hostRows: number | null
} {
return {
hostCols: readPositiveDimension(data.cols),
hostRows: readPositiveDimension(data.rows)
}
}
export type TerminalViewportResubscribeDecision =
| { readonly kind: 'resubscribe'; readonly delayMs: number }
| { readonly kind: 'converged' }
| { readonly kind: 'hold' }
| { readonly kind: 'exhausted' }
function resubscribeDelayMs(attempts: number): number {
return TERMINAL_VIEWPORT_RESUBSCRIBE_BACKOFF_MS[
Math.min(attempts, TERMINAL_VIEWPORT_RESUBSCRIBE_BACKOFF_MS.length - 1)
]
}
export function resolveTerminalViewportResubscribe(args: {
hostCols: number | null
hostRows: number | null
viewportMeasured: boolean
viewport: TerminalViewportDims | null
attempts: number
}): TerminalViewportResubscribeDecision {
const overBudget = args.attempts >= MAX_TERMINAL_VIEWPORT_RESUBSCRIBE_ATTEMPTS
// First subscribe carries no viewport; resubscribing is how the server learns it.
if (!args.viewportMeasured || args.viewport == null) {
return overBudget ? { kind: 'exhausted' } : { kind: 'resubscribe', delayMs: 0 }
}
// Why: a host that doesn't report PTY dims can never converge — resubscribing
// replays the identical frame, so keep the stream instead of probing it.
if (args.hostCols == null || args.hostRows == null) {
return { kind: 'hold' }
}
if (args.hostCols === args.viewport.cols && args.hostRows === args.viewport.rows) {
return { kind: 'converged' }
}
return overBudget
? { kind: 'exhausted' }
: { kind: 'resubscribe', delayMs: resubscribeDelayMs(args.attempts) }
}
/** Post-measure re-check: the pre-measure mismatch may have been a stale cached
* viewport. Resubscribing is only productive when the server still disagrees
* with the fresh measure, or was never told the viewport at all. */
export function shouldResubscribeAfterViewportMeasure(args: {
hostCols: number | null
hostRows: number | null
measured: TerminalViewportDims
viewportWasMeasured: boolean
}): boolean {
if (!args.viewportWasMeasured) {
return true
}
return args.hostCols !== args.measured.cols || args.hostRows !== args.measured.rows
}
/** Per-handle resubscribe budget, mirroring the chat-side rearm bound: attempts
* refill only when the handle actually left terminal.list and came back. A
* still-listed non-converging handle re-funded on every list refresh would undo
* the bound this class exists to enforce. */
export class TerminalViewportResubscribeBudget {
private readonly attemptsByHandle = new Map<string, number>()
private readonly absentSinceExhaustion = new Set<string>()
private readonly announcedExhaustion = new Set<string>()
private readonly retryGenerationByHandle = new Map<string, object>()
attempts(handle: string): number {
return this.attemptsByHandle.get(handle) ?? 0
}
chargeAttempt(handle: string): void {
this.attemptsByHandle.set(handle, this.attempts(handle) + 1)
}
retryGeneration(handle: string): object {
const existing = this.retryGenerationByHandle.get(handle)
if (existing) {
return existing
}
const generation = {}
this.retryGenerationByHandle.set(handle, generation)
return generation
}
isRetryGenerationCurrent(handle: string, generation: object): boolean {
return this.retryGenerationByHandle.get(handle) === generation
}
observeResize(
handle: string,
data: Readonly<Record<string, unknown>>,
viewport: TerminalViewportDims | null
): readonly [number, number] {
const { hostCols, hostRows } = readTerminalViewportDims(data)
const cols = hostCols ?? 80
const rows = hostRows ?? 24
if (viewport?.cols === cols && viewport.rows === rows) {
this.markConverged(handle)
}
return [cols, rows]
}
markConverged(handle: string): void {
this.forget(handle)
}
/** True exactly once per exhaustion so the degraded state is announced, not spammed. */
shouldAnnounceExhaustion(handle: string): boolean {
if (this.announcedExhaustion.has(handle)) {
return false
}
this.announcedExhaustion.add(handle)
return true
}
notifyListedHandles(liveHandles: ReadonlySet<string>): void {
for (const handle of Array.from(this.attemptsByHandle.keys())) {
if (this.attempts(handle) < MAX_TERMINAL_VIEWPORT_RESUBSCRIBE_ATTEMPTS) {
continue
}
if (!liveHandles.has(handle)) {
this.absentSinceExhaustion.add(handle)
continue
}
// Why: only an absence marker buys a refill — the handle's PTY may be live
// again, so a fresh budget (and a fresh degrade announcement) is warranted.
if (this.absentSinceExhaustion.delete(handle)) {
this.forget(handle)
}
}
}
forget(handle: string): void {
this.attemptsByHandle.delete(handle)
this.absentSinceExhaustion.delete(handle)
this.announcedExhaustion.delete(handle)
this.retryGenerationByHandle.delete(handle)
}
clear(): void {
this.attemptsByHandle.clear()
this.absentSinceExhaustion.clear()
this.announcedExhaustion.clear()
this.retryGenerationByHandle.clear()
}
}
type MutableRef<T> = { current: T }
type TerminalFitWebView = {
awaitReady: () => Promise<unknown>
measureFitDimensions: (frameHeight?: number) => Promise<TerminalViewportDims | null | undefined>
}
export type TerminalViewportFitPassArgs = {
handle: string
seq: number
hostCols: number | null
hostRows: number | null
budget: TerminalViewportResubscribeBudget
diagnostics: Pick<
MobileTerminalDiagnostics,
'streamResubscribing' | 'streamResubscribeHeld' | 'streamResubscribeExhausted'
>
viewportRef: MutableRef<TerminalViewportDims | null>
viewportMeasuredRef: MutableRef<boolean>
subscribeSeqRef: MutableRef<Map<string, number>>
initializedHandlesRef: MutableRef<Set<string>>
terminalUnsubsRef: MutableRef<Map<string, () => void>>
terminalFrameHeightRef: MutableRef<number>
getTerminalRef: (handle: string | null) => TerminalFitWebView | undefined
unsubscribeTerminal: (handle: string) => void
subscribeToTerminal: (handle: string) => void
scheduleDelayedAction: (fn: () => void, ms: number) => void
showToast: (message: string, durationMs?: number) => void
}
/** One bounded fit pass per scrollback frame: converge, hold, degrade visibly,
* or measure and resubscribe (backing off) so the server can phone-fit. */
export function runTerminalViewportFitPass(args: TerminalViewportFitPassArgs): void {
const { handle, seq, hostCols, hostRows, budget, diagnostics } = args
const retryGeneration = budget.retryGeneration(handle)
const decision = resolveTerminalViewportResubscribe({
hostCols,
hostRows,
viewportMeasured: args.viewportMeasuredRef.current,
viewport: args.viewportRef.current,
attempts: budget.attempts(handle)
})
if (decision.kind === 'converged') {
budget.markConverged(handle)
return
}
if (decision.kind === 'hold') {
diagnostics.streamResubscribeHeld(handle, seq)
return
}
if (decision.kind === 'exhausted') {
diagnostics.streamResubscribeExhausted(handle, seq, budget.attempts(handle))
if (budget.shouldAnnounceExhaustion(handle)) {
args.showToast("Couldn't fit the terminal to this screen", 4000)
}
return
}
const viewportWasMeasured = args.viewportMeasuredRef.current
void (async () => {
// Why: wait for init()'s rAF chain before measuring, else the measure races ahead and returns null (log dump 2026-05-06).
await args.getTerminalRef(handle)?.awaitReady()
if (
args.subscribeSeqRef.current.get(handle) !== seq ||
!budget.isRetryGenerationCurrent(handle, retryGeneration)
) {
return
}
const dims = await args
.getTerminalRef(handle)
?.measureFitDimensions(args.terminalFrameHeightRef.current || undefined)
// Why: re-check seq — the awaits may have let a newer subscribe cycle arm; tearing it down would resubscribe a stale generation.
if (
args.subscribeSeqRef.current.get(handle) !== seq ||
!budget.isRetryGenerationCurrent(handle, retryGeneration)
) {
return
}
if (!args.getTerminalRef(handle) || !dims) {
return
}
args.viewportRef.current = dims
args.viewportMeasuredRef.current = true
if (
!shouldResubscribeAfterViewportMeasure({
hostCols,
hostRows,
measured: dims,
viewportWasMeasured
})
) {
// Why: the pre-measure mismatch was a stale cached viewport; the server already agrees.
budget.markConverged(handle)
return
}
const resubscribe = (): void => {
if (
args.subscribeSeqRef.current.get(handle) !== seq ||
!budget.isRetryGenerationCurrent(handle, retryGeneration)
) {
return
}
if (!args.getTerminalRef(handle)) {
return
}
diagnostics.streamResubscribing(handle, seq, dims, budget.attempts(handle), decision.delayMs)
args.unsubscribeTerminal(handle)
args.initializedHandlesRef.current.delete(handle)
args.subscribeToTerminal(handle)
// Why: only a resubscribe that actually armed spends budget; one turned away by its own gates never reached the host.
if (args.terminalUnsubsRef.current.has(handle)) {
budget.chargeAttempt(handle)
}
}
if (decision.delayMs > 0) {
// Why: keep the live stream up through the backoff so input keeps flowing; teardown happens only when the retry fires.
args.scheduleDelayedAction(resubscribe, decision.delayMs)
} else {
resubscribe()
}
})()
}
@@ -0,0 +1,134 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useMobileFileTapHandlers } from './use-mobile-file-tap-handlers'
const push = vi.fn()
vi.mock('expo-router', () => ({ useRouter: () => ({ push }) }))
vi.mock('../platform/haptics', () => ({ triggerSelection: vi.fn() }))
type Handlers = ReturnType<typeof useMobileFileTapHandlers>
function ok(result: unknown) {
return { ok: true, result, _meta: { runtimeId: 'runtime-1' } }
}
describe('useMobileFileTapHandlers', () => {
let renderer: ReactTestRenderer | null = null
let handlers: Handlers | null = null
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
push.mockClear()
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
handlers = null
})
function createOptions(sendRequest: ReturnType<typeof vi.fn>) {
return {
client: { sendRequest },
hostId: 'host-1',
worktreeId: 'wt-1',
worktreeName: 'Orca',
activeHandleRef: { current: 'terminal-1' as string | null },
terminalCwdRef: { current: new Map([['terminal-1', '/repo/sub']]) },
openBrowser: vi.fn(),
fetchSessionTabs: vi.fn(async () => {}),
getSessionTabs: () => [],
getActiveSessionTabId: () => null,
getActiveSessionTabType: () => 'terminal',
switchSessionTab: vi.fn(),
scheduleDelayedAction: vi.fn(),
reportChatTapFailure: vi.fn()
}
}
function Harness({ options }: { options: ReturnType<typeof createOptions> }): null {
handlers = useMobileFileTapHandlers(options)
return null
}
it('keeps handler identities stable across rerenders', () => {
const options = createOptions(vi.fn())
act(() => {
renderer = create(createElement(Harness, { options }))
})
const first = handlers
act(() => {
renderer!.update(createElement(Harness, { options: { ...options } }))
})
expect(handlers!.handleFileTap).toBe(first!.handleFileTap)
expect(handlers!.handleNativeChatFileTap).toBe(first!.handleNativeChatFileTap)
})
it('dispatches through the latest options after a rerender', () => {
const firstSendRequest = vi.fn()
const firstOptions = createOptions(firstSendRequest)
act(() => {
renderer = create(createElement(Harness, { options: firstOptions }))
})
const latestSendRequest = vi.fn(async () => ok({ exists: false, isDirectory: false }))
act(() => {
renderer!.update(
createElement(Harness, {
options: { ...firstOptions, client: { sendRequest: latestSendRequest } }
})
)
})
handlers!.handleFileTap('terminal-1', 'index.ts', null, null)
expect(firstSendRequest).not.toHaveBeenCalled()
expect(latestSendRequest).toHaveBeenCalledTimes(1)
})
it('resolves terminal taps with the terminal handle and cwd', async () => {
const sendRequest = vi.fn(async () => ok({ exists: false, isDirectory: false }))
act(() => {
renderer = create(createElement(Harness, { options: createOptions(sendRequest) }))
})
handlers!.handleFileTap('terminal-1', 'index.ts', null, null)
await act(async () => {})
expect(sendRequest).toHaveBeenCalledWith(
'files.resolveTerminalPath',
{ worktree: 'id:wt-1', pathText: 'index.ts', terminal: 'terminal-1', cwd: '/repo/sub' },
{ timeoutMs: 10_000 }
)
})
it('ignores terminal taps from a non-active handle', () => {
const sendRequest = vi.fn()
act(() => {
renderer = create(createElement(Harness, { options: createOptions(sendRequest) }))
})
handlers!.handleFileTap('terminal-2', 'index.ts', null, null)
expect(sendRequest).not.toHaveBeenCalled()
})
it('resolves chat taps against the worktree root and reports a miss', async () => {
const sendRequest = vi.fn(async () => ok({ exists: false, isDirectory: false }))
const options = createOptions(sendRequest)
act(() => {
renderer = create(createElement(Harness, { options }))
})
handlers!.handleNativeChatFileTap('mobile/src/x.ts:12')
await act(async () => {})
expect(sendRequest).toHaveBeenCalledWith(
'files.resolveTerminalPath',
{ worktree: 'id:wt-1', pathText: 'mobile/src/x.ts' },
{ timeoutMs: 10_000 }
)
expect(options.reportChatTapFailure).toHaveBeenCalledWith("Couldn't open mobile/src/x.ts:12")
})
})
@@ -0,0 +1,174 @@
import { useCallback, useLayoutEffect, useRef, type MutableRefObject } from 'react'
import { useRouter } from 'expo-router'
import { triggerSelection } from '../platform/haptics'
import type { RpcClient } from '../transport/rpc-client'
import { openMobileFileTap, type FileTapSessionTab } from './mobile-file-tap-open'
import { openMobileNativeChatFileTap } from './mobile-native-chat-open-file'
type MobileFileTapHandlerOptions<T extends FileTapSessionTab> = {
client: Pick<RpcClient, 'sendRequest'> | null
hostId: string
worktreeId: string
worktreeName?: string
activeHandleRef: MutableRefObject<string | null>
terminalCwdRef: MutableRefObject<Map<string, string>>
openBrowser: (url: string) => void
fetchSessionTabs: () => Promise<void>
getSessionTabs: () => readonly T[]
getActiveSessionTabId: () => string | null
getActiveSessionTabType: () => string | null
switchSessionTab: (tab: T) => void
scheduleDelayedAction: (callback: () => void, delayMs: number) => unknown
reportChatTapFailure: (message: string) => void
}
/**
* Tap-to-open handlers for file references, shared by the terminal (link taps
* with the terminal's cwd) and native chat (worktree-root-relative paths, with
* failure feedback). Handlers are identity-stable and read the latest options at
* dispatch time; the shared activation seq lets a newer tap on either surface
* supersede an in-flight one.
*/
export function useMobileFileTapHandlers<T extends FileTapSessionTab>(
options: MobileFileTapHandlerOptions<T>
): {
handleFileTap: (
handle: string,
pathText: string,
line: number | null,
column: number | null
) => void
handleNativeChatFileTap: (pathText: string) => void
} {
const {
activeHandleRef,
client,
fetchSessionTabs,
getActiveSessionTabId,
getActiveSessionTabType,
getSessionTabs,
hostId,
openBrowser,
scheduleDelayedAction,
reportChatTapFailure,
switchSessionTab,
terminalCwdRef,
worktreeId,
worktreeName
} = options
const router = useRouter()
const routerRef = useRef(router)
const optionsRef = useRef(options)
const activationSeqRef = useRef(0)
useLayoutEffect(() => {
routerRef.current = router
optionsRef.current = {
activeHandleRef,
client,
fetchSessionTabs,
getActiveSessionTabId,
getActiveSessionTabType,
getSessionTabs,
hostId,
openBrowser,
scheduleDelayedAction,
reportChatTapFailure,
switchSessionTab,
terminalCwdRef,
worktreeId,
worktreeName
}
}, [
activeHandleRef,
client,
fetchSessionTabs,
getActiveSessionTabId,
getActiveSessionTabType,
getSessionTabs,
hostId,
openBrowser,
router,
scheduleDelayedAction,
reportChatTapFailure,
switchSessionTab,
terminalCwdRef,
worktreeId,
worktreeName
])
const handleFileTap = useCallback(
(handle: string, pathText: string, line: number | null, column: number | null) => {
const current = optionsRef.current
if (handle !== current.activeHandleRef.current || !current.client) {
return
}
const activationSeq = ++activationSeqRef.current
openMobileFileTap<T>({
client: current.client,
hostId: current.hostId,
worktreeId: current.worktreeId,
worktreeName: current.worktreeName,
terminalHandle: handle,
pathText,
cwd: current.terminalCwdRef.current.get(handle) ?? null,
line,
column,
pushPreviewRoute: (href) => routerRef.current.push(href),
openBrowser: current.openBrowser,
triggerOpenFeedback: triggerSelection,
fetchSessionTabs: current.fetchSessionTabs,
getSessionTabs: current.getSessionTabs,
getActiveSessionTabId: current.getActiveSessionTabId,
getActivationState: (activated) => ({
activated,
activationSeq,
latestActivationSeq: activationSeqRef.current,
sourceTerminalHandle: handle,
activeTerminalHandle: current.activeHandleRef.current,
activeTabType: current.getActiveSessionTabType()
}),
switchSessionTab: current.switchSessionTab,
scheduleDelayedAction: current.scheduleDelayedAction
})
},
[]
)
const handleNativeChatFileTap = useCallback((pathText: string) => {
const current = optionsRef.current
// The chat overlay rides on its backing terminal tab; that handle anchors
// the activation gate even though resolution ignores the terminal's cwd.
const sourceTerminalHandle = current.activeHandleRef.current
if (!current.client || !sourceTerminalHandle) {
return
}
const activationSeq = ++activationSeqRef.current
openMobileNativeChatFileTap<T>({
client: current.client,
hostId: current.hostId,
worktreeId: current.worktreeId,
worktreeName: current.worktreeName,
pathText,
pushPreviewRoute: (href) => routerRef.current.push(href),
openBrowser: current.openBrowser,
triggerOpenFeedback: triggerSelection,
fetchSessionTabs: current.fetchSessionTabs,
getSessionTabs: current.getSessionTabs,
getActiveSessionTabId: current.getActiveSessionTabId,
getActivationState: (activated) => ({
activated,
activationSeq,
latestActivationSeq: activationSeqRef.current,
sourceTerminalHandle,
activeTerminalHandle: current.activeHandleRef.current,
activeTabType: current.getActiveSessionTabType()
}),
switchSessionTab: current.switchSessionTab,
scheduleDelayedAction: current.scheduleDelayedAction,
onOpenFailed: () => current.reportChatTapFailure(`Couldn't open ${pathText}`)
})
}, [])
return { handleFileTap, handleNativeChatFileTap }
}
@@ -11,7 +11,13 @@ import {
markMobileNativeChatInputStale,
resetMobileNativeChatStaleInputForTests
} from './mobile-native-chat-stale-input'
import {
acquireMobileNativeChatTerminalWrite,
releaseMobileNativeChatTerminalWrite,
resetMobileNativeChatTerminalWritesForTests
} from './mobile-native-chat-terminal-write-lock'
import { useMobileNativeChatAnswerSend } from './use-mobile-native-chat-answer-send'
import { useNativeChatAcceptedAction } from './use-native-chat-action-outcomes'
type AnswerSend = ReturnType<typeof useMobileNativeChatAnswerSend>
@@ -40,17 +46,24 @@ describe('useMobileNativeChatAnswerSend', () => {
let mountedClient: RpcClient | null = null
let mountedOnSendError: ((message: string) => void) | null = null
let mountedAgent: AgentType = 'claude'
// The route sends through useNativeChatAcceptedAction, whose accepted callback
// retires the shared send-error banner (use-mobile-native-chat-controller.ts).
let acceptedAnswerAsk: AnswerSend['answerAsk'] | null = null
let onAccepted = vi.fn()
beforeEach(() => {
onAccepted = vi.fn()
vi.useFakeTimers()
globalThis.IS_REACT_ACT_ENVIRONMENT = true
resetMobileNativeChatStaleInputForTests()
resetMobileNativeChatTerminalWritesForTests()
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
answerSend = null
acceptedAnswerAsk = null
mountedClient = null
mountedOnSendError = null
mountedAgent = 'claude'
@@ -68,6 +81,7 @@ describe('useMobileNativeChatAnswerSend', () => {
streamIdentity: 'host\0worktree\0tab\0session',
onSendError: mountedOnSendError!
})
acceptedAnswerAsk = useNativeChatAcceptedAction(answerSend.answerAsk, onAccepted)
return null
}
@@ -396,4 +410,517 @@ describe('useMobileNativeChatAnswerSend', () => {
await expect(result).resolves.toBe(false)
expect(sendRequest).toHaveBeenCalledTimes(1)
})
it('rejects an answer while another composed write holds the terminal', async () => {
const onSendError = vi.fn()
const sendRequest = vi.fn().mockResolvedValue(acceptedResponse())
await mount({ sendRequest } as unknown as RpcClient, onSendError)
// An image paste sequence is mid-flight into the same PTY.
expect(acquireMobileNativeChatTerminalWrite('terminal')).toBe(true)
await expect(answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])).resolves.toBe(false)
expect(sendRequest).not.toHaveBeenCalled()
expect(onSendError).toHaveBeenCalledWith('Answer not sent')
// Once that sequence releases, answers flow again.
releaseMobileNativeChatTerminalWrite('terminal')
await expect(answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])).resolves.toBe(true)
})
it('answers again on the same handle after an earlier answer already landed', async () => {
const onSendError = vi.fn()
const sendRequest = vi.fn().mockResolvedValue(acceptedResponse())
await mount({ sendRequest } as unknown as RpcClient, onSendError)
await expect(answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [0] }])).resolves.toBe(true)
// A landed answer resolves its turn FALSE — correct for a queued successor,
// fatal if the turn outlives the chain. Leaving it parked in the slot fences
// every later answer on this handle for the life of the hook, not just an
// overlapping one, so the ask card dies after its first use.
await expect(answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])).resolves.toBe(true)
expect(sendRequest).toHaveBeenCalledTimes(2)
expect(onSendError).not.toHaveBeenCalled()
})
it('fences a superseding answer after the cancelled chain moved the selector', async () => {
const sendRequest = vi.fn().mockResolvedValue(acceptedResponse())
await mount({ sendRequest } as unknown as RpcClient, vi.fn())
const prompt: AskPrompt = {
questions: [
{ question: 'q1', multiSelect: false, options: [{ label: 'A' }, { label: 'B' }] },
{ question: 'q2', multiSelect: false, options: [{ label: 'C' }, { label: 'D' }] }
]
}
let first: Promise<boolean> | undefined
let second: Promise<boolean> | undefined
await act(async () => {
first = answerSend?.answerAsk(prompt, [{ indices: [0] }, { indices: [0] }])
})
// The first digit already advanced Claude to q2. Replaying a from-q1 key
// plan now would answer the wrong question.
await act(async () => {
second = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])
})
await act(async () => vi.runAllTimersAsync())
await expect(first).resolves.toBe(false)
await expect(second).resolves.toBe(false)
expect(sendRequest).toHaveBeenCalledTimes(1)
// Both chains unwound: the terminal is free for the next composed write.
expect(acquireMobileNativeChatTerminalWrite('terminal')).toBe(true)
releaseMobileNativeChatTerminalWrite('terminal')
})
it('does not write a successor after the prior in-flight key is accepted', async () => {
let resolveFirst: (response: unknown) => void = () => undefined
const sendRequest = vi
.fn()
.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveFirst = resolve
})
)
.mockResolvedValue(acceptedResponse())
await mount({ sendRequest } as unknown as RpcClient, vi.fn())
const prompt: AskPrompt = {
questions: [
{ question: 'q1', multiSelect: false, options: [{ label: 'A' }, { label: 'B' }] },
{ question: 'q2', multiSelect: false, options: [{ label: 'C' }, { label: 'D' }] }
]
}
let first: Promise<boolean> | undefined
let second: Promise<boolean> | undefined
await act(async () => {
first = answerSend?.answerAsk(prompt, [{ indices: [0] }, { indices: [0] }])
await Promise.resolve()
})
await act(async () => {
second = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])
await Promise.resolve()
})
// The successor is queued behind the in-flight key, not racing it.
expect(sendRequest).toHaveBeenCalledTimes(1)
await act(async () => {
resolveFirst(acceptedResponse())
await Promise.resolve()
})
await expect(first).resolves.toBe(false)
await expect(second).resolves.toBe(false)
expect(sendRequest).toHaveBeenCalledTimes(1)
})
it('lets a queued successor continue after the prior key is definitely rejected', async () => {
let resolveFirst: (response: unknown) => void = () => undefined
const sendRequest = vi
.fn()
.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveFirst = resolve
})
)
.mockResolvedValue(acceptedResponse())
await mount({ sendRequest } as unknown as RpcClient, vi.fn())
let first: Promise<boolean> | undefined
let second: Promise<boolean> | undefined
await act(async () => {
first = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [0] }])
await Promise.resolve()
})
await act(async () => {
second = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])
await Promise.resolve()
})
await act(async () => {
resolveFirst({
...acceptedResponse(),
result: { send: { accepted: false } }
})
await Promise.resolve()
})
// Nothing landed, so the selector never moved — the successor is safe.
await expect(first).resolves.toBe(false)
await expect(second).resolves.toBe(true)
expect(sendRequest).toHaveBeenCalledTimes(2)
})
it('does not write a successor after the prior delivery becomes ambiguous', async () => {
let rejectFirst: (error: Error) => void = () => undefined
const sendRequest = vi
.fn()
.mockImplementationOnce(
() =>
new Promise((_resolve, reject) => {
rejectFirst = reject
})
)
.mockResolvedValue(acceptedResponse())
await mount({ sendRequest } as unknown as RpcClient, vi.fn())
let first: Promise<boolean> | undefined
let second: Promise<boolean> | undefined
await act(async () => {
first = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [0] }])
await Promise.resolve()
})
await act(async () => {
second = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])
await Promise.resolve()
})
await act(async () => {
rejectFirst(markRpcDeliveryUnknown(new Error('Connection closed')))
await Promise.resolve()
})
// The key may have landed; a blind successor could double-step the selector.
await expect(first).resolves.toBe(false)
await expect(second).resolves.toBe(false)
expect(sendRequest).toHaveBeenCalledTimes(1)
})
it('tells the user when a queued answer is fenced instead of dropping it silently', async () => {
const onSendError = vi.fn()
let rejectFirst: (error: Error) => void = () => undefined
const sendRequest = vi
.fn()
.mockImplementationOnce(
() =>
new Promise((_resolve, reject) => {
rejectFirst = reject
})
)
.mockResolvedValue(acceptedResponse())
await mount({ sendRequest } as unknown as RpcClient, onSendError)
let first: Promise<boolean> | undefined
let second: Promise<boolean> | undefined
await act(async () => {
first = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [0] }])
await Promise.resolve()
})
await act(async () => {
second = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])
await Promise.resolve()
})
await act(async () => {
rejectFirst(markRpcDeliveryUnknown(new Error('Connection closed')))
await Promise.resolve()
})
await expect(first).resolves.toBe(false)
await expect(second).resolves.toBe(false)
// The card re-enables on a false result, so an unreported fence looks exactly
// like a dead button. The superseded chain stays quiet — the newest answer
// owns the error surface, and it is the one the user is waiting on.
expect(onSendError).toHaveBeenCalledTimes(1)
expect(onSendError).toHaveBeenCalledWith('Answer not sent — check chat before retrying')
})
it('tells the user when a dropped lease fences a queued answer whose predecessor landed', async () => {
const onSendError = vi.fn()
const settle: Array<(response: unknown) => void> = []
const sendRequest = vi.fn().mockImplementation(
() =>
new Promise((resolve) => {
settle.push(resolve)
})
)
await mount({ sendRequest } as unknown as RpcClient, onSendError)
let first: Promise<boolean> | undefined
let second: Promise<boolean> | undefined
await act(async () => {
first = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])
await Promise.resolve()
})
await act(async () => {
second = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [0] }])
await Promise.resolve()
})
// A transport blip, not a user cancel: unlike Stop and ask-cancel, the lost
// input lease writes no Escape, so the card stays up with Submit re-enabled.
await setEnabled(false)
await act(async () => {
settle[0]!(acceptedResponse())
await Promise.resolve()
})
expect(sendRequest).toHaveBeenCalledTimes(1)
await expect(first).resolves.toBe(false)
await expect(second).resolves.toBe(false)
// The first option key LANDED, so the live selector already moved. Reporting
// nothing invites a retry that double-steps it.
expect(onSendError).toHaveBeenCalledTimes(1)
expect(onSendError).toHaveBeenCalledWith('Answer not sent — check chat before retrying')
})
it('keeps the terminal locked while a queued successor writes', async () => {
const settle: Array<(response: unknown) => void> = []
const sendRequest = vi.fn().mockImplementation(
() =>
new Promise((resolve) => {
settle.push(resolve)
})
)
await mount({ sendRequest } as unknown as RpcClient, vi.fn())
let first: Promise<boolean> | undefined
let second: Promise<boolean> | undefined
await act(async () => {
first = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [0] }])
await Promise.resolve()
})
await act(async () => {
second = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])
await Promise.resolve()
})
expect(sendRequest).toHaveBeenCalledTimes(1)
// Nothing landed, so the successor is cleared to write.
await act(async () => {
settle[0]!({ ...acceptedResponse(), result: { send: { accepted: false } } })
await Promise.resolve()
})
expect(sendRequest).toHaveBeenCalledTimes(2)
// The successor's key is on the wire. The superseded chain unwinding behind it
// must NOT free the terminal, or an image paste interleaves into this sequence.
expect(acquireMobileNativeChatTerminalWrite('terminal')).toBe(false)
await act(async () => {
settle[1]!(acceptedResponse())
await Promise.resolve()
})
await expect(first).resolves.toBe(false)
await expect(second).resolves.toBe(true)
expect(acquireMobileNativeChatTerminalWrite('terminal')).toBe(true)
releaseMobileNativeChatTerminalWrite('terminal')
})
it('does not retire the fence banner when the superseded answer lands', async () => {
const onSendError = vi.fn()
const settle: Array<(response: unknown) => void> = []
const sendRequest = vi.fn().mockImplementation(
() =>
new Promise((resolve) => {
settle.push(resolve)
})
)
await mount({ sendRequest } as unknown as RpcClient, onSendError)
let first: Promise<boolean> | undefined
let second: Promise<boolean> | undefined
await act(async () => {
first = acceptedAnswerAsk?.(TABS_OR_SPACES, [{ indices: [0] }])
await Promise.resolve()
})
await act(async () => {
second = acceptedAnswerAsk?.(TABS_OR_SPACES, [{ indices: [1] }])
await Promise.resolve()
})
// The healthy path: the first answer LANDS, which is also the case that fences
// hardest — its key moved the live selector.
await act(async () => {
settle[0]!(acceptedResponse())
await Promise.resolve()
})
await expect(first).resolves.toBe(false)
await expect(second).resolves.toBe(false)
expect(onSendError).toHaveBeenCalledWith('Answer not sent — check chat before retrying')
// A superseded chain reporting success would clear the banner it just raised —
// the accepted hook runs after the fence, so the user would see nothing at all.
expect(onAccepted).not.toHaveBeenCalled()
expect(sendRequest).toHaveBeenCalledTimes(1)
})
it('does not retire the fence banner when a superseded pasted answer lands', async () => {
const onSendError = vi.fn()
const settle: Array<(response: unknown) => void> = []
const sendRequest = vi.fn().mockImplementation(
() =>
new Promise((resolve) => {
settle.push(resolve)
})
)
await mount({ sendRequest } as unknown as RpcClient, onSendError, 'grok')
let first: Promise<boolean> | undefined
let second: Promise<boolean> | undefined
await act(async () => {
first = acceptedAnswerAsk?.(TABS_OR_SPACES, [{ indices: [0] }])
await Promise.resolve()
})
await act(async () => {
second = acceptedAnswerAsk?.(TABS_OR_SPACES, [{ indices: [1] }])
await Promise.resolve()
})
await act(async () => {
settle[0]!(acceptedResponse())
await Promise.resolve()
})
// The pasted shape commits with Enter, so a superseded chain is doubly unsafe
// to report as accepted — the answer it committed is not the one on screen.
await expect(first).resolves.toBe(false)
await expect(second).resolves.toBe(false)
expect(onAccepted).not.toHaveBeenCalled()
expect(onSendError).toHaveBeenCalledWith('Answer not sent — check chat before retrying')
})
it('reports a landed answer that Stop cancelled with no successor waiting', async () => {
const onSendError = vi.fn()
const settle: Array<(response: unknown) => void> = []
const sendRequest = vi.fn().mockImplementation(
() =>
new Promise((resolve) => {
settle.push(resolve)
})
)
await mount({ sendRequest } as unknown as RpcClient, onSendError)
let first: Promise<boolean> | undefined
await act(async () => {
first = acceptedAnswerAsk?.(TABS_OR_SPACES, [{ indices: [1] }])
await Promise.resolve()
})
// Stop, an ask cancel, and a dropped input lease all bump the generation with
// NO successor chain behind them.
await act(async () => {
answerSend?.cancelPending()
settle[0]!(acceptedResponse())
await Promise.resolve()
})
// The key is on the PTY and nobody else owns the surface. Calling that a
// non-send leaves the card up and silent (fail() never runs on an accepted
// write), and the retry double-steps the selector this key already moved.
await expect(first).resolves.toBe(true)
expect(onAccepted).toHaveBeenCalledTimes(1)
expect(onSendError).not.toHaveBeenCalled()
})
it('reports a landed pasted answer that Stop cancelled with no successor', async () => {
const onSendError = vi.fn()
const settle: Array<(response: unknown) => void> = []
const sendRequest = vi.fn().mockImplementation(
() =>
new Promise((resolve) => {
settle.push(resolve)
})
)
await mount({ sendRequest } as unknown as RpcClient, onSendError, 'grok')
let first: Promise<boolean> | undefined
await act(async () => {
first = acceptedAnswerAsk?.(TABS_OR_SPACES, [{ indices: [1] }])
await Promise.resolve()
})
await act(async () => {
answerSend?.cancelPending()
settle[0]!(acceptedResponse())
await Promise.resolve()
})
// The pasted shape already committed with Enter, so suppressing the success
// strands an answered card the user is invited to submit a second time.
await expect(first).resolves.toBe(true)
expect(onAccepted).toHaveBeenCalledTimes(1)
expect(onSendError).not.toHaveBeenCalled()
})
it('fences a third answer behind an already-fenced successor, reporting once', async () => {
const onSendError = vi.fn()
const settle: Array<(response: unknown) => void> = []
const sendRequest = vi.fn().mockImplementation(
() =>
new Promise((resolve) => {
settle.push(resolve)
})
)
await mount({ sendRequest } as unknown as RpcClient, onSendError)
let first: Promise<boolean> | undefined
let second: Promise<boolean> | undefined
let third: Promise<boolean> | undefined
await act(async () => {
first = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [0] }])
await Promise.resolve()
})
await act(async () => {
second = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])
await Promise.resolve()
})
await act(async () => {
third = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [0] }])
await Promise.resolve()
})
expect(sendRequest).toHaveBeenCalledTimes(1)
await act(async () => {
settle[0]!(acceptedResponse())
await Promise.resolve()
})
await expect(first).resolves.toBe(false)
await expect(second).resolves.toBe(false)
await expect(third).resolves.toBe(false)
// The middle chain sent nothing, so only the verdict it INHERITED can stop the
// third from replaying a from-scratch plan onto the advanced selector.
expect(sendRequest).toHaveBeenCalledTimes(1)
// Only the newest chain owns the error surface.
expect(onSendError).toHaveBeenCalledTimes(1)
expect(onSendError).toHaveBeenCalledWith('Answer not sent — check chat before retrying')
})
it('queues a late third answer behind the successor already on the wire', async () => {
const settle: Array<(response: unknown) => void> = []
const sendRequest = vi.fn().mockImplementation(
() =>
new Promise((resolve) => {
settle.push(resolve)
})
)
await mount({ sendRequest } as unknown as RpcClient, vi.fn())
let first: Promise<boolean> | undefined
let second: Promise<boolean> | undefined
let third: Promise<boolean> | undefined
await act(async () => {
first = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [0] }])
await Promise.resolve()
})
await act(async () => {
second = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])
await Promise.resolve()
})
// Nothing landed, so the successor is cleared and puts its own key on the wire.
await act(async () => {
settle[0]!({ ...acceptedResponse(), result: { send: { accepted: false } } })
await Promise.resolve()
})
expect(sendRequest).toHaveBeenCalledTimes(2)
await act(async () => {
third = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [0] }])
await Promise.resolve()
await Promise.resolve()
})
// The first chain unwound while the second was mid-write: it must not have
// dropped the second's turn, or this one writes into the same PTY concurrently.
expect(sendRequest).toHaveBeenCalledTimes(2)
await act(async () => {
settle[1]!(acceptedResponse())
await Promise.resolve()
})
await expect(first).resolves.toBe(false)
await expect(second).resolves.toBe(false)
await expect(third).resolves.toBe(false)
expect(sendRequest).toHaveBeenCalledTimes(2)
})
})
@@ -14,6 +14,10 @@ import {
sendMobileNativeChatMessageWithOutcome
} from './mobile-native-chat-send'
import { healMobileNativeChatStaleInput } from './mobile-native-chat-stale-input'
import {
acquireMobileNativeChatTerminalWrite,
releaseMobileNativeChatTerminalWrite
} from './mobile-native-chat-terminal-write-lock'
import {
resolveNativeChatTranscriptAgent,
shouldStepNativeChatAskAnswer
@@ -72,6 +76,12 @@ export function useMobileNativeChatAnswerSend(args: {
const generationRef = useRef(0)
const activeRouteRef = useRef({ client, enabled, sessionId, streamIdentity })
activeRouteRef.current = { client, enabled, sessionId, streamIdentity }
// Per-terminal count of this hook's chains sharing one write-lock hold: a
// superseding answer inherits the cancelled chain's hold (it re-enters before
// the old chain unwinds), and only the last chain out releases the lock.
const writeHoldsRef = useRef(new Map<string, number>())
// Successors wait for the prior RPC and inherit any delivery ambiguity.
const writeTurnsRef = useRef(new Map<string, Promise<boolean>>())
const delaysRef = useRef<
Set<{ timer: ReturnType<typeof setTimeout>; resolve: (completed: boolean) => void }>
>(new Set())
@@ -103,125 +113,191 @@ export function useMobileNativeChatAnswerSend(args: {
if (!hasAskAnswer(prompt, selections)) {
return false
}
// One composed write sequence per terminal: an answer landing mid-flight
// in an image paste (or vice versa) would interleave bytes into the PTY.
// A superseding answer shares the cancelled chain's hold on this terminal
// (that chain has not unwound to its release yet).
const holds = writeHoldsRef.current
const heldCount = holds.get(handle) ?? 0
if (heldCount === 0 && !acquireMobileNativeChatTerminalWrite(handle)) {
onSendError('Answer not sent')
return false
}
holds.set(handle, heldCount + 1)
const previousTurn = writeTurnsRef.current.get(handle) ?? Promise.resolve(true)
let finishTurn: (safeToContinue: boolean) => void = () => undefined
const turn = new Promise<boolean>((resolve) => {
finishTurn = resolve
})
writeTurnsRef.current.set(handle, turn)
// A new answer supersedes any still-pending keystroke writes.
cancelPending()
const generation = generationRef.current
let sawUnknownOutcome = false
let sawAcceptedGroup = false
// One budget for the whole answer instead of a fresh timeout per keystroke
// group, which let an N-group selector hold the card for N × the send timeout.
// It bounds transport time only: each deliberate pacing wait is credited back
// below, so a long multi-question answer still gets a full budget to write in.
let deadline = openMobileNativeChatSendBudget()
const sendTerminal = async (body: string, enter: boolean): Promise<boolean> => {
const activeRoute = activeRouteRef.current
if (
!activeRoute.enabled ||
activeRoute.client !== client ||
activeRoute.sessionId !== sessionId ||
activeRoute.streamIdentity !== streamIdentity ||
handleRef.current !== handle
) {
return false
}
const outcome = await sendMobileNativeChatMessageWithOutcome({
client,
terminal: handle,
text: body,
enter,
deadline,
...(deviceTokenRef.current
? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } }
: {})
})
if (outcome === 'unknown') {
sawUnknownOutcome = true
}
if (outcome === 'accepted') {
sawAcceptedGroup = true
}
return outcome === 'accepted'
}
const wait = (ms: number): Promise<boolean> =>
new Promise((resolve) => {
const delay = {
timer: setTimeout(() => {
delaysRef.current.delete(delay)
resolve(generationRef.current === generation)
}, ms),
resolve
}
delaysRef.current.add(delay)
})
const fail = (): false => {
if (generationRef.current === generation) {
// Why: keystrokes that may have landed (ack lost / path cutover) must
// not read as a definite failure — a blind resend could double-step
// the selector. An earlier group that WAS accepted is the same hazard
// in definite form: a multi-question answer whose shared budget ran out
// mid-sequence left the remote selector half-stepped, and telling the
// user nothing was sent invites a retry on top of the advanced state.
onSendError(
sawAcceptedGroup
? 'Answer partly sent — check chat before retrying'
: sawUnknownOutcome
? 'Answer unconfirmed — check chat before retrying'
: 'Answer not sent'
)
}
return false
}
// Grok commits pasted labels; Claude and Codex need their selector-specific
// keystrokes paced so each step renders before the next lands.
if (!shouldStepNativeChatAskAnswer(agentRef.current)) {
// This shape pastes the label into the composer and commits it, so an
// orphaned image paste would be submitted along with the answer (#10228).
// The selector shapes below deliberately skip the heal: their keys are
// `enter: false` for an active overlay, and a single-select answer is a
// bare option digit that cannot submit the line at all, so clearing there
// would consume the marker still protecting the next real message.
// Desktop splits it identically — use-native-chat-interactive-send.ts
// routes only the pasted-label shape through the clearing sender.
if (
!(await healMobileNativeChatStaleInput({
client,
terminal: handle,
deviceToken: deviceTokenRef.current,
deadline
}))
) {
if (generationRef.current === generation) {
onSendError('Answer not sent')
let predecessorSafe = true
try {
predecessorSafe = await previousTurn
if (!predecessorSafe) {
// Fenced. Report it: the card re-enables on a false result, so silence
// here is indistinguishable from a dead button. "Check chat" rather than
// a bare "not sent" because the PREVIOUS answer's keys may have landed.
// Gate on the turn slot, not the generation: a dropped input lease bumps
// the generation without writing the Escape that Stop and ask-cancel do,
// so the card is still up and silence there strands an advanced selector.
if (writeTurnsRef.current.get(handle) === turn) {
onSendError('Answer not sent — check chat before retrying')
}
return false
}
// Superseded by a newer answer, which owns the error surface from here.
if (generationRef.current !== generation) {
return false
}
return (await sendTerminal(formatAskAnswer(prompt, selections), true)) || fail()
}
const groups =
resolveNativeChatTranscriptAgent(agentRef.current) === 'codex'
? buildCodexAskAnswerKeys(prompt, selections)
: buildAskAnswerKeys(prompt, selections)
for (let index = 0; index < groups.length; index += 1) {
if (generationRef.current !== generation) {
return false
}
const group = groups[index]!
const body = 'raw' in group ? group.raw : sanitizeAskFreeText(group.text)
if (!(await sendTerminal(body, false))) {
return fail()
}
if (index < groups.length - 1) {
if (!(await wait(MOBILE_NATIVE_CHAT_QUESTION_STEP_MS))) {
// One budget for the whole answer instead of a fresh timeout per keystroke
// group, which let an N-group selector hold the card for N × the send timeout.
// It bounds transport time only: each deliberate pacing wait is credited back
// below, so a long multi-question answer still gets a full budget to write in.
let deadline = openMobileNativeChatSendBudget()
const sendTerminal = async (body: string, enter: boolean): Promise<boolean> => {
const activeRoute = activeRouteRef.current
if (
!activeRoute.enabled ||
activeRoute.client !== client ||
activeRoute.sessionId !== sessionId ||
activeRoute.streamIdentity !== streamIdentity ||
handleRef.current !== handle
) {
return false
}
// Pacing is deliberate, not transport latency — don't charge it to the budget.
deadline += MOBILE_NATIVE_CHAT_QUESTION_STEP_MS
const outcome = await sendMobileNativeChatMessageWithOutcome({
client,
terminal: handle,
text: body,
enter,
deadline,
...(deviceTokenRef.current
? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } }
: {})
})
if (outcome === 'unknown') {
sawUnknownOutcome = true
}
if (outcome === 'accepted') {
sawAcceptedGroup = true
}
return outcome === 'accepted'
}
const wait = (ms: number): Promise<boolean> => {
// Already superseded: don't hold the successor for a full pacing step
// waiting on a timer whose only job is to report the cancellation.
if (generationRef.current !== generation) {
return Promise.resolve(false)
}
return new Promise((resolve) => {
const delay = {
timer: setTimeout(() => {
delaysRef.current.delete(delay)
resolve(generationRef.current === generation)
}, ms),
resolve
}
delaysRef.current.add(delay)
})
}
const fail = (): false => {
if (generationRef.current === generation) {
// Why: keystrokes that may have landed (ack lost / path cutover) must
// not read as a definite failure — a blind resend could double-step
// the selector. An earlier group that WAS accepted is the same hazard
// in definite form: a multi-question answer whose shared budget ran out
// mid-sequence left the remote selector half-stepped, and telling the
// user nothing was sent invites a retry on top of the advanced state.
onSendError(
sawAcceptedGroup
? 'Answer partly sent — check chat before retrying'
: sawUnknownOutcome
? 'Answer unconfirmed — check chat before retrying'
: 'Answer not sent'
)
}
return false
}
// Grok commits pasted labels; Claude and Codex need their selector-specific
// keystrokes paced so each step renders before the next lands.
if (!shouldStepNativeChatAskAnswer(agentRef.current)) {
// This shape pastes the label into the composer and commits it, so an
// orphaned image paste would be submitted along with the answer (#10228).
// The selector shapes below deliberately skip the heal: their keys are
// `enter: false` for an active overlay, and a single-select answer is a
// bare option digit that cannot submit the line at all, so clearing there
// would consume the marker still protecting the next real message.
// Desktop splits it identically — use-native-chat-interactive-send.ts
// routes only the pasted-label shape through the clearing sender.
if (
!(await healMobileNativeChatStaleInput({
client,
terminal: handle,
deviceToken: deviceTokenRef.current,
deadline
}))
) {
if (generationRef.current === generation) {
onSendError('Answer not sent')
}
return false
}
if (generationRef.current !== generation) {
return false
}
// A chain a successor took over from must not report success either: an
// accepted answer retires the shared send-error banner, wiping the
// successor's fence. Test the turn slot, not the generation counter —
// Stop, ask-cancel and a dropped lease all bump the generation with no
// successor, and there a landed answer IS a success.
const sent = (await sendTerminal(formatAskAnswer(prompt, selections), true)) || fail()
return sent && writeTurnsRef.current.get(handle) === turn
}
const groups =
resolveNativeChatTranscriptAgent(agentRef.current) === 'codex'
? buildCodexAskAnswerKeys(prompt, selections)
: buildAskAnswerKeys(prompt, selections)
for (let index = 0; index < groups.length; index += 1) {
if (generationRef.current !== generation) {
return false
}
const group = groups[index]!
const body = 'raw' in group ? group.raw : sanitizeAskFreeText(group.text)
if (!(await sendTerminal(body, false))) {
return fail()
}
if (index < groups.length - 1) {
if (!(await wait(MOBILE_NATIVE_CHAT_QUESTION_STEP_MS))) {
return false
}
// Pacing is deliberate, not transport latency — don't charge it to the budget.
deadline += MOBILE_NATIVE_CHAT_QUESTION_STEP_MS
}
}
// Taken over on the last key: same as above, the successor owns the surface.
return groups.length > 0 && writeTurnsRef.current.get(handle) === turn
} finally {
// Any accepted key changed the live selector, so a queued replacement
// cannot safely apply its from-scratch key plan to that new position.
finishTurn(predecessorSafe && !sawUnknownOutcome && !sawAcceptedGroup)
if (writeTurnsRef.current.get(handle) === turn) {
writeTurnsRef.current.delete(handle)
}
// Last chain out releases; a superseded chain unwinding late must not
// free the lock out from under the successor sharing its hold.
const remaining = (holds.get(handle) ?? 1) - 1
if (remaining <= 0) {
holds.delete(handle)
releaseMobileNativeChatTerminalWrite(handle)
} else {
holds.set(handle, remaining)
}
}
return groups.length > 0
},
[
agentRef,
@@ -47,8 +47,12 @@ vi.mock('./use-mobile-native-chat-drafts', () => ({
vi.mock('./use-mobile-native-chat-prompts', () => ({
useMobileNativeChatPrompts: () => ({ permission: null, question: null, ask: null })
}))
const answerSendArgs: { streamIdentity?: string }[] = []
vi.mock('./use-mobile-native-chat-answer-send', () => ({
useMobileNativeChatAnswerSend: () => ({ answerAsk: vi.fn(), cancelPending: vi.fn() })
useMobileNativeChatAnswerSend: (args: { streamIdentity?: string }) => {
answerSendArgs.push(args)
return { answerAsk: vi.fn(), cancelPending: vi.fn() }
}
}))
vi.mock('./mobile-native-chat-permission-send', () => ({
useMobileNativeChatPermissionSend: () => vi.fn()
@@ -421,3 +425,99 @@ describe('useMobileNativeChatController launch-draft wiring', () => {
expect(draftsArgs.at(-1)).toMatchObject({ launchDraft: null, chatActive: true })
})
})
describe('useMobileNativeChatController streaming scope', () => {
let renderer: ReactTestRenderer | null = null
let controller: MobileNativeChatController | null = null
const clientStub = { sendRequest: vi.fn() }
const workingTab = {
type: 'terminal',
id: 'tab-1',
terminal: 'term-1',
launchAgent: 'claude',
agentStatus: {
state: 'working',
agentType: 'claude',
providerSession: { id: 'session-1' }
},
isActive: true
}
function Harness(): null {
controller = useMobileNativeChatController({
client: clientStub as unknown as RpcClient,
connState: 'connected',
hostId: 'h',
worktreeId: 'w',
activeSessionTab: workingTab as never,
activeSessionTabId: 'tab-1',
activeHandleRef: { current: 'term-1' },
deviceTokenRef: { current: null },
nativeChatTranscriptIsLocalReadable: true,
nativeChatInputLeaseReady: true,
onSendError: vi.fn(),
onSendResolved: vi.fn()
})
return null
}
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
viewMode.isTabChatView = () => true
const original = console.error
const spy = vi.spyOn(console, 'error').mockImplementation((...a) => {
if (typeof a[0] === 'string' && a[0].includes('react-test-renderer is deprecated')) {
return
}
original(...a)
})
try {
act(() => {
renderer = create(createElement(Harness))
})
} finally {
spy.mockRestore()
}
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
controller = null
viewMode.isTabChatView = () => true
})
it('holds the stream scope and liveness while the user peeks at the terminal', () => {
// Both feed the streaming gate, which lives above the chat view's mount and
// must not be reset by a view toggle.
expect(controller?.showNativeChat).toBe(true)
const scopeKey = controller?.nativeChatStreamScopeKey
expect(scopeKey).toContain('session-1')
expect(controller?.nativeChatStreamLive).toBe(true)
viewMode.isTabChatView = () => false
act(() => renderer?.update(createElement(Harness)))
expect(controller?.showNativeChat).toBe(false)
expect(controller?.nativeChatAgentWorking).toBe(false)
expect(controller?.nativeChatStreamScopeKey).toBe(scopeKey)
expect(controller?.nativeChatStreamLive).toBe(true)
})
it('keeps the delayed-send route guard view-gated, unlike the stream scope', () => {
// `streamIdentity` fences a delayed answer/stop to the route it was armed
// on, so it must still drop its session when chat closes — the scope key is
// the one that has to survive the toggle. Same string while chat is open.
const before = answerSendArgs.at(-1)?.streamIdentity
expect(before).toBe(controller?.nativeChatStreamScopeKey)
viewMode.isTabChatView = () => false
act(() => renderer?.update(createElement(Harness)))
const after = answerSendArgs.at(-1)?.streamIdentity
expect(after).not.toBe(before)
expect(after).not.toContain('session-1')
expect(controller?.nativeChatStreamScopeKey).toBe(before)
})
})
@@ -17,7 +17,6 @@ import {
import { type MobileNativeChatTab, resolveMobileNativeChat } from './mobile-native-chat-eligibility'
import { detectAgentPermission } from './mobile-native-chat-permission'
import { parseAgentQuestion } from './mobile-native-chat-question'
import { openMobileNativeChatFile } from './mobile-native-chat-open-file'
import { useMobileNativeChatPermissionSend } from './mobile-native-chat-permission-send'
import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send'
import { useMobileNativeChatAnswerSend } from './use-mobile-native-chat-answer-send'
@@ -53,10 +52,13 @@ export type MobileNativeChatController = {
nativeChatSession: ReturnType<typeof useMobileNativeChatSession>
nativeChatAgentWorking: boolean
nativeChatStreamingText?: string
/** Agent mid-turn, regardless of whether chat is the visible view. */
nativeChatStreamLive: boolean
/** Host/workspace/tab/session scope for stateful streaming suppression. */
nativeChatStreamScopeKey: string
nativeChatPermission: ReturnType<typeof detectAgentPermission>
nativeChatQuestion: ReturnType<typeof parseAgentQuestion>
nativeChatAsk: ReturnType<typeof parseAskFromStatus>
handleNativeChatOpenFile: (relativePath: string) => void
handleNativeChatAnswerAsk: (
prompt: AskPrompt,
selections: AskAnswerSelection[]
@@ -134,7 +136,12 @@ export function useMobileNativeChatController(args: {
}, [activeChatAgent, showNativeChat])
const activeChatSessionId = activeChatResolution?.sessionId ?? null
const streamIdentity = `${hostId}\0${worktreeId}\0${activeSessionTabId ?? ''}\0${activeChatSessionId ?? ''}\0${activeHandleRef.current ?? ''}`
const routeKey = `${hostId}\0${worktreeId}\0${activeSessionTabId ?? ''}`
const streamIdentity = `${routeKey}\0${activeChatSessionId ?? ''}\0${activeHandleRef.current ?? ''}`
// Same chat, but keyed off the tab rather than the view-gated resolution:
// `streamIdentity` goes session-less the moment the user peeks at the terminal,
// and a scope that flips on a view toggle throws the gate's baseline away.
const streamScopeKey = `${routeKey}\0${activeSessionTab?.agentStatus?.providerSession?.id ?? ''}\0${activeHandleRef.current ?? ''}`
const nativeChatSession = useMobileNativeChatSession({
client,
@@ -170,6 +177,9 @@ export function useMobileNativeChatController(args: {
const nativeChatStatus = activeChatResolution ? activeSessionTab?.agentStatus : null
const nativeChatAgentWorking = nativeChatStatus?.state === 'working'
// Deliberately not gated on the chat view being visible: the streaming gate
// has to tell "hidden mid-turn" from "the turn ended".
const nativeChatStreamLive = activeSessionTab?.agentStatus?.state === 'working'
// Throttle the streaming bubble: OpenCode emits a status frame per streamed
// part, and each one re-renders and re-parses the whole accumulated markdown.
const nativeChatStreamingText = useThrottledLatestValue(
@@ -186,21 +196,6 @@ export function useMobileNativeChatController(args: {
messages: nativeChatSession.messages
})
const handleNativeChatOpenFile = useCallback(
(pathText: string) => {
if (!client) {
return
}
void openMobileNativeChatFile({
client,
worktreeId,
pathText,
terminal: activeHandleRef.current
})
},
[activeHandleRef, client, worktreeId]
)
// Every chat write gates on both: the lease proves the input floor is ours, and
// `connState` collapses a render before the lease does on disconnect.
const inputSendable = nativeChatInputLeaseReady && connState === 'connected'
@@ -310,10 +305,11 @@ export function useMobileNativeChatController(args: {
nativeChatSession,
nativeChatAgentWorking,
nativeChatStreamingText,
nativeChatStreamLive,
nativeChatStreamScopeKey: streamScopeKey,
nativeChatPermission,
nativeChatQuestion,
nativeChatAsk,
handleNativeChatOpenFile,
handleNativeChatAnswerAsk: answerAsk,
handleNativeChatCancelAsk: cancelAsk,
handleNativeChatRespondPermission: respond,
@@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcResponse, RpcSuccess } from '../transport/types'
import { resetMobileNativeChatStaleInputForTests } from './mobile-native-chat-stale-input'
import { resetMobileNativeChatTerminalWritesForTests } from './mobile-native-chat-terminal-write-lock'
import { useMobileNativeChatImageAttachments } from './use-mobile-native-chat-image-attachments'
// Fully stub the picker so the real expo/react-native chain never loads under
@@ -88,9 +89,10 @@ describe('useMobileNativeChatImageAttachments', () => {
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
pick.mockReset()
// Stale markers live at module scope now (they outlive the screen), so they
// also outlive a test.
// Stale markers and write locks live at module scope (they outlive the
// screen), so they also outlive a test.
resetMobileNativeChatStaleInputForTests()
resetMobileNativeChatTerminalWritesForTests()
})
afterEach(() => {
act(() => renderer?.unmount())
@@ -26,6 +26,10 @@ import {
isMobileNativeChatInputStale,
markMobileNativeChatInputStale
} from './mobile-native-chat-stale-input'
import {
acquireMobileNativeChatTerminalWrite,
releaseMobileNativeChatTerminalWrite
} from './mobile-native-chat-terminal-write-lock'
type CurrentRef<T> = { readonly current: T }
type ShowToast = (message: string, durationMs?: number) => void
@@ -127,8 +131,6 @@ export function useMobileNativeChatImageAttachments({
// checked 'connected' at entry, so only a ref can see a mid-upload disconnect.
const connStateRef = useRef(connState)
connStateRef.current = connState
// Serialize clear/paste/submit ownership per terminal while allowing other tabs to send.
const sendInFlightTerminalsRef = useRef(new Set<string>())
const attachments = (scopeKey ? attachmentsByScope[scopeKey] : undefined) ?? NO_ATTACHMENTS
@@ -219,15 +221,15 @@ export function useMobileNativeChatImageAttachments({
const sendNativeChat = useCallback(
async (text: string): Promise<boolean> => {
// Serialize clear/paste/submit ownership per terminal while allowing other
// tabs to send. Shared with the prompt-card writes (answer/permission), so
// a card tap can't interleave into a mid-flight paste sequence either.
const operationTerminal = activeHandleRef.current
if (operationTerminal && sendInFlightTerminalsRef.current.has(operationTerminal)) {
if (operationTerminal && !acquireMobileNativeChatTerminalWrite(operationTerminal)) {
onError?.()
onSendError('Message not sent')
return false
}
if (operationTerminal) {
sendInFlightTerminalsRef.current.add(operationTerminal)
}
// One budget for the whole user action. The paste loop, the settle, and the
// text body that follows are a single send from the composer's point of view;
// opening a budget per leg let `sending` run to twice the stated ceiling.
@@ -347,7 +349,7 @@ export function useMobileNativeChatImageAttachments({
}
} finally {
if (operationTerminal) {
sendInFlightTerminalsRef.current.delete(operationTerminal)
releaseMobileNativeChatTerminalWrite(operationTerminal)
}
}
},
@@ -19,6 +19,11 @@ vi.mock('./mobile-native-chat-stale-input', () => ({
}))
import { useMobileNativeChatMessageSend } from './use-mobile-native-chat-message-send'
import {
acquireMobileNativeChatTerminalWrite,
releaseMobileNativeChatTerminalWrite,
resetMobileNativeChatTerminalWritesForTests
} from './mobile-native-chat-terminal-write-lock'
import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear'
type Send = ReturnType<typeof useMobileNativeChatMessageSend>
@@ -33,6 +38,7 @@ describe('useMobileNativeChatMessageSend', () => {
const onCommandSend = vi.fn()
const commandSendRef = { current: onCommandSend }
const agentRef = { current: null as string | null }
let onSendError = vi.fn()
const mount = (
readSeededLaunchDraftSeed: () => { text: string; createdAt: number | null } | null,
@@ -53,7 +59,7 @@ describe('useMobileNativeChatMessageSend', () => {
restoreRejectedDraft: () => {},
acceptSend,
holdUnconfirmedSend,
onSendError: () => {}
onSendError
})
return null
}
@@ -83,6 +89,8 @@ describe('useMobileNativeChatMessageSend', () => {
holdUnconfirmedSend.mockReset()
onCommandSend.mockReset()
commandSendRef.current = onCommandSend
onSendError = vi.fn()
resetMobileNativeChatTerminalWritesForTests()
})
afterEach(() => {
act(() => {
@@ -297,4 +305,27 @@ describe('useMobileNativeChatMessageSend', () => {
expect(originalRecorder).toHaveBeenCalledWith('/clear')
expect(nextRecorder).not.toHaveBeenCalled()
})
it('rejects a question answer while another composed write holds the terminal', async () => {
mount(() => null)
// An image paste sequence is mid-flight into the same PTY.
expect(acquireMobileNativeChatTerminalWrite('term')).toBe(true)
let result: boolean | undefined
await act(async () => {
result = await api!.answerQuestion('1')
})
expect(result).toBe(false)
expect(sendWithOutcome).not.toHaveBeenCalled()
expect(onSendError).toHaveBeenCalledWith('Answer not sent')
releaseMobileNativeChatTerminalWrite('term')
await act(async () => {
result = await api!.answerQuestion('1')
})
expect(result).toBe(true)
// The answer released its own hold on the way out.
expect(acquireMobileNativeChatTerminalWrite('term')).toBe(true)
releaseMobileNativeChatTerminalWrite('term')
})
})

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