Merge remote-tracking branch 'origin/main' into brennanb2025/fix-paired-host-pr-refresh

This commit is contained in:
Brennan Benson
2026-07-16 15:12:02 -07:00
513 changed files with 41554 additions and 5613 deletions
+8
View File
@@ -15,6 +15,11 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
with:
# Why: the freshness registry is derived from immutable release tags,
# so shallow PR checkouts cannot verify historical official identities.
fetch-depth: 0
persist-credentials: false
- name: Install native build tools
run: sudo apt-get update && sudo apt-get install -y build-essential python3 zlib1g-dev zsh
@@ -82,6 +87,9 @@ jobs:
- name: Verify bundled skill guides
run: pnpm run verify:bundled-skill-guides
- name: Verify skill freshness manifest
run: pnpm run verify:skill-bundle-manifest
# Why: project-owned type declarations must live in .ts so tsc
# actually checks them. TypeScript's skipLibCheck: true (inherited
# from @electron-toolkit/tsconfig) silently widens unresolved names
@@ -0,0 +1,50 @@
name: Skill update round trip
on:
pull_request:
paths:
- 'skills/**'
- 'resources/skills/**'
- 'config/scripts/verify-skill-update-roundtrip.mjs'
- 'src/main/skills/skill-freshness-eligibility.ts'
- 'src/shared/skill-freshness.ts'
- '.github/workflows/skill-update-roundtrip.yml'
merge_group:
push:
branches:
- main
jobs:
roundtrip:
strategy:
fail-fast: false
matrix:
os: [macos-latest, ubuntu-latest, windows-latest]
shape: [symlink, copy]
autocrlf: ['false', 'true']
skills-cli: ['1.5.17']
include:
- os: ubuntu-latest
shape: symlink
autocrlf: 'false'
skills-cli: latest
continue-on-error: ${{ matrix.skills-cli == 'latest' }}
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-node@v6
with:
node-version-file: package.json
- name: Verify targeted update convergence and copy behavior
env:
GITHUB_TOKEN: ${{ github.token }}
SKILL_UPDATE_SOURCE: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
SKILL_UPDATE_REF: ${{ github.head_ref || github.ref_name }}
run: >-
node config/scripts/verify-skill-update-roundtrip.mjs
--cli=${{ matrix.skills-cli }}
--autocrlf=${{ matrix.autocrlf }}
--shape=${{ matrix.shape }}
+2 -3
View File
@@ -238,10 +238,9 @@ Pair with your desktop app to monitor and steer your agents from your phone.
- **Discord:** Join the community on **[Discord](https://discord.gg/fzjDKHxv8Q)**.
- **Twitter / X:** Follow **[@orca_build](https://x.com/orca_build)** for updates and announcements.
- **WeChat:** Scan the QR code to join the community. If the first group is full, use the backup group.
- **WeChat:** First WeChat group is full, now you can join the second one.
<img src="docs/assets/wechat-qr.png" alt="WeChat QR code for the Orca community" width="160" />
<img src="docs/assets/wechat-qr-backup.jpg" alt="Backup WeChat QR code for the Orca community" width="160" />
<img src="docs/assets/wechat-qr.jpg" alt="WeChat QR code for the Orca community" width="160" />
- **Feedback &amp; Ideas:** We ship fast. Missing something? [Request a new feature](https://github.com/stablyai/orca/issues).
- **Privacy:** See the [privacy &amp; telemetry docs](https://www.onorca.dev/docs/telemetry) for what anonymous usage data Orca collects and how to opt out.
+17 -2
View File
@@ -18,6 +18,12 @@ const featureWallResources = {
from: 'resources/onboarding/feature-wall',
to: 'onboarding/feature-wall'
}
// Why: freshness detection needs immutable identity metadata from this exact
// app build, but never needs the skill package bytes or a runtime network read.
const skillFreshnessResources = {
from: 'resources/skills',
to: 'skills'
}
// Why: SSH relay deploy resolves bundles from process.resourcesPath in packaged
// apps. Keeping relay assets as extraResources makes them real directories
// instead of paths hidden inside app.asar.
@@ -31,7 +37,11 @@ const relayExtraResource = {
// do not fall through to a developer checkout's node_modules.
const packagedRuntimeNodeModuleResources = createPackagedRuntimeNodeModuleResources()
const commonExtraResources = [relayExtraResource, ...packagedRuntimeNodeModuleResources]
const commonExtraResources = [
relayExtraResource,
...packagedRuntimeNodeModuleResources,
skillFreshnessResources
]
const macSpeechNativeResource = {
from: 'node_modules/sherpa-onnx-darwin-${arch}',
to: 'node_modules/sherpa-onnx-darwin-${arch}'
@@ -66,6 +76,10 @@ module.exports = {
// authoring sources too would duplicate content without a runtime consumer.
'!skill-guides{,/**/*}',
'!tests{,/**/*}',
// Why: pr-evidence/ is a local e2e screenshot output (ORCA_CAPTURE_EVIDENCE);
// it is gitignored, but exclude it defensively so a stray local capture at
// package time never bloats app.asar.
'!pr-evidence{,/**/*}',
'!Casks{,/**/*}',
'!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.md}',
'!out/**/*.test.js',
@@ -75,7 +89,8 @@ module.exports = {
'!tsconfig.json',
// Why: feature-wall media is copied via extraResources so runtime can read
// it from process.resourcesPath; exclude the source copy from app.asar.
'!resources/onboarding/feature-wall/**'
'!resources/onboarding/feature-wall/**',
'!resources/skills/**'
],
// Why: the CLI entry-point lives in out/cli/ but imports shared modules
// from out/shared/ and local hook mutators from out/main/. These paths must be
+40 -31
View File
@@ -1950,7 +1950,6 @@
"daemon stream",
"main PTY batching",
"runtime path provenance",
"runtime serve-sim detection",
"runtime terminal wait detection",
"renderer ACK",
"xterm scheduler",
@@ -1971,7 +1970,7 @@
"macos"
],
"coveredProviders": [],
"coverageNotes": "Local macOS evidence covers the existing main-process pending-output caps plus deterministic runtime path-provenance history reuse, idle-path serve-sim detection, and saturated-tail wait detection. Daemon stream write(false)/drain contracts, cross-session drain priority, and bounded queued tails arrive with the pending perf slice; live flood/latency artifacts remain gaps.",
"coverageNotes": "Local macOS evidence covers the existing main-process pending-output caps plus deterministic runtime path-provenance history reuse and saturated-tail wait detection. Daemon stream write(false)/drain contracts, cross-session drain priority, and bounded queued tails arrive with the pending perf slice; live flood/latency artifacts remain gaps.",
"motivatingLinks": [
"https://github.com/stablyai/orca/pull/6836",
"https://github.com/stablyai/orca/pull/6858",
@@ -1979,17 +1978,15 @@
"https://github.com/stablyai/orca/pull/7054"
],
"invariant": "High-volume terminal output must stay bounded across daemon socket writes, main runtime metadata, detectors, and tail checks, main-to-renderer in-flight bytes, renderer scheduler queues, and hidden-output restore without starving focused input.",
"oracle": "The current executable slice injects daemon socket backpressure and main-process renderer backlog pressure, then asserts write(false)/drain ordering, bounded queued daemon bytes, unchanged path-provenance history reuse for pathless output, idle serve-sim detection with split-JSON continuity, ordinary terminal-wait detection without a joined-tail allocation and with full prompt-family continuity, per-PTY and total pending-output caps, preserved sequenced-tail metadata, active-pending protection ahead of background trimming, and ACK-gated in-flight bounds. The live Electron perf oracle adds hidden-output floods, renderer scheduler queue depth, dropped-output-zero normal scenarios, and active key latency budgets before promotion.",
"oracle": "The current executable slice injects main-process renderer backlog pressure, then asserts unchanged path-provenance history reuse for pathless output, ordinary terminal-wait detection without a joined-tail allocation and with full prompt-family continuity, per-PTY and total pending-output caps, preserved sequenced-tail metadata, active-pending protection ahead of background trimming, and ACK-gated in-flight bounds. The live Electron perf oracle adds hidden-output floods, renderer scheduler queue depth, dropped-output-zero normal scenarios, and active key latency budgets before promotion.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-path-candidate-history.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/main/emulator/serve-sim-state-watcher.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/orca-runtime-tail-wait-memo.test.ts"
],
"testFiles": [
"src/main/ipc/pty.test.ts",
"src/main/runtime/orca-runtime-path-candidate-history.test.ts",
"src/main/emulator/serve-sim-state-watcher.test.ts",
"src/main/runtime/orca-runtime.test.ts",
"src/main/runtime/orca-runtime-tail-wait-memo.test.ts"
],
@@ -2009,13 +2006,6 @@
"copies history when new output adds a path candidate"
]
},
{
"file": "src/main/emulator/serve-sim-state-watcher.test.ts",
"assertions": [
"does not buffer repeated brace-free PTY output while waiting for metadata",
"detects serve-sim metadata split across PTY chunks and releases the partial object"
]
},
{
"file": "src/main/runtime/orca-runtime-tail-wait-memo.test.ts",
"assertions": [
@@ -2053,15 +2043,6 @@
"durationSeconds": 3.49,
"summary": "1 test file passed, 2 tests passed on the exact rebased tree based on main@dc468f0ded."
},
{
"date": "2026-07-10",
"runner": "local",
"platform": "macos",
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/emulator/serve-sim-state-watcher.test.ts",
"result": "passed",
"durationSeconds": 0.37,
"summary": "1 test file passed, 6 tests passed on pushed commit f0e3a185593b."
},
{
"date": "2026-07-10",
"runner": "local",
@@ -2078,11 +2059,11 @@
},
"flakeHistory": {
"status": "unknown",
"evidence": "Daemon stream backpressure and focused main/renderer backlog tests are deterministic unit slices. The combined perf soak still needs runtime history before promotion."
"evidence": "Focused main-process backlog tests are deterministic unit slices. The combined perf soak still needs runtime history before promotion."
},
"redGreenEvidence": {
"status": "partial",
"evidence": "Tests assert daemon stream writes pause after socket write(false), later stream data queues behind the pressured socket, flush-immediate output from another session is prioritized ahead of unrelated queued background backlog on drain while preserving per-session order, queued lines resume on drain, global cleanup clears clients that only have backpressured writes, queued daemon stream bytes are bounded by keeping priority output and the newest tail under sustained pressure, pathless runtime output reuses unchanged path-provenance history with zero old-candidate byte scans, ordinary output leaves the niche serve-sim detector idle while split JSON still emits and releases its partial buffer, ordinary saturated terminal tails retain no rebuilt wait text or repeated phrase scans while every blocked/ready prompt family remains live, main pending renderer output is capped per PTY and in total, total-pressure trimming prefers background pending output before active pending output, trimmed pending tails preserve seq/rawLength metadata, and ACK-gated in-flight output remains bounded. Intentionally restoring the old path-history shape failed the focused scale test after 774ms and 4,096 replacements; restoring the old serve-sim buffer failed its scale test after 180ms with 4,096 buffer writes; disabling the ordinary-tail branch failed with a retained 253,999-character waitText. The fixed shapes passed their focused suites. Existing renderer tests cover replay/backlog slices. Needs broader hidden-output/input-latency perf artifacts before promotion."
"evidence": "Tests assert pathless runtime output reuses unchanged path-provenance history with zero old-candidate byte scans, ordinary saturated terminal tails retain no rebuilt wait text or repeated phrase scans while every blocked/ready prompt family remains live, main pending renderer output is capped per PTY and in total, total-pressure trimming prefers background pending output before active pending output, trimmed pending tails preserve seq/rawLength metadata, and ACK-gated in-flight output remains bounded. Intentionally restoring the old path-history shape failed the focused scale test after 774ms and 4,096 replacements; disabling the ordinary-tail branch failed with a retained 253,999-character waitText. The fixed shapes passed their focused suites. Existing renderer tests cover replay/backlog slices. Needs daemon-stream contract coverage and broader hidden-output/input-latency perf artifacts before promotion."
},
"performanceBudget": {
"required": true,
@@ -2094,9 +2075,8 @@
"Keep stress variants non-blocking until stable runtime history exists."
],
"knownGaps": [
"Current command asserts daemon stream write(false)/drain/cleanup at the batcher contract layer and main pending renderer caps at the IPC contract layer.",
"Daemon stream write(false)/drain/cleanup and bounded queued-tail contracts are not registered in the current command.",
"Runtime provenance coverage is deterministic and does not include a live high-throughput provider artifact.",
"Serve-sim detector coverage is deterministic and does not include a live external simulator helper.",
"Terminal-wait scale evidence is deterministic and does not yet include a live saturated-tail event-loop artifact.",
"Current command does not prove renderer parse pressure, scheduler queue depth, event-loop delay, or active key latency.",
"Live hidden-output pressure, active input latency, and full Electron perf artifacts remain unproved."
@@ -3896,6 +3876,7 @@
"layer": "runtime-rpc-contract",
"surfaces": [
"mobile terminal",
"native-chat terminal overlay",
"runtime RPC",
"binary multiplex",
"JSON subscribe fallback",
@@ -3917,21 +3898,25 @@
"macos"
],
"coveredProviders": [],
"coverageNotes": "Local macOS evidence over the runtime-RPC stream budgets on main@1282f5c2d. The pending stack adds byte-exact 512KB/2MB/256KB/48KB budget assertions; legacy JSON subscribe parity remains undecided.",
"coverageNotes": "Local macOS evidence over the runtime-RPC stream budgets on main@1282f5c2d. PR #5824 adds a platform-neutral mobile decision gate proving chat-covered terminal streams pause and resume only after the mounted WebView is ready; live Android restore evidence remains required. The pending stack adds byte-exact 512KB/2MB/256KB/48KB budget assertions; legacy JSON subscribe parity remains undecided.",
"motivatingLinks": [
"https://github.com/stablyai/orca/pull/6951",
"https://github.com/stablyai/orca/pull/6955",
"https://github.com/stablyai/orca/pull/7009"
],
"invariant": "Runtime and mobile terminal subscriptions must cap initial snapshots, live output buffered while snapshots load, chunk sizes, and batch sizes, while preserving output order, input locks, resize/driver events, and fallback parity or explicit fallback deprecation.",
"oracle": "The current executable slice asserts mobile initial snapshots downgrade until they fit <=512KB, requested binary snapshots downgrade until they fit <=2MB, binary live output queued while the initial snapshot loads stays <=256KB while preserving the newest tail, large binary output is split into <=48KB frames, output bursts are coalesced before emit, aborts do not register stale listeners, and stale mobile resize re-stream completions are dropped. JSON fallback parity/deprecation remains an explicit gap.",
"invariant": "Runtime and mobile terminal subscriptions must cap initial snapshots, live output buffered while snapshots load, chunk sizes, and batch sizes; a terminal covered by native chat must have no live output subscription and must restore from fresh scrollback when revealed, while preserving output order, input locks, resize/driver events, and fallback parity or explicit fallback deprecation.",
"oracle": "The current executable slice asserts mobile initial snapshots downgrade until they fit <=512KB, requested binary snapshots downgrade until they fit <=2MB, binary live output queued while the initial snapshot loads stays <=256KB while preserving the newest tail, large binary output is split into <=48KB frames, output bursts are coalesced before emit, aborts do not register stale listeners, and stale mobile resize re-stream completions are dropped. The mobile native-chat decision test asserts an active stream pauses while covered and resumes only for a ready active terminal. JSON fallback parity and live Android scrollback restoration remain explicit gaps.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/main/runtime/rpc/terminal-output-batching.test.ts src/main/runtime/rpc/terminal-multiplex.test.ts"
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/main/runtime/rpc/terminal-output-batching.test.ts src/main/runtime/rpc/terminal-multiplex.test.ts",
"pnpm --dir mobile exec vitest run --root .. mobile/src/session/mobile-native-chat-terminal-stream.test.ts",
"pnpm --dir mobile exec vitest run --root .. mobile/src/session/use-mobile-native-chat-terminal-stream.test.ts"
],
"testFiles": [
"src/main/runtime/rpc/terminal-subscribe-buffer.test.ts",
"src/main/runtime/rpc/terminal-output-batching.test.ts",
"src/main/runtime/rpc/terminal-multiplex.test.ts"
"src/main/runtime/rpc/terminal-multiplex.test.ts",
"mobile/src/session/mobile-native-chat-terminal-stream.test.ts",
"mobile/src/session/use-mobile-native-chat-terminal-stream.test.ts"
],
"assertionRefs": [
{
@@ -3956,6 +3941,21 @@
"oversized live output frames are bounded for subscribed binary streams",
"multibyte live output flushes when encoded bytes reach the batch budget"
]
},
{
"file": "mobile/src/session/mobile-native-chat-terminal-stream.test.ts",
"assertions": [
"an active terminal stream pauses while native chat covers its WebView",
"the stream resumes only for the ready active terminal after chat closes",
"non-terminal and already-settled stream states perform no lifecycle work"
]
},
{
"file": "mobile/src/session/use-mobile-native-chat-terminal-stream.test.ts",
"assertions": [
"a late WebView-ready transition converts a cold-start lease-only stream back to output",
"ordinary WebView readiness does not rerender the mobile session route"
]
}
],
"evidenceRuns": [
@@ -3967,6 +3967,15 @@
"result": "passed",
"durationSeconds": 3.8,
"summary": "3 test file(s) passed, 28 tests passed on main@1282f5c2d in a clean checkout."
},
{
"date": "2026-07-12",
"runner": "local",
"platform": "macos",
"command": "pnpm --dir mobile exec vitest run --root .. mobile/src/session/mobile-native-chat-terminal-stream.test.ts",
"result": "passed",
"durationSeconds": 0.2,
"summary": "The focused mobile native-chat suite passed with 3 terminal-stream lifecycle assertions in the staged PR #5824 worktree."
}
],
"runtimeBudget": {
@@ -3991,7 +4000,7 @@
"Record byte counts and order/cursor artifacts on failure."
],
"knownGaps": [
"Current command covers binary runtime/mobile stream contracts only.",
"The pure mobile decision gate does not yet prove live Android WebView scrollback restore after a chat toggle.",
"Legacy JSON subscribe parity is undecided."
],
"demotionRule": "Cannot promote while a supported stream path has uncapped snapshot or live-output buffering."
@@ -29,7 +29,9 @@ describe('electron-builder config', () => {
'!native{,/**/*}',
'!skills{,/**/*}',
'!skill-guides{,/**/*}',
'!resources/skills/**',
'!tests{,/**/*}',
'!pr-evidence{,/**/*}',
'!Casks{,/**/*}',
'!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.md}',
'!out/**/*.test.js'
@@ -38,6 +40,12 @@ describe('electron-builder config', () => {
})
it('keeps runtime resources available through extraResources', () => {
for (const platform of ['mac', 'linux', 'win']) {
expect(electronBuilderConfig[platform].extraResources).toContainEqual({
from: 'resources/skills',
to: 'skills'
})
}
expect(electronBuilderConfig.mac.extraResources).toEqual(
expect.arrayContaining([
expect.objectContaining({
@@ -0,0 +1,522 @@
import { createHash } from 'node:crypto'
import { execFileSync } from 'node:child_process'
import { constants } from 'node:fs'
import { access, lstat, mkdir, readFile, readdir, writeFile } from 'node:fs/promises'
import path from 'node:path'
import process from 'node:process'
import { isDeepStrictEqual } from 'node:util'
const SCHEMA_VERSION = 1
const SCRIPT_DIR = import.meta.dirname
const REPO_ROOT = path.resolve(SCRIPT_DIR, '..', '..')
const SKILLS_ROOT = path.join(REPO_ROOT, 'skills')
const OUTPUT_ROOT = path.join(REPO_ROOT, 'resources', 'skills')
const CURRENT_MANIFEST_PATH = path.join(OUTPUT_ROOT, 'current-manifest.json')
const SNAPSHOT_REGISTRY_PATH = path.join(OUTPUT_ROOT, 'snapshot-registry.json')
const RELEASE_MAPPING_PATH = path.join(OUTPUT_ROOT, 'release-mapping.json')
function sha256(bytes) {
return createHash('sha256').update(bytes).digest('hex')
}
function compareCodeUnits(left, right) {
return left === right ? 0 : left < right ? -1 : 1
}
function gitObjectSha(kind, bytes) {
return createHash('sha1').update(`${kind} ${bytes.length}\0`).update(bytes).digest()
}
function normalizeText(bytes) {
const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes)
return Buffer.from(text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'), 'utf8')
}
function classifyFile(bytes) {
if (bytes.includes(0)) {
return 'binary'
}
try {
normalizeText(bytes)
return 'text'
} catch {
return 'binary'
}
}
function assertSafeRelativePath(relativePath) {
if (
path.isAbsolute(relativePath) ||
relativePath === '..' ||
relativePath.startsWith(`..${path.sep}`)
) {
throw new Error(`Unsafe skill package path: ${relativePath}`)
}
}
function describeFile(manifestPath, bytes, executable) {
const classification = classifyFile(bytes)
const exactSha256 = sha256(bytes)
const textNormalizedSha256 = classification === 'text' ? sha256(normalizeText(bytes)) : null
return {
path: manifestPath,
size: bytes.length,
executable,
classification,
exactSha256,
textNormalizedSha256,
identitySha256: classification === 'text' && !executable ? textNormalizedSha256 : exactSha256,
gitBlobSha: gitObjectSha('blob', bytes).toString('hex')
}
}
function gitTreeSha(entries) {
const root = { directories: new Map(), files: [] }
for (const entry of entries) {
const parts = entry.path.split('/')
const filename = parts.pop()
let directory = root
for (const part of parts) {
let child = directory.directories.get(part)
if (!child) {
child = { directories: new Map(), files: [] }
directory.directories.set(part, child)
}
directory = child
}
directory.files.push({ filename, ...entry })
}
function hashDirectory(directory) {
const children = [
...[...directory.directories].map(([name, child]) => ({
mode: '40000',
name,
hash: hashDirectory(child)
})),
...directory.files.map((file) => ({
mode: file.executable ? '100755' : '100644',
name: file.filename,
hash: Buffer.from(file.gitBlobSha, 'hex')
}))
].sort((left, right) => {
const leftName = left.mode === '40000' ? `${left.name}/` : left.name
const rightName = right.mode === '40000' ? `${right.name}/` : right.name
return Buffer.from(leftName).compare(Buffer.from(rightName))
})
const body = Buffer.concat(
children.map(({ mode, name, hash }) =>
Buffer.concat([Buffer.from(`${mode} ${name}\0`, 'utf8'), hash])
)
)
return gitObjectSha('tree', body)
}
return hashDirectory(root).toString('hex')
}
async function collectPackageFiles(packageRoot) {
const files = []
const caseFoldedPaths = new Map()
async function visit(directory) {
const entries = await readdir(directory, { withFileTypes: true })
// Why: build-time Node and packaged Electron may ship different ICU data;
// package identity order must use the same locale-independent comparison.
entries.sort((left, right) => compareCodeUnits(left.name, right.name))
for (const entry of entries) {
const absolutePath = path.join(directory, entry.name)
const relativePath = path.relative(packageRoot, absolutePath)
assertSafeRelativePath(relativePath)
const manifestPath = relativePath.split(path.sep).join('/')
const foldedPath = manifestPath.toLocaleLowerCase('en-US')
const collision = caseFoldedPaths.get(foldedPath)
if (collision && collision !== manifestPath) {
throw new Error(`Case-colliding skill paths: ${collision} and ${manifestPath}`)
}
caseFoldedPaths.set(foldedPath, manifestPath)
const fileStat = await lstat(absolutePath)
if (fileStat.isSymbolicLink()) {
throw new Error(`Symlink is not allowed in a shipped skill: ${manifestPath}`)
}
if (fileStat.isDirectory()) {
await visit(absolutePath)
continue
}
if (!fileStat.isFile()) {
throw new Error(`Special file is not allowed in a shipped skill: ${manifestPath}`)
}
// Why: Windows observation cannot see execute bits, so an executable file in
// a shipped skill would misclassify every pristine Windows install as unrecognized.
if ((fileStat.mode & 0o111) !== 0) {
throw new Error(`Executable file is not allowed in a shipped skill: ${manifestPath}`)
}
files.push(describeFile(manifestPath, await readFile(absolutePath), false))
}
}
await visit(packageRoot)
return sortManifestFiles(files)
}
function collectGitSkillTreeEntries(treeSha) {
const output = execFileSync('git', ['ls-tree', '-r', '-z', treeSha])
.toString('utf8')
.split('\0')
.filter(Boolean)
const packages = new Map()
for (const line of output) {
const match = /^(\d+) (\w+) ([a-f0-9]+)\t(.+)$/.exec(line)
if (!match) {
throw new Error(`Unexpected git tree entry in ${treeSha}: ${line}`)
}
const [, mode, type, objectSha, sourcePath] = match
const separator = sourcePath.indexOf('/')
if (separator <= 0 || separator === sourcePath.length - 1) {
throw new Error(`Unsupported shipped skill path in ${treeSha}: ${sourcePath}`)
}
const name = sourcePath.slice(0, separator)
const manifestPath = sourcePath.slice(separator + 1)
const entries = packages.get(name) ?? []
entries.push({ mode, type, objectSha, manifestPath })
packages.set(name, entries)
}
return packages
}
function readGitBlobs(objectShas) {
const uniqueShas = [...new Set(objectShas)]
if (uniqueShas.length === 0) {
return new Map()
}
// Why: released history spans hundreds of tags. Batch mode avoids a Git
// subprocess per historical file while remaining available on Git 2.25.
const output = execFileSync('git', ['cat-file', '--batch'], {
input: `${uniqueShas.join('\n')}\n`,
maxBuffer: 64 * 1024 * 1024
})
const blobs = new Map()
let offset = 0
for (const requestedSha of uniqueShas) {
const headerEnd = output.indexOf(10, offset)
if (headerEnd < 0) {
throw new Error(`Missing git cat-file header for ${requestedSha}`)
}
const header = output.subarray(offset, headerEnd).toString('utf8')
const match = /^([a-f0-9]+) blob (\d+)$/.exec(header)
if (!match || match[1] !== requestedSha) {
throw new Error(`Unexpected git cat-file header for ${requestedSha}: ${header}`)
}
const size = Number(match[2])
const contentStart = headerEnd + 1
const contentEnd = contentStart + size
if (contentEnd >= output.length || output[contentEnd] !== 10) {
throw new Error(`Truncated git blob for ${requestedSha}`)
}
blobs.set(requestedSha, Buffer.from(output.subarray(contentStart, contentEnd)))
offset = contentEnd + 1
}
return blobs
}
function collectGitPackageFiles(treeSha, name, entries, blobs) {
const caseFoldedPaths = new Map()
const files = entries.map(({ mode, type, objectSha, manifestPath }) => {
if (type !== 'blob' || (mode !== '100644' && mode !== '100755')) {
throw new Error(`Unsupported shipped skill entry in ${treeSha}: ${name}/${manifestPath}`)
}
assertSafeRelativePath(manifestPath)
const foldedPath = manifestPath.toLocaleLowerCase('en-US')
const collision = caseFoldedPaths.get(foldedPath)
if (collision && collision !== manifestPath) {
throw new Error(`Case-colliding skill paths in ${treeSha}: ${collision} and ${manifestPath}`)
}
caseFoldedPaths.set(foldedPath, manifestPath)
const bytes = blobs.get(objectSha)
if (!bytes) {
throw new Error(`Missing git blob ${objectSha} for ${name}/${manifestPath}`)
}
return describeFile(manifestPath, bytes, mode === '100755')
})
// Why: git ls-tree emits git byte-order, not the canonical walk order.
return sortManifestFiles(files)
}
// Why: snapshot matching compares files by array index, so every producer —
// working-tree walk, git history, and runtime observation — must emit one
// canonical order. This mirrors the sorted depth-first filesystem walk.
function compareManifestPaths(left, right) {
const leftParts = left.split('/')
const rightParts = right.split('/')
const shared = Math.min(leftParts.length, rightParts.length)
for (let index = 0; index < shared; index += 1) {
const order = compareCodeUnits(leftParts[index], rightParts[index])
if (order !== 0) {
return order
}
}
return leftParts.length - rightParts.length
}
function sortManifestFiles(files) {
return [...files].sort((left, right) => compareManifestPaths(left.path, right.path))
}
function packageDigest(files) {
return sha256(
Buffer.from(
JSON.stringify(
files.map((file) => ({
path: file.path,
executable: file.executable,
classification: file.classification,
identitySha256: file.identitySha256
}))
),
'utf8'
)
)
}
function releaseTags() {
return execFileSync(
'git',
['for-each-ref', '--sort=creatordate', '--format=%(refname:short)', 'refs/tags/v*'],
{ encoding: 'utf8' }
)
.split('\n')
.filter((tag) => /^v\d+\.\d+\.\d+(?:[-.][0-9A-Za-z.-]+)?$/.test(tag))
}
function skillsTreeShasAtRefs(refs) {
if (refs.length === 0) {
return []
}
const output = execFileSync('git', ['cat-file', '--batch-check=%(objectname) %(objecttype)'], {
input: `${refs.map((ref) => `${ref}:skills`).join('\n')}\n`,
encoding: 'utf8'
})
const lines = output.trimEnd().split('\n')
if (lines.length !== refs.length) {
throw new Error(`Expected ${refs.length} skills tree identities, received ${lines.length}`)
}
return lines.map((line, index) => {
if (line.endsWith(' missing')) {
return null
}
const match = /^([a-f0-9]+) tree$/.exec(line)
if (!match) {
throw new Error(`Unexpected skills tree identity at ${refs[index]}: ${line}`)
}
return match[1]
})
}
function buildReleasedHistory() {
const registry = { schemaVersion: SCHEMA_VERSION, skills: {} }
const mapping = { schemaVersion: SCHEMA_VERSION, releases: [] }
const tags = releaseTags()
const treeShas = skillsTreeShasAtRefs(tags)
const distinctTreeShas = [...new Set(treeShas.filter(Boolean))]
const packagesByTree = new Map(
distinctTreeShas.map((treeSha) => [treeSha, collectGitSkillTreeEntries(treeSha)])
)
const blobs = readGitBlobs(
[...packagesByTree.values()].flatMap((packages) =>
[...packages.values()].flatMap((entries) => entries.map((entry) => entry.objectSha))
)
)
let previousSkillsTreeSha = null
for (const [index, tag] of tags.entries()) {
const skillsTreeSha = treeShas[index]
if (!skillsTreeSha || skillsTreeSha === previousSkillsTreeSha) {
continue
}
previousSkillsTreeSha = skillsTreeSha
const revisions = {}
const packages = packagesByTree.get(skillsTreeSha)
if (!packages) {
throw new Error(`Missing released skill tree ${skillsTreeSha} at ${tag}`)
}
for (const name of [...packages.keys()].sort(compareCodeUnits)) {
const entries = packages.get(name)
const filesWithGitHashes = collectGitPackageFiles(skillsTreeSha, name, entries, blobs)
if (!filesWithGitHashes.some((file) => file.path === 'SKILL.md')) {
continue
}
const digest = packageDigest(filesWithGitHashes)
const snapshots = registry.skills[name] ?? []
const latest = snapshots.at(-1)
if (!latest || latest.packageDigest !== digest) {
const files = filesWithGitHashes.map(({ gitBlobSha: _gitBlobSha, ...file }) => file)
snapshots.push({
releaseRevision: (latest?.releaseRevision ?? 0) + 1,
packageDigest: digest,
gitTreeSha: gitTreeSha(filesWithGitHashes),
files
})
registry.skills[name] = snapshots
}
revisions[name] = snapshots.at(-1).releaseRevision
}
if (Object.keys(revisions).length > 0) {
mapping.releases.push({ appVersion: tag.slice(1), skills: revisions })
}
}
return { registry, mapping }
}
async function buildArtifacts(appVersion) {
const { registry, mapping } = buildReleasedHistory()
const releasedSnapshotCounts = Object.fromEntries(
Object.entries(registry.skills).map(([name, snapshots]) => [name, snapshots.length])
)
const skillDirectories = (await readdir(SKILLS_ROOT, { withFileTypes: true }))
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort(compareCodeUnits)
const currentSkills = []
for (const name of skillDirectories) {
const filesWithGitHashes = await collectPackageFiles(path.join(SKILLS_ROOT, name))
if (!filesWithGitHashes.some((file) => file.path === 'SKILL.md')) {
throw new Error(`Skill package ${name} has no top-level SKILL.md`)
}
const digest = packageDigest(filesWithGitHashes)
const snapshots = registry.skills[name] ?? []
const latest = snapshots.at(-1)
let snapshot = latest
if (!latest || latest.packageDigest !== digest) {
const files = filesWithGitHashes.map(({ gitBlobSha: _gitBlobSha, ...file }) => file)
snapshot = {
releaseRevision: (latest?.releaseRevision ?? 0) + 1,
packageDigest: digest,
gitTreeSha: gitTreeSha(filesWithGitHashes),
files
}
snapshots.push(snapshot)
registry.skills[name] = snapshots
}
currentSkills.push({
name,
sourcePath: `skills/${name}`,
appVersion,
...snapshot
})
}
return {
currentManifest: {
schemaVersion: SCHEMA_VERSION,
appVersion,
skills: currentSkills
},
snapshotRegistry: registry,
releaseMapping: mapping,
releasedSnapshotCounts
}
}
// Why: released snapshots are the detection ground truth for existing installs,
// so a generation-logic change must not rewrite them silently. Only the one
// unreleased working-tree append per skill may change between runs.
function assertReleasedHistoryPreserved(committedRegistry, artifacts) {
if (!committedRegistry || committedRegistry.schemaVersion !== SCHEMA_VERSION) {
return
}
for (const [name, committedSnapshots] of Object.entries(committedRegistry.skills ?? {})) {
const releasedCount = artifacts.releasedSnapshotCounts[name] ?? 0
const regenerated = artifacts.snapshotRegistry.skills[name] ?? []
if (releasedCount < Math.max(0, committedSnapshots.length - 1)) {
throw new Error(
`Released snapshot history is incomplete for ${name}. ` +
'Fetch all release tags before regenerating skill artifacts.'
)
}
const protectedCount = Math.min(committedSnapshots.length, releasedCount)
for (let index = 0; index < protectedCount; index += 1) {
const committed = committedSnapshots[index]
const rebuilt = regenerated[index]
if (!rebuilt || !isDeepStrictEqual(rebuilt, committed)) {
throw new Error(
`Released snapshot history changed for ${name} at revision ${committed.releaseRevision}. ` +
'Released snapshots are append-only; a deliberate identity migration must update this check.'
)
}
}
}
}
async function readCommittedRegistry() {
try {
return JSON.parse(await readFile(SNAPSHOT_REGISTRY_PATH, 'utf8'))
} catch {
return null
}
}
function serialized(value) {
return `${JSON.stringify(value, null, 2)}\n`
}
async function writeArtifacts(artifacts) {
await mkdir(OUTPUT_ROOT, { recursive: true })
await Promise.all([
writeFile(CURRENT_MANIFEST_PATH, serialized(artifacts.currentManifest)),
writeFile(SNAPSHOT_REGISTRY_PATH, serialized(artifacts.snapshotRegistry)),
writeFile(RELEASE_MAPPING_PATH, serialized(artifacts.releaseMapping))
])
}
async function verifyArtifacts(artifacts) {
const expected = [
[CURRENT_MANIFEST_PATH, artifacts.currentManifest],
[SNAPSHOT_REGISTRY_PATH, artifacts.snapshotRegistry],
[RELEASE_MAPPING_PATH, artifacts.releaseMapping]
]
const stale = []
for (const [filePath, value] of expected) {
try {
await access(filePath, constants.R_OK)
if ((await readFile(filePath, 'utf8')) !== serialized(value)) {
stale.push(filePath)
}
} catch {
stale.push(filePath)
}
}
if (stale.length > 0) {
throw new Error(
`Generated skill artifacts are stale:\n${stale
.map((filePath) => path.relative(REPO_ROOT, filePath))
.join('\n')}\nRun pnpm generate:skill-bundle-manifest.`
)
}
}
async function main() {
const packageJson = JSON.parse(await readFile(path.join(REPO_ROOT, 'package.json'), 'utf8'))
const artifacts = await buildArtifacts(packageJson.version)
assertReleasedHistoryPreserved(await readCommittedRegistry(), artifacts)
await (process.argv.includes('--write') ? writeArtifacts : verifyArtifacts)(artifacts)
}
if (process.argv[1] && path.resolve(process.argv[1]) === import.meta.filename) {
main().catch((error) => {
console.error(error instanceof Error ? error.message : error)
process.exitCode = 1
})
}
export {
assertReleasedHistoryPreserved,
buildArtifacts,
buildReleasedHistory,
classifyFile,
collectPackageFiles,
describeFile,
gitTreeSha,
normalizeText,
packageDigest,
sortManifestFiles,
verifyArtifacts,
writeArtifacts
}
@@ -0,0 +1,199 @@
import { execFileSync } from 'node:child_process'
import { chmod, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
assertReleasedHistoryPreserved,
classifyFile,
collectPackageFiles,
describeFile,
gitTreeSha,
normalizeText,
packageDigest,
sortManifestFiles
} from './generate-skill-bundle-manifest.mjs'
const temporaryDirectories = []
async function createPackage() {
const directory = await mkdtemp(path.join(tmpdir(), 'orca-skill-manifest-'))
temporaryDirectories.push(directory)
return directory
}
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true }))
)
})
describe('skill bundle manifest generator', () => {
it('folds platform line endings for text identity', () => {
const lf = Buffer.from('first\nsecond\n')
const crlf = Buffer.from('first\r\nsecond\r\n')
expect(classifyFile(lf)).toBe('text')
expect(normalizeText(crlf)).toEqual(lf)
})
it('classifies null-containing and invalid UTF-8 content as binary', () => {
expect(classifyFile(Buffer.from([0, 1, 2]))).toBe('binary')
expect(classifyFile(Buffer.from([0xc3, 0x28]))).toBe('binary')
})
it('uses normalized text identity but exact executable identity', () => {
const skillFile = describeFile('SKILL.md', Buffer.from('line one\r\nline two\r\n'), false)
const executable = describeFile('run.sh', Buffer.from('#!/bin/sh\r\necho ok\r\n'), true)
expect(skillFile.identitySha256).toBe(skillFile.textNormalizedSha256)
expect(skillFile.identitySha256).not.toBe(skillFile.exactSha256)
expect(executable.exactSha256).not.toBe(executable.textNormalizedSha256)
expect(executable.identitySha256).toBe(executable.exactSha256)
expect(packageDigest([skillFile, executable])).toMatch(/^[a-f0-9]{64}$/)
})
it('orders git-history files identically to the filesystem walk', async () => {
const packageRoot = await createPackage()
await mkdir(path.join(packageRoot, 'sub'))
for (const name of ['apple.md', 'sub.md', 'Zebra.md', path.join('sub', 'inner.txt')]) {
await writeFile(path.join(packageRoot, name), `${name}\n`)
}
const walked = await collectPackageFiles(packageRoot)
// Why: git ls-tree emits [Zebra.md, apple.md, sub.md, sub/inner.txt]; index-based
// snapshot matching requires history and observation to share one order.
const gitOrdered = ['Zebra.md', 'apple.md', 'sub.md', 'sub/inner.txt'].map((manifestPath) =>
walked.find((file) => file.path === manifestPath)
)
expect(sortManifestFiles(gitOrdered)).toEqual(walked)
expect(packageDigest(sortManifestFiles(gitOrdered))).toBe(packageDigest(walked))
expect(walked.map((file) => file.path)).toEqual([
'Zebra.md',
'apple.md',
'sub/inner.txt',
'sub.md'
])
})
it('rejects rewrites of released snapshots and allows floating-tail replacement', () => {
const snapshot = (releaseRevision, packageDigest) => ({ releaseRevision, packageDigest })
const artifacts = {
releasedSnapshotCounts: { 'orca-cli': 2 },
snapshotRegistry: {
schemaVersion: 1,
skills: { 'orca-cli': [snapshot(1, 'aaa'), snapshot(2, 'bbb'), snapshot(3, 'ccc')] }
}
}
expect(() =>
assertReleasedHistoryPreserved(
{ schemaVersion: 1, skills: { 'orca-cli': [snapshot(1, 'aaa'), snapshot(2, 'bbb')] } },
artifacts
)
).not.toThrow()
expect(() =>
assertReleasedHistoryPreserved(
{
schemaVersion: 1,
skills: { 'orca-cli': [snapshot(1, 'aaa'), snapshot(2, 'bbb'), snapshot(3, 'stale')] }
},
artifacts
)
).not.toThrow()
expect(() =>
assertReleasedHistoryPreserved(
{
schemaVersion: 1,
skills: { 'orca-cli': [snapshot(1, 'aaa'), snapshot(2, 'rewritten')] }
},
artifacts
)
).toThrow('Released snapshot history changed for orca-cli at revision 2')
expect(() =>
assertReleasedHistoryPreserved(
{
schemaVersion: 1,
skills: {
'orca-cli': [snapshot(1, 'aaa'), { ...snapshot(2, 'bbb'), gitTreeSha: 'rewritten' }]
}
},
artifacts
)
).toThrow('Released snapshot history changed for orca-cli at revision 2')
expect(() =>
assertReleasedHistoryPreserved(
{
schemaVersion: 1,
skills: {
'orca-cli': [snapshot(1, 'aaa'), snapshot(2, 'bbb'), snapshot(3, 'stale')]
}
},
{ ...artifacts, releasedSnapshotCounts: { 'orca-cli': 1 } }
)
).toThrow('Released snapshot history is incomplete for orca-cli')
expect(() => assertReleasedHistoryPreserved(null, artifacts)).not.toThrow()
})
it.runIf(process.platform !== 'win32')(
'rejects executable files in shipped skill packages',
async () => {
const packageRoot = await createPackage()
await writeFile(path.join(packageRoot, 'SKILL.md'), 'skill\n')
await writeFile(path.join(packageRoot, 'run.sh'), '#!/bin/sh\necho ok\n')
await chmod(path.join(packageRoot, 'run.sh'), 0o755)
await expect(collectPackageFiles(packageRoot)).rejects.toThrow(
'Executable file is not allowed in a shipped skill: run.sh'
)
}
)
it.runIf(process.platform === 'linux')('rejects case-colliding paths', async () => {
const packageRoot = await createPackage()
await writeFile(path.join(packageRoot, 'SKILL.md'), 'skill')
await writeFile(path.join(packageRoot, 'Readme.md'), 'one')
await writeFile(path.join(packageRoot, 'README.md'), 'two')
await expect(collectPackageFiles(packageRoot)).rejects.toThrow('Case-colliding skill paths')
})
it.runIf(process.platform !== 'win32')('rejects symlinks inside shipped packages', async () => {
const packageRoot = await createPackage()
await writeFile(path.join(packageRoot, 'SKILL.md'), 'skill')
await symlink('SKILL.md', path.join(packageRoot, 'linked.md'))
await expect(collectPackageFiles(packageRoot)).rejects.toThrow(
'Symlink is not allowed in a shipped skill'
)
})
it('computes the same Git tree identity as Git', async () => {
const packageRoot = path.resolve('skills', 'orca-cli')
const files = await collectPackageFiles(packageRoot)
const expected = execFileSync('git', ['ls-tree', 'HEAD:skills', 'orca-cli'], {
encoding: 'utf8'
})
.trim()
.split(/\s+/)[2]
expect(gitTreeSha(files)).toBe(expected)
})
it('matches Git when a directory and file share a name prefix', async () => {
const packageRoot = await createPackage()
await mkdir(path.join(packageRoot, 'sub'))
await writeFile(path.join(packageRoot, 'sub', 'inner.txt'), 'nested\n')
await writeFile(path.join(packageRoot, 'sub.md'), 'sibling\n')
const files = await collectPackageFiles(packageRoot)
execFileSync('git', ['init', '--quiet'], { cwd: packageRoot })
execFileSync('git', ['add', '-A'], { cwd: packageRoot })
const expected = execFileSync('git', ['write-tree'], {
cwd: packageRoot,
encoding: 'utf8'
}).trim()
expect(gitTreeSha(files)).toBe(expected)
})
})
@@ -1,93 +0,0 @@
#!/usr/bin/env node
// Benchmark: main-process retained-Set growth of the serve-sim state watcher's
// external-helper dedupe across worktree switches.
//
// emitIfExternal() records one entry per (worktreeId, deviceUdid, pid, wsUrl,
// streamUrl) tuple in `seenExternalKeys` so a helper is only auto-tabbed once.
// Before the fix, forgetWorktree() pruned ptyToWorktree and ptyBuffers but never
// touched seenExternalKeys, so its keys accumulated for the renderer/main
// process lifetime — only cleared at app shutdown (stop()). This script
// simulates N bind→detect→forget cycles and reports retained Set size with the
// prune disabled vs enabled.
import { performance } from 'node:perf_hooks'
const CYCLES = Number.parseInt(process.env.ORCA_SEEN_KEYS_BENCH_CYCLES ?? '5000', 10)
const HELPERS_PER_WORKTREE = Number.parseInt(process.env.ORCA_SEEN_KEYS_BENCH_HELPERS ?? '2', 10)
for (const [name, value] of [
['ORCA_SEEN_KEYS_BENCH_CYCLES', CYCLES],
['ORCA_SEEN_KEYS_BENCH_HELPERS', HELPERS_PER_WORKTREE]
]) {
if (!Number.isInteger(value) || value <= 0) {
throw new Error(`${name} must be a positive integer, received ${value}`)
}
}
// Mirror of the watcher's dedupe bookkeeping (serve-sim-state-watcher.ts): a
// Set of `${worktreeId}::${instanceKey}` keys, plus the prefix-prune the fix
// adds to forgetWorktree().
function makeWatcher({ prune }) {
const seenExternalKeys = new Set()
return {
set: seenExternalKeys,
emitIfExternal(worktreeId, instanceKey) {
const dedupeKey = `${worktreeId}::${instanceKey}`
if (seenExternalKeys.has(dedupeKey)) {
return false
}
seenExternalKeys.add(dedupeKey)
return true
},
forgetWorktree(worktreeId) {
if (!prune) {
return
}
const prefix = `${worktreeId}::`
for (const key of seenExternalKeys) {
if (key.startsWith(prefix)) {
seenExternalKeys.delete(key)
}
}
}
}
}
function run({ prune }) {
const watcher = makeWatcher({ prune })
const start = performance.now()
for (let cycle = 0; cycle < CYCLES; cycle++) {
const worktreeId = `worktree-${cycle}`
for (let h = 0; h < HELPERS_PER_WORKTREE; h++) {
// A realistic instance key: device udid :: pid :: wsUrl :: streamUrl.
const instanceKey = `udid-${cycle}-${h}::pid-${1000 + h}::ws://127.0.0.1:${3100 + h}/ws::http://127.0.0.1:${3100 + h}/stream.mjpeg`
watcher.emitIfExternal(worktreeId, instanceKey)
}
watcher.forgetWorktree(worktreeId)
}
return { retained: watcher.set.size, elapsedMs: performance.now() - start }
}
console.log(
`serve-sim seenExternalKeys leak benchmark: ${CYCLES} worktree open/forget cycles, ` +
`${HELPERS_PER_WORKTREE} helper(s)/worktree\n`
)
const before = run({ prune: false })
const after = run({ prune: true })
// Each key is ~120-160 bytes (worktreeId + udid + pid + two URLs).
const APPROX_BYTES_PER_KEY = 140
const leakedKb = (before.retained * APPROX_BYTES_PER_KEY) / 1024
console.log(
`before (no prune) retained=${before.retained} keys ` +
`(~${leakedKb.toFixed(0)} KiB) time=${before.elapsedMs.toFixed(1)}ms`
)
console.log(
`after (prune) retained=${after.retained} keys ` +
`(~0 KiB) time=${after.elapsedMs.toFixed(1)}ms`
)
console.log(
`\nBounded the dedupe Set to live worktrees: ${before.retained} → ${after.retained} ` +
`retained keys after ${CYCLES} worktree switches.`
)
@@ -0,0 +1,249 @@
import { execFileSync } from 'node:child_process'
import {
chmod,
cp,
lstat,
mkdir,
mkdtemp,
readFile,
realpath,
rm,
symlink,
writeFile
} from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import process from 'node:process'
import { collectPackageFiles, packageDigest } from './generate-skill-bundle-manifest.mjs'
function option(name) {
return process.argv.find((value) => value.startsWith(`--${name}=`))?.slice(name.length + 3)
}
const cliVersion = option('cli')
const autocrlf = option('autocrlf')
const shape = option('shape')
// Why: PR branch names are untrusted workflow input. Keep them out of the
// generated shell command and pass them to Node through the environment.
const source = option('source') ?? process.env.SKILL_UPDATE_SOURCE
const ref = option('ref') ?? process.env.SKILL_UPDATE_REF
if (
!cliVersion ||
(autocrlf !== 'true' && autocrlf !== 'false') ||
(shape !== 'symlink' && shape !== 'copy') ||
!source ||
!ref ||
!/^[^/\s]+\/[^/\s]+$/.test(source)
) {
throw new Error(
'Usage: verify-skill-update-roundtrip.mjs --cli=<version> --autocrlf=true|false --shape=symlink|copy --source=<owner/repo> --ref=<git-ref>'
)
}
const sandbox = await mkdtemp(path.join(tmpdir(), 'orca-skill-update-roundtrip-'))
const home = path.join(sandbox, 'home')
const stateHome = path.join(home, '.state')
const fakeBin = path.join(sandbox, 'bin')
const targetName = 'orca-cli'
const controlName = 'orchestration'
const manifest = JSON.parse(await readFile('resources/skills/current-manifest.json', 'utf8'))
const registry = JSON.parse(await readFile('resources/skills/snapshot-registry.json', 'utf8'))
const releaseMapping = JSON.parse(await readFile('resources/skills/release-mapping.json', 'utf8'))
function currentSkill(name) {
const skill = manifest.skills.find((entry) => entry.name === name)
if (!skill) {
throw new Error(`Current manifest is missing ${name}`)
}
return skill
}
function historicalRelease(name) {
const current = currentSkill(name)
for (const release of releaseMapping.releases.toReversed()) {
const revision = release.skills[name]
if (typeof revision !== 'number' || revision >= current.releaseRevision) {
continue
}
const snapshot = registry.skills[name]?.find((entry) => entry.releaseRevision === revision)
if (snapshot) {
return { tag: `v${release.appVersion}`, snapshot }
}
}
throw new Error(`No historical released snapshot is available for ${name}`)
}
async function materializePackage(name, tag, destination) {
const prefix = `skills/${name}/`
const entries = execFileSync('git', ['ls-tree', '-r', '-z', tag, '--', `skills/${name}`])
.toString('utf8')
.split('\0')
.filter(Boolean)
if (entries.length === 0) {
throw new Error(`${tag} does not contain ${name}`)
}
for (const entry of entries) {
const match = /^(\d+) (\w+) ([a-f0-9]+)\t(.+)$/.exec(entry)
if (!match || match[2] !== 'blob') {
throw new Error(`Unsupported historical tree entry: ${entry}`)
}
const relativePath = match[4].slice(prefix.length)
const destinationPath = path.join(destination, ...relativePath.split('/'))
await mkdir(path.dirname(destinationPath), { recursive: true })
await writeFile(destinationPath, execFileSync('git', ['cat-file', 'blob', match[3]]))
if (process.platform !== 'win32' && match[1] === '100755') {
await chmod(destinationPath, 0o755)
}
}
}
async function seedPlacement(name, tag) {
const canonical = path.join(home, '.agents', 'skills', name)
await materializePackage(name, tag, canonical)
const providerRoot = path.join(home, '.claude', 'skills')
const provider = path.join(providerRoot, name)
await mkdir(providerRoot, { recursive: true })
await (shape === 'copy'
? cp(canonical, provider, { recursive: true })
: symlink(canonical, provider, process.platform === 'win32' ? 'junction' : 'dir'))
}
async function installFakeAgentCommands() {
await mkdir(fakeBin, { recursive: true })
for (const name of ['codex', 'claude']) {
const executable = path.join(fakeBin, process.platform === 'win32' ? `${name}.cmd` : name)
await writeFile(
executable,
process.platform === 'win32' ? '@exit /b 0\r\n' : '#!/bin/sh\nexit 0\n'
)
if (process.platform !== 'win32') {
await chmod(executable, 0o755)
}
}
}
async function packageDigestAt(pathValue) {
return packageDigest(await collectPackageFiles(pathValue))
}
async function assertCurrentCanonical(name) {
const expected = currentSkill(name).packageDigest
const canonical = path.join(home, '.agents', 'skills', name)
if ((await packageDigestAt(canonical)) !== expected) {
throw new Error(`${name} canonical placement did not update to the PR content`)
}
}
function execSkills(args) {
const executable = process.platform === 'win32' ? (process.env.ComSpec ?? 'cmd.exe') : 'npx'
const cliArgs = ['--yes', `skills@${cliVersion}`, ...args]
execFileSync(
executable,
process.platform === 'win32' ? ['/d', '/s', '/c', 'npx.cmd', ...cliArgs] : cliArgs,
{
cwd: process.cwd(),
env: {
...process.env,
HOME: home,
USERPROFILE: home,
CODEX_HOME: path.join(home, '.codex'),
CLAUDE_CONFIG_DIR: path.join(home, '.claude'),
XDG_STATE_HOME: stateHome,
GIT_CONFIG_COUNT: '1',
GIT_CONFIG_KEY_0: 'core.autocrlf',
GIT_CONFIG_VALUE_0: autocrlf,
PATH: `${fakeBin}${path.delimiter}${process.env.PATH ?? ''}`,
CI: '1'
},
stdio: 'inherit'
}
)
}
try {
const targetHistorical = historicalRelease(targetName)
const controlHistorical = historicalRelease(controlName)
await installFakeAgentCommands()
await mkdir(path.join(home, '.codex'), { recursive: true })
await mkdir(path.join(home, '.claude'), { recursive: true })
await seedPlacement(targetName, targetHistorical.tag)
await seedPlacement(controlName, controlHistorical.tag)
const targetProvider = path.join(home, '.claude', 'skills', targetName)
const controlCanonical = path.join(home, '.agents', 'skills', controlName)
const controlProvider = path.join(home, '.claude', 'skills', controlName)
const targetProviderBefore = await packageDigestAt(await realpath(targetProvider))
const controlBefore = await packageDigestAt(controlCanonical)
const controlProviderBefore = await packageDigestAt(await realpath(controlProvider))
const timestamp = new Date().toISOString()
const lock = {
version: 3,
skills: {
[targetName]: {
source,
sourceType: 'github',
sourceUrl: `https://github.com/${source}.git`,
ref,
skillPath: `skills/${targetName}/SKILL.md`,
skillFolderHash: targetHistorical.snapshot.gitTreeSha,
installedAt: timestamp,
updatedAt: timestamp
},
[controlName]: {
source,
sourceType: 'github',
sourceUrl: `https://github.com/${source}.git`,
ref,
skillPath: `skills/${controlName}/SKILL.md`,
skillFolderHash: controlHistorical.snapshot.gitTreeSha,
installedAt: timestamp,
updatedAt: timestamp
}
}
}
const lockPath = path.join(stateHome, 'skills', '.skill-lock.json')
await mkdir(path.dirname(lockPath), { recursive: true })
await writeFile(lockPath, `${JSON.stringify(lock, null, 2)}\n`)
// Why: this is the exact user-visible rail. A bare update would include
// unrelated vendors, while this command must leave the control skill alone.
execSkills(['update', targetName, '--global'])
await assertCurrentCanonical(targetName)
const targetProviderAfter = await packageDigestAt(await realpath(targetProvider))
const targetProviderStat = await lstat(targetProvider)
if (shape === 'symlink' && !targetProviderStat.isSymbolicLink()) {
throw new Error(`${targetName} provider alias was replaced with an independent copy`)
}
if (shape === 'symlink' && targetProviderAfter !== currentSkill(targetName).packageDigest) {
throw new Error(`${targetName} provider alias did not converge with the canonical update`)
}
if (
shape === 'copy' &&
targetProviderAfter !== targetProviderBefore &&
targetProviderAfter !== currentSkill(targetName).packageDigest
) {
throw new Error('Independent provider copy changed to an unexpected package identity')
}
if (shape === 'copy') {
// Why: hosted 1.5.17 replaces copies with aliases while equivalent local runs
// retain the copy. Both prove this input topology must remain ineligible.
const outcome = targetProviderStat.isSymbolicLink()
? 'converged to an alias'
: targetProviderAfter === targetProviderBefore
? 'remained a historical copy'
: 'converged as a copy'
console.log(`[skill-update-roundtrip] independent copy ${outcome}`)
}
if ((await packageDigestAt(controlCanonical)) !== controlBefore) {
throw new Error('Targeted update changed the non-targeted control skill')
}
if ((await packageDigestAt(await realpath(controlProvider))) !== controlProviderBefore) {
throw new Error('Targeted update changed the non-targeted control provider placement')
}
const controlProviderStat = await lstat(controlProvider)
if (shape === 'symlink' && !controlProviderStat.isSymbolicLink()) {
throw new Error('Targeted update changed the non-targeted control topology')
}
} finally {
await rm(sandbox, { recursive: true, force: true })
}
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 5.6m">
<title>downloads: 5.6m</title>
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 5.8m">
<title>downloads: 5.8m</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@@ -15,7 +15,7 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
<text x="37" y="14">downloads</text>
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">5.6m</text>
<text x="93.5" y="14">5.6m</text>
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">5.8m</text>
<text x="93.5" y="14">5.8m</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 943 B

After

Width:  |  Height:  |  Size: 943 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 394 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 434 KiB

+2 -3
View File
@@ -235,10 +235,9 @@ yay -S stably-orca-bin
- **Discord:** 加入 **[Discord](https://discord.gg/fzjDKHxv8Q)** 社区。
- **Twitter / X:** 关注 **[@orca_build](https://x.com/orca_build)** 获取更新和公告。
- **微信:** 扫描二维码加入社区。如果第一个群已满,请使用备用微信群。
- **微信:** 第一个微信群已满,现在可以加入第二个群。
<img src="../assets/wechat-qr.png" alt="Orca 社区微信群二维码" width="160" />
<img src="../assets/wechat-qr-backup.jpg" alt="Orca 社区备用微信群二维码" width="160" />
<img src="../assets/wechat-qr.jpg" alt="Orca 社区微信群二维码" width="160" />
- **反馈与想法:** 我们发布很快。缺少什么功能?[提交功能请求](https://github.com/stablyai/orca/issues)。
- **隐私:** 查看[隐私与遥测文档](https://www.onorca.dev/docs/telemetry),了解 Orca 收集哪些匿名使用数据以及如何退出。
@@ -46,6 +46,22 @@ persisted physical-to-owner pane-key alias for detach, plus a live Electron
parked-close gate. Focused tests cover local, SSH, split, parked, shared,
paired-host, ordinary runtime, pane-transfer, and late-spawn paths.
`orca terminal close --tab` extends that same ownership boundary to automation.
With an attached renderer, main sends a request/reply close, the renderer runs
the canonical retirement path, persists a fresh host-partitioned session, and
forces the main store to disk before acknowledging success. Without a renderer,
the runtime applies the equivalent immutable projection to the persisted
aggregate tab, removes every split binding and resume record, safely selects a
surviving browser/editor/terminal surface, stops exact live or durable SSH PTYs,
and flushes before returning. The ordinary `terminal close` pane/session
contract is unchanged.
This durability boundary does not use replay fences or tombstones. Renderer
session writes are ordered through the same IPC sender, and a pending debounced
write rebuilds from the latest store state, so the forced fresh snapshot is not
followed by a captured pre-close topology patch. Headless closes update the
single main-owned session projection directly.
Origin-aware defensive expiry remains a follow-up. Immediate deletion on
explicit close fixes the incident without applying a wall-clock policy that
could invalidate intentional long-lived `worktree-sleep` checkpoints.
+164 -158
View File
@@ -26,10 +26,8 @@ import {
Bot,
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronsRight,
Copy,
Eraser,
Folder,
File,
FileText,
@@ -174,6 +172,7 @@ import {
type MobileNewTabAgentSettings
} from '../../../../src/session/mobile-new-tab-agent-options'
import { useMobileImageAttachment } from '../../../../src/session/use-mobile-image-attachment'
import { useMobileAttachmentInputLeaseGate } from '../../../../src/session/use-mobile-attachment-input-lease-gate'
import { useMobileTerminalPaste } from '../../../../src/session/use-mobile-terminal-paste'
import { useTerminalLiveInputModePreference } from '../../../../src/session/use-terminal-live-input-mode-preference'
import { MobileTerminalLiveInputStatus } from '../../../../src/session/MobileTerminalLiveInputStatus'
@@ -198,6 +197,15 @@ import {
isDictationSetupRequiredError
} from '../../../../src/dictation/mobile-dictation-setup'
import { TerminalPaneView } from '../../../../src/session/TerminalPaneView'
import { MobileNativeChatOverlay } from '../../../../src/session/MobileNativeChatOverlay'
import { MobileBrowserTabActionSheet } from '../../../../src/session/MobileBrowserTabActionSheet'
import { useMobileNativeChatController } from '../../../../src/session/use-mobile-native-chat-controller'
import { useMobileNativeChatReadability } from '../../../../src/session/use-mobile-native-chat-readability'
import { useMobileNativeChatInputLease } from '../../../../src/session/use-mobile-native-chat-input-lease'
import { getMobileTerminalActionSheetActions } from '../../../../src/session/mobile-terminal-action-sheet-actions'
import * as nativeChatTerminalStream from '../../../../src/session/mobile-native-chat-terminal-stream'
import { useMobileNativeChatTerminalStream } from '../../../../src/session/use-mobile-native-chat-terminal-stream'
import { subscribeMobileTerminalSafely } from '../../../../src/session/mobile-terminal-stream-subscribe'
import {
activateMobileSessionTab,
focusMobileTerminal
@@ -249,8 +257,6 @@ import type {
TerminalGestureInputQueue
} from './mobile-session-route-types'
type TerminalLiveAccessoryInput = ReturnType<typeof createTerminalLiveAccessoryInput>
const TERMINAL_KEYBOARD_DISMISS_ACTION_SHEET_FALLBACK_MS = 450
function MarkdownReader({
@@ -1167,11 +1173,48 @@ export default function SessionScreen() {
},
[clearToastHideTimer]
)
const showNativeChatSendError = useCallback(
(message: string) => showToast(message, 1600),
[showToast]
)
const nativeChatTranscriptIsLocalReadable = useMobileNativeChatReadability(client, worktreeId)
const {
ready: nativeChatInputLeaseReady,
readyRef: nativeChatInputLeaseReadyRef,
lockReason: nativeChatInputLockReason,
markReady: markNativeChatInputLeaseReady,
clear: clearNativeChatInputLease
} = useMobileNativeChatInputLease({
activeHandle,
connected: connState === 'connected'
})
const nativeChatController = useMobileNativeChatController({
client,
hostId,
worktreeId,
activeSessionTab,
activeSessionTabId,
activeHandleRef,
deviceTokenRef,
nativeChatTranscriptIsLocalReadable,
nativeChatInputLeaseReady,
onSendError: showNativeChatSendError
})
const { chatTabIds, toggleTabChatView, showNativeChat, showNativeChatRef } = nativeChatController
const dictation = useMobileDictation({
client,
enabled: canSend,
onTranscript: (text) => {
// Why: dictation belongs to the visible composer. Native chat consumes it
// locally; terminal mode retains the live-input routing and flush contract.
if (showNativeChatRef.current) {
nativeChatController.setChatComposerText((current) =>
appendBufferedDictation(current, text)
)
showToast('Dictation inserted')
return
}
// Live mode inserts the transcript straight into its originating PTY as
// text (no Return — the user sends it themselves), matching live keystroke
// semantics; buffered mode keeps appending to the command field.
@@ -1290,22 +1333,27 @@ export default function SessionScreen() {
return handle ? terminalRefs.current.get(handle) : undefined
}, [])
const unsubscribeTerminal = useCallback((handle: string) => {
terminalUnsubsRef.current.get(handle)?.()
terminalUnsubsRef.current.delete(handle)
subscribingHandlesRef.current.delete(handle)
terminalDiagnosticsRef.current.terminalUnsubscribed(handle)
subscribeSeqRef.current.set(handle, (subscribeSeqRef.current.get(handle) ?? 0) + 1)
// Why: a fresh subscription will land on a new server-side state machine
// run (or the same one with a higher seq); reset the high-water mark so
// the first scrollback isn't accidentally dropped as stale.
layoutSeqRef.current.delete(handle)
}, [])
const unsubscribeTerminal = useCallback(
(handle: string) => {
terminalUnsubsRef.current.get(handle)?.()
terminalUnsubsRef.current.delete(handle)
subscribingHandlesRef.current.delete(handle)
terminalDiagnosticsRef.current.terminalUnsubscribed(handle)
subscribeSeqRef.current.set(handle, (subscribeSeqRef.current.get(handle) ?? 0) + 1)
// Why: a fresh subscription will land on a new server-side state machine
// run (or the same one with a higher seq); reset the high-water mark so
// the first scrollback isn't accidentally dropped as stale.
layoutSeqRef.current.delete(handle)
clearNativeChatInputLease(handle)
},
[clearNativeChatInputLease]
)
const unsubscribeTerminalRef = useRef(unsubscribeTerminal)
unsubscribeTerminalRef.current = unsubscribeTerminal
const clearTerminalCache = useCallback(() => {
for (const unsub of terminalUnsubsRef.current.values()) {
unsub()
}
terminalUnsubsRef.current.forEach((unsub) => unsub())
clearNativeChatInputLease()
terminalUnsubsRef.current.clear()
subscribingHandlesRef.current.clear()
initializedHandlesRef.current.clear()
@@ -1318,7 +1366,7 @@ export default function SessionScreen() {
for (const term of terminalRefs.current.values()) {
term.clear()
}
}, [])
}, [clearNativeChatInputLease])
// Why: measures the phone viewport once from the first available TerminalWebView.
// The viewport dims are passed with every subscribe call so the server can
@@ -1357,13 +1405,22 @@ export default function SessionScreen() {
logSkippedGate('subscribe-in-flight')
return
}
if (!getTerminalRef(handle)) {
logSkippedGate('no-webview-ref')
return
}
if (!webReadyHandlesRef.current.has(handle)) {
logSkippedGate('webview-not-ready')
return
const covered = nativeChatTerminalStream.isTerminalCoveredByNativeChat(
showNativeChatRef.current,
activeHandleRef.current,
handle
)
// Why: a native-chat-covered terminal subscribes as the input-floor lease
// without a mounted xterm webview, so only gate on the webview when NOT covered.
if (!covered) {
if (!getTerminalRef(handle)) {
logSkippedGate('no-webview-ref')
return
}
if (!webReadyHandlesRef.current.has(handle)) {
logSkippedGate('webview-not-ready')
return
}
}
subscribingHandlesRef.current.add(handle)
@@ -1375,13 +1432,13 @@ export default function SessionScreen() {
// The viewport is embedded in the subscribe params so the server resizes
// the PTY before serializing scrollback. This eliminates the focus→safeFit
// race and the measure→resize→resubscribe pipeline.
const unsub = client.subscribe(
'terminal.subscribe',
const unsub = subscribeMobileTerminalSafely(
client,
{
terminal: handle,
client: { id: deviceTokenRef.current!, type: 'mobile' as const },
viewport: viewportRef.current ?? undefined,
capabilities: { terminalBinaryStream: 1 }
capabilities: nativeChatTerminalStream.mobileNativeChatTerminalCapabilities(covered)
},
(result) => {
if (subscribeSeqRef.current.get(handle) !== seq) {
@@ -1389,6 +1446,25 @@ export default function SessionScreen() {
}
const data = result as Record<string, unknown>
diagnostics.firstStreamEvent(handle, seq, data.type)
if (data.type === 'end' || data.type === 'error') {
unsubscribeTerminalRef.current(handle)
return
}
if (data.type === 'subscribed') {
markNativeChatInputLeaseReady(handle)
return
}
// Why: retain the subscription as the mobile input-floor lease, but
// do not mutate covered xterm state; return-to-terminal resubscribes.
if (
nativeChatTerminalStream.isTerminalCoveredByNativeChat(
showNativeChatRef.current,
activeHandleRef.current,
handle
)
) {
return
}
// Why: stale-event filter. Server-side state machine bumps a
// monotonic seq on every applyLayout. Drop `resized` events
// whose seq is strictly older than what we've already observed
@@ -1417,9 +1493,6 @@ export default function SessionScreen() {
} else if (eventSeq != null && data.type === 'scrollback') {
layoutSeqRef.current.set(handle, eventSeq)
}
if (data.type === 'subscribed') {
return
}
if (data.type === 'scrollback') {
diagnostics.streamScrollback(handle, seq, eventSeq, data)
if (initializedHandlesRef.current.has(handle)) {
@@ -1572,7 +1645,8 @@ export default function SessionScreen() {
}
scheduleDelayedAction(() => getTerminalRef(handle)?.resetZoom(), 200)
}
}
},
() => unsubscribeTerminalRef.current(handle)
)
if (subscribeSeqRef.current.get(handle) === seq) {
@@ -1582,9 +1656,21 @@ export default function SessionScreen() {
}
subscribingHandlesRef.current.delete(handle)
},
[client, getTerminalRef, scheduleDelayedAction]
[client, getTerminalRef, markNativeChatInputLeaseReady, scheduleDelayedAction]
)
const notifyTerminalWebReady = useMobileNativeChatTerminalStream({
showNativeChat,
activeHandle,
activeTabType: activeSessionTab?.type ?? null,
subscriptionsRef: terminalUnsubsRef,
subscribingRef: subscribingHandlesRef,
webReadyRef: webReadyHandlesRef,
initializedRef: initializedHandlesRef,
subscribe: subscribeToTerminal,
unsubscribe: unsubscribeTerminal
})
// Why: toggles between phone and desktop mode via server RPC. The server
// handles the actual resize and emits a 'resized' event on the existing
// subscription stream — no client-side state tracking needed.
@@ -3064,6 +3150,7 @@ export default function SessionScreen() {
(handle: string) => {
const wasAlreadyReady = webReadyHandlesRef.current.has(handle)
webReadyHandlesRef.current.add(handle)
notifyTerminalWebReady(handle, wasAlreadyReady)
terminalDiagnosticsRef.current.webViewReady(
handle,
wasAlreadyReady,
@@ -3105,7 +3192,7 @@ export default function SessionScreen() {
})()
}
},
[getTerminalRef, measureViewportOnce, subscribeToTerminal, unsubscribeTerminal]
[measureViewportOnce, notifyTerminalWebReady, subscribeToTerminal, unsubscribeTerminal]
)
useEffect(() => {
@@ -3156,7 +3243,7 @@ export default function SessionScreen() {
}
}
async function handleAccessoryKey(input: TerminalLiveAccessoryInput) {
async function handleAccessoryKey(input: ReturnType<typeof createTerminalLiveAccessoryInput>) {
if (!client || !activeHandle || !canSend) {
return
}
@@ -3643,7 +3730,7 @@ export default function SessionScreen() {
}
}, [])
const startAccessoryRepeat = useCallback(
(input: TerminalLiveAccessoryInput) => {
(input: ReturnType<typeof createTerminalLiveAccessoryInput>) => {
stopAccessoryRepeat()
repeatTimeoutRef.current = setTimeout(() => {
repeatIntervalRef.current = setInterval(() => {
@@ -3815,19 +3902,14 @@ export default function SessionScreen() {
showToast
})
const flushPendingLiveInputBeforeAttachmentSend = useCallback(
async (targetHandle: string): Promise<boolean> => {
const flushedPendingInput = await flushPendingLiveInputBeforeExternalSend(targetHandle)
// Why: image picking/upload and IME flushing can outlive the original tab.
return (
flushedPendingInput &&
connStateRef.current === 'connected' &&
targetHandle === activeHandleRef.current &&
activeSessionTabTypeRef.current === 'terminal'
)
},
[flushPendingLiveInputBeforeExternalSend]
)
const flushPendingLiveInputBeforeAttachmentSend = useMobileAttachmentInputLeaseGate({
flushPendingLiveInputBeforeExternalSend,
connStateRef,
activeHandleRef,
activeSessionTabTypeRef,
nativeChatInputLeaseReadyRef,
showToast
})
const { attachImage, isAttaching } = useMobileImageAttachment({
client,
@@ -4887,6 +4969,18 @@ export default function SessionScreen() {
onOpenUrl={handleTerminalOpenUrl}
/>
))}
<MobileNativeChatOverlay
controller={nativeChatController}
onAttachImage={() => void attachImage('library')}
isAttaching={isAttaching}
onMicPress={handleDictationToggle}
micActive={dictation.isRecording}
dictationMode={dictationMode}
onMicPressIn={handleDictationPressIn}
onMicPressOut={handleDictationPressOut}
inputLockReason={nativeChatInputLockReason}
keyboardInset={keyboardLift}
/>
{toastMessage && (
<Animated.View pointerEvents="none" style={[styles.toast, toastAnimatedStyle]}>
<Text style={styles.toastText}>{toastMessage}</Text>
@@ -4896,8 +4990,9 @@ export default function SessionScreen() {
)}
{/* Why: translate instead of resizing so keyboard open/close does not
trigger a server-side PTY viewport change. */}
{!activeMarkdownTab && !activeFileTab && !activeBrowserTab && (
trigger a server-side PTY viewport change. The dock hides in native
chat because that view supplies its own composer. */}
{!activeMarkdownTab && !activeFileTab && !activeBrowserTab && !showNativeChat && (
<View
style={[
styles.commandDock,
@@ -5318,55 +5413,19 @@ export default function SessionScreen() {
<ActionSheetModal
visible={actionTarget != null}
title={actionTarget?.title || 'Terminal'}
actions={[
...(actionTarget
? [
{
label: isPhoneMode(actionTarget.handle) ? 'Switch to Desktop' : 'Switch to Phone',
icon: isPhoneMode(actionTarget.handle) ? Monitor : Smartphone,
onPress: () => {
const target = actionTarget
setActionTarget(null)
if (target) {
void toggleDisplayMode(target.handle)
}
}
}
]
: []),
{
label: 'Rename',
onPress: () => {
const target = actionTarget
setActionTarget(null)
if (target) {
setRenameTarget(target)
}
}
},
{
label: 'Clear Terminal',
icon: Eraser,
onPress: () => {
const target = actionTarget
setActionTarget(null)
if (target) {
void handleClearTerminal(target)
}
}
},
{
label: 'Close',
destructive: true,
onPress: () => {
const target = actionTarget
setActionTarget(null)
if (target) {
void handleCloseTerminal(target)
}
}
}
]}
actions={getMobileTerminalActionSheetActions({
target: actionTarget,
tabs: sessionTabs.filter((tab) => tab.type === 'terminal'),
chatTabIds,
nativeChatTranscriptIsLocalReadable,
onDismiss: () => setActionTarget(null),
onToggleChat: toggleTabChatView,
isPhoneMode,
onToggleDisplayMode: (handle) => void toggleDisplayMode(handle),
onRename: setRenameTarget,
onClear: (target) => void handleClearTerminal(target),
onClose: (target) => void handleCloseTerminal(target)
})}
onClose={() => setActionTarget(null)}
/>
<ActionSheetModal
@@ -5439,64 +5498,11 @@ export default function SessionScreen() {
]}
onClose={() => setFileActionTarget(null)}
/>
<ActionSheetModal
visible={browserActionTarget != null}
title={browserActionTarget ? getMobileSessionTabTitle(browserActionTarget) : 'Browser'}
actions={[
...(browserActionTarget?.canGoBack
? [
{
label: 'Back',
icon: ChevronLeft,
onPress: () => {
const target = browserActionTarget
setBrowserActionTarget(null)
if (target) {
void handleBrowserNavigationCommand(target, 'browser.back')
}
}
}
]
: []),
...(browserActionTarget?.canGoForward
? [
{
label: 'Forward',
icon: ChevronRight,
onPress: () => {
const target = browserActionTarget
setBrowserActionTarget(null)
if (target) {
void handleBrowserNavigationCommand(target, 'browser.forward')
}
}
}
]
: []),
{
label: 'Reload',
icon: RefreshCw,
onPress: () => {
const target = browserActionTarget
setBrowserActionTarget(null)
if (target) {
void handleBrowserNavigationCommand(target, 'browser.reload')
}
}
},
{
label: 'Close',
destructive: true,
onPress: () => {
const target = browserActionTarget
setBrowserActionTarget(null)
if (target) {
void handleCloseSessionTab(target)
}
}
}
]}
<MobileBrowserTabActionSheet
target={browserActionTarget}
onClose={() => setBrowserActionTarget(null)}
onNavigate={handleBrowserNavigationCommand}
onCloseTab={handleCloseSessionTab}
/>
<ActionSheetModal
visible={leaveDrafts != null}
@@ -23,6 +23,8 @@ export type MobileSessionTab =
status?: 'pending-handle' | 'ready'
terminal: string | null
agentStatus?: AgentStatusEntry | null
/** Agent Orca launched in this terminal, if any. This makes chat eligible
* before the first live agent-status update reaches the mobile client. */
launchAgent?: TuiAgent
terminalTheme?: MobileTerminalTheme
isActive: boolean
+80 -162
View File
@@ -1,12 +1,24 @@
import { Fragment, memo, useMemo, type ReactNode } from 'react'
import { Linking, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import { Linking, Pressable, ScrollView, Text, View } from 'react-native'
import { normalizeMobileMarkdownPreviewHtml } from './mobile-markdown-preview-html'
import { styles } from './mobile-markdown-styles'
import {
detectFilePathSegments,
isFilePathCodeSpan,
normalizeFilePath
} from './markdown-file-path-detection'
import { parseMobileMarkdown } from './mobile-markdown-parser'
type Props = {
content?: string
fallback?: string
/** 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
* paths render as plain text (no behavior change). */
onOpenFile?: (relativePath: string) => void
}
const MAX_TABLE_ROWS = 40
@@ -19,7 +31,37 @@ function openMarkdownUrl(url: string): void {
}
}
function renderInline(text: string): ReactNode[] {
// Render a plain (non-token) text run, splitting out tappable file paths when
// onOpenFile is provided. Without it, paths stay plain text.
function renderTextRun(
text: string,
keyPrefix: string,
onOpenFile?: (relativePath: string) => void
): ReactNode {
if (!onOpenFile) {
return text
}
const segments = detectFilePathSegments(text)
if (segments.length === 1 && segments[0]!.type === 'text') {
return text
}
return segments.map((segment, segmentIndex) => {
if (segment.type === 'file') {
return (
<Text
key={`${keyPrefix}:${segmentIndex}`}
style={styles.link}
onPress={() => onOpenFile(segment.path)}
>
{segment.value}
</Text>
)
}
return <Fragment key={`${keyPrefix}:${segmentIndex}`}>{segment.value}</Fragment>
})
}
function renderInline(text: string, onOpenFile?: (relativePath: string) => void): ReactNode[] {
const parts: ReactNode[] = []
const pattern =
/(!\[[^\]]*\]\([^)]+\)|`[^`]+`|~~[^~]+~~|\*\*[^*]+\*\*|__[^_]+__|\*[^*\n]+\*|_[^_\n]+_|\[[^\]]+\]\([^)]+\)|https?:\/\/[^\s<]+)/g
@@ -28,7 +70,7 @@ function renderInline(text: string): ReactNode[] {
while ((match = pattern.exec(text))) {
if (match.index > lastIndex) {
parts.push(text.slice(lastIndex, match.index))
parts.push(renderTextRun(text.slice(lastIndex, match.index), `t${lastIndex}`, onOpenFile))
}
const token = match[0]
const key = `${match.index}:${token}`
@@ -53,11 +95,24 @@ function renderInline(text: string): ReactNode[] {
</Text>
)
} else if (token.startsWith('`')) {
parts.push(
<Text key={key} style={styles.inlineCode}>
{token.slice(1, -1)}
</Text>
)
const code = token.slice(1, -1)
if (onOpenFile && isFilePathCodeSpan(code)) {
parts.push(
<Text
key={key}
style={[styles.inlineCode, styles.inlineCodeLink]}
onPress={() => onOpenFile(normalizeFilePath(code.trim()))}
>
{code}
</Text>
)
} else {
parts.push(
<Text key={key} style={styles.inlineCode}>
{code}
</Text>
)
}
} else if (token.startsWith('~~')) {
parts.push(
<Text key={key} style={styles.strike}>
@@ -81,15 +136,20 @@ function renderInline(text: string): ReactNode[] {
}
if (lastIndex < text.length) {
parts.push(text.slice(lastIndex))
parts.push(renderTextRun(text.slice(lastIndex), `t${lastIndex}`, onOpenFile))
}
return parts
}
function MobileMarkdownInner({ content, fallback = '' }: Props) {
function MobileMarkdownInner({ content, fallback = '', textScale = 1, onOpenFile }: Props) {
const text = content?.trim() ?? ''
const previewText = useMemo(() => normalizeMobileMarkdownPreviewHtml(text), [text])
const blocks = useMemo(() => parseMobileMarkdown(previewText), [previewText])
// Scale prose sizes; inline spans inherit fontSize from the wrapping Text.
const scaled = (size: number): { fontSize: number; lineHeight: number } | null =>
textScale !== 1 ? { fontSize: size * textScale, lineHeight: (size + 6) * textScale } : null
const proseScale = scaled(13)
const listScale = scaled(14)
if (!text) {
return fallback ? <Text style={styles.paragraph}>{fallback}</Text> : null
}
@@ -103,14 +163,14 @@ function MobileMarkdownInner({ content, fallback = '' }: Props) {
key={index}
style={[styles.heading, block.level <= 2 ? styles.headingLarge : null]}
>
{renderInline(block.text)}
{renderInline(block.text, onOpenFile)}
</Text>
)
}
if (block.type === 'quote') {
return (
<View key={index} style={styles.quote}>
<Text style={styles.quoteText}>{renderInline(block.text)}</Text>
<Text style={styles.quoteText}>{renderInline(block.text, onOpenFile)}</Text>
</View>
)
}
@@ -147,7 +207,7 @@ function MobileMarkdownInner({ content, fallback = '' }: Props) {
<View style={styles.tableRow}>
{visibleHeaders.map((header, cellIndex) => (
<Text key={cellIndex} style={[styles.tableCell, styles.tableHeader]}>
{renderInline(header)}
{renderInline(header, onOpenFile)}
</Text>
))}
</View>
@@ -155,7 +215,7 @@ function MobileMarkdownInner({ content, fallback = '' }: Props) {
<View key={rowIndex} style={styles.tableRow}>
{visibleHeaders.map((_, cellIndex) => (
<Text key={cellIndex} style={styles.tableCell}>
{renderInline(row[cellIndex] ?? '')}
{renderInline(row[cellIndex] ?? '', onOpenFile)}
</Text>
))}
</View>
@@ -185,7 +245,9 @@ function MobileMarkdownInner({ content, fallback = '' }: Props) {
? '[x]'
: '[ ]'}
</Text>
<Text style={styles.listText}>{renderInline(item.text)}</Text>
<Text style={[styles.listText, listScale]}>
{renderInline(item.text, onOpenFile)}
</Text>
</View>
))}
</View>
@@ -195,11 +257,11 @@ function MobileMarkdownInner({ content, fallback = '' }: Props) {
return <View key={index} style={styles.rule} />
}
return (
<Text key={index} style={styles.paragraph}>
<Text key={index} style={[styles.paragraph, proseScale]}>
{block.text.split('\n').map((line, lineIndex) => (
<Fragment key={lineIndex}>
{lineIndex > 0 ? '\n' : null}
{renderInline(line)}
{renderInline(line, onOpenFile)}
</Fragment>
))}
</Text>
@@ -210,147 +272,3 @@ function MobileMarkdownInner({ content, fallback = '' }: Props) {
}
export const MobileMarkdown = memo(MobileMarkdownInner)
const styles = StyleSheet.create({
root: {
gap: spacing.sm
},
paragraph: {
fontSize: 13,
lineHeight: 19,
color: colors.textPrimary
},
heading: {
fontSize: 14,
lineHeight: 20,
fontWeight: '700',
color: colors.textPrimary
},
headingLarge: {
fontSize: 15,
lineHeight: 21
},
bold: {
fontWeight: '700',
color: colors.textPrimary
},
italic: {
fontStyle: 'italic'
},
strike: {
textDecorationLine: 'line-through'
},
link: {
color: colors.accentBlue,
textDecorationLine: 'underline'
},
inlineCode: {
fontFamily: typography.monoFamily,
fontSize: 12,
color: colors.textPrimary,
backgroundColor: colors.bgRaised,
borderRadius: radii.row,
paddingHorizontal: 4
},
quote: {
borderLeftWidth: 2,
borderLeftColor: colors.borderSubtle,
paddingLeft: spacing.sm
},
quoteText: {
fontSize: 13,
lineHeight: 19,
color: colors.textSecondary
},
codeBlock: {
backgroundColor: colors.bgRaised,
borderWidth: 1,
borderColor: colors.borderSubtle,
borderRadius: radii.input,
padding: spacing.sm
},
codeLanguage: {
fontSize: 10,
color: colors.textMuted,
marginBottom: spacing.xs,
textTransform: 'uppercase'
},
codeText: {
fontFamily: typography.monoFamily,
fontSize: 12,
lineHeight: 17,
color: colors.textPrimary
},
imageFrame: {
borderWidth: 1,
borderColor: colors.borderSubtle,
borderRadius: radii.input,
backgroundColor: colors.bgRaised,
overflow: 'hidden',
padding: spacing.sm
},
imageCaption: {
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
fontSize: 11,
color: colors.textSecondary
},
table: {
borderTopWidth: 1,
borderLeftWidth: 1,
borderColor: colors.borderSubtle,
borderRadius: radii.input,
overflow: 'hidden',
backgroundColor: colors.bgPanel
},
tableRow: {
flexDirection: 'row'
},
tableCell: {
minWidth: 112,
maxWidth: 220,
borderRightWidth: 1,
borderBottomWidth: 1,
borderColor: colors.borderSubtle,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
fontSize: 12,
lineHeight: 17,
color: colors.textPrimary
},
tableHeader: {
fontWeight: '700',
backgroundColor: colors.bgRaised
},
tableTruncated: {
padding: spacing.sm,
fontSize: 12,
color: colors.textMuted
},
list: {
gap: spacing.xs
},
listItem: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: spacing.sm
},
listMarker: {
width: 22,
fontSize: 13,
lineHeight: 19,
color: colors.textSecondary,
fontFamily: typography.monoFamily
},
listText: {
flex: 1,
minWidth: 0,
fontSize: 13,
lineHeight: 19,
color: colors.textPrimary
},
rule: {
height: StyleSheet.hairlineWidth,
backgroundColor: colors.borderSubtle
}
})
@@ -0,0 +1,190 @@
import { describe, expect, it } from 'vitest'
import {
detectFilePathSegments,
isFilePathCodeSpan,
normalizeFilePath
} from './markdown-file-path-detection'
describe('detectFilePathSegments', () => {
it('returns a single text segment when there is no path', () => {
expect(detectFilePathSegments('just some prose here')).toEqual([
{ type: 'text', value: 'just some prose here' }
])
})
it('detects a relative source path with surrounding prose', () => {
const segments = detectFilePathSegments('Edit src/app/Main.tsx now')
expect(segments).toEqual([
{ type: 'text', value: 'Edit ' },
{ type: 'file', value: 'src/app/Main.tsx', path: 'src/app/Main.tsx' },
{ type: 'text', value: ' now' }
])
})
it('strips a leading ./ in the path but keeps the displayed value', () => {
const segments = detectFilePathSegments('see ./lib/x.ts')
expect(segments).toEqual([
{ type: 'text', value: 'see ' },
{ type: 'file', value: './lib/x.ts', path: 'lib/x.ts' }
])
})
it('keeps ../ parent-relative paths intact', () => {
const segments = detectFilePathSegments('../shared/util.ts')
expect(segments).toEqual([
{ type: 'file', value: '../shared/util.ts', path: '../shared/util.ts' }
])
})
it('detects multiple paths in one run', () => {
const segments = detectFilePathSegments('a/b.ts and c/d/e.json')
expect(segments.filter((s) => s.type === 'file')).toEqual([
{ type: 'file', value: 'a/b.ts', path: 'a/b.ts' },
{ type: 'file', value: 'c/d/e.json', path: 'c/d/e.json' }
])
})
it('detects Windows relative, drive, and UNC paths', () => {
const segments = detectFilePathSegments(
String.raw`Edit src\app\Main.tsx, C:\repo\config.json, and \\server\share\docs\readme.md`
)
expect(segments.filter((segment) => segment.type === 'file')).toEqual([
{ type: 'file', value: String.raw`src\app\Main.tsx`, path: String.raw`src\app\Main.tsx` },
{
type: 'file',
value: String.raw`C:\repo\config.json`,
path: String.raw`C:\repo\config.json`
},
{
type: 'file',
value: String.raw`\\server\share\docs\readme.md`,
path: String.raw`\\server\share\docs\readme.md`
}
])
})
it('does not match bare filenames without a slash', () => {
expect(detectFilePathSegments('open Main.tsx please')).toEqual([
{ type: 'text', value: 'open Main.tsx please' }
])
})
it('does not match URLs', () => {
expect(detectFilePathSegments('https://example.com/path/file.ts')).toEqual([
{ type: 'text', value: 'https://example.com/path/file.ts' }
])
})
it('does not match version numbers', () => {
expect(detectFilePathSegments('upgraded to 1.2.3 today')).toEqual([
{ type: 'text', value: 'upgraded to 1.2.3 today' }
])
})
it('does not match unknown extensions', () => {
expect(detectFilePathSegments('path/to/thing.whatever')).toEqual([
{ type: 'text', value: 'path/to/thing.whatever' }
])
})
it('detects scoped-package file paths with a segment-leading @', () => {
expect(detectFilePathSegments('open @types/react/index.d.ts here')).toEqual([
{ type: 'text', value: 'open ' },
{
type: 'file',
value: '@types/react/index.d.ts',
path: '@types/react/index.d.ts'
},
{ type: 'text', value: ' here' }
])
expect(
detectFilePathSegments('node_modules/@scope/pkg/file.ts').filter((s) => s.type === 'file')
).toEqual([
{
type: 'file',
value: 'node_modules/@scope/pkg/file.ts',
path: 'node_modules/@scope/pkg/file.ts'
}
])
})
it('does not match emails or git URLs with a mid-token @', () => {
expect(detectFilePathSegments('clone git@github.com:user/repo.git')).toEqual([
{ type: 'text', value: 'clone git@github.com:user/repo.git' }
])
expect(detectFilePathSegments('open user@host.com/path/file.txt')).toEqual([
{ type: 'text', value: 'open user@host.com/path/file.txt' }
])
})
it('returns a single text segment when the run has no dot', () => {
const text = 'a/'.repeat(8192)
expect(detectFilePathSegments(text)).toEqual([{ type: 'text', value: text }])
})
it('skips detection for runs over the length cap even with dots', () => {
// 'a.b/'-repeats pass the dot precheck, so this exercises the length cap that
// bounds CANDIDATE_PATTERN's worst-case backtracking.
const text = 'a.b/'.repeat(2000)
expect(detectFilePathSegments(text)).toEqual([{ type: 'text', value: text }])
})
it('still detects a path in a long-but-under-cap run', () => {
const prefix = 'context '.repeat(200)
const segments = detectFilePathSegments(`${prefix}src/app/Main.tsx`)
expect(segments.filter((s) => s.type === 'file')).toEqual([
{ type: 'file', value: 'src/app/Main.tsx', path: 'src/app/Main.tsx' }
])
})
})
describe('isFilePathCodeSpan', () => {
it('accepts a slashed path code span', () => {
expect(isFilePathCodeSpan('src/app/Main.tsx')).toBe(true)
})
it('accepts Windows paths in code spans', () => {
expect(isFilePathCodeSpan(String.raw`src\app\Main.tsx`)).toBe(true)
expect(isFilePathCodeSpan(String.raw`C:\repo\Main.tsx`)).toBe(true)
expect(isFilePathCodeSpan(String.raw`\\server\share\Main.tsx`)).toBe(true)
})
it('accepts a bare filename code span', () => {
expect(isFilePathCodeSpan('package.json')).toBe(true)
})
it('rejects multi-word code spans', () => {
expect(isFilePathCodeSpan('npm run build')).toBe(false)
})
it('rejects non-file code spans', () => {
expect(isFilePathCodeSpan('someVariable')).toBe(false)
})
it('rejects urls in code spans', () => {
expect(isFilePathCodeSpan('https://x.com/a.ts')).toBe(false)
})
it('accepts scoped-package paths with a segment-leading @', () => {
expect(isFilePathCodeSpan('@types/react/index.d.ts')).toBe(true)
expect(isFilePathCodeSpan('node_modules/@scope/pkg/file.ts')).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('normalizeFilePath', () => {
it('strips a leading ./', () => {
expect(normalizeFilePath('./a/b.ts')).toBe('a/b.ts')
expect(normalizeFilePath(String.raw`.\a\b.ts`)).toBe(String.raw`a\b.ts`)
})
it('leaves other paths unchanged', () => {
expect(normalizeFilePath('../a/b.ts')).toBe('../a/b.ts')
expect(normalizeFilePath('a/b.ts')).toBe('a/b.ts')
})
})
@@ -0,0 +1,212 @@
// Conservative detection of file-path-like tokens inside an inline markdown text
// run, so the chat view can render them as tappable (opening the mobile file
// viewer). We deliberately favor precision over recall: a missed path is a minor
// annoyance, but a false positive on prose or a version number is a broken tap.
export type FilePathSegment =
| { type: 'text'; value: string }
| { type: 'file'; value: string; path: string }
// Common source/code/config extensions we treat as openable file paths. Kept
// explicit (rather than "any extension") so prose like "etc." or "e.g." and
// domain-ish tokens like "example.com" don't get matched.
const FILE_EXTENSIONS = [
'ts',
'tsx',
'js',
'jsx',
'mjs',
'cjs',
'json',
'jsonc',
'css',
'scss',
'sass',
'less',
'html',
'htm',
'md',
'mdx',
'markdown',
'txt',
'py',
'rb',
'go',
'rs',
'java',
'kt',
'kts',
'swift',
'c',
'h',
'cc',
'cpp',
'hpp',
'cs',
'php',
'sh',
'bash',
'zsh',
'fish',
'yml',
'yaml',
'toml',
'ini',
'cfg',
'conf',
'env',
'lock',
'sql',
'graphql',
'gql',
'proto',
'xml',
'svg',
'vue',
'svelte',
'astro',
'dart',
'ex',
'exs',
'erl',
'lua',
'pl',
'r',
'scala',
'clj',
'gradle',
'dockerfile',
'gitignore',
'npmrc'
] as const
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.
const CANDIDATE_PATTERN =
/(?:[A-Za-z]:[\\/]|\\\\)?(?:\.{1,2}[\\/])?(?:[\w.@~+-]+[\\/])+[\w.@+-]+\.[A-Za-z0-9]+/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
// skip detection entirely above this cap.
const MAX_DETECTION_LENGTH = 2000
// A mid-token '@' (one preceded by a non-separator) marks an email or git URL such
// as git@github.com; a segment-leading '@' is a scoped package dir (@scope/…) and
// stays eligible.
function hasMidTokenAt(candidate: string): boolean {
return /[^\\/]@/.test(candidate)
}
function isOpenablePath(candidate: string): boolean {
// Reject anything URL-ish or scheme-bearing — those are handled as web links.
if (candidate.includes('://') || hasMidTokenAt(candidate)) {
return false
}
// Must contain a separator (a bare "file.ts" is too ambiguous in prose).
if (!/[\\/]/.test(candidate)) {
return false
}
const lastSeparator = Math.max(candidate.lastIndexOf('/'), candidate.lastIndexOf('\\'))
const lastSegment = candidate.slice(lastSeparator + 1)
const dot = lastSegment.lastIndexOf('.')
// A leading-dot dotfile in the final segment (e.g. ".env") has no extension to
// anchor on; require a real name.ext shape.
if (dot <= 0) {
return false
}
const ext = lastSegment.slice(dot + 1).toLowerCase()
if (!EXTENSION_SET.has(ext)) {
return false
}
// Guard against version-number-ish tails ("1.2.3" style) where the "extension"
// is purely numeric — those aren't files.
if (/^\d+$/.test(ext)) {
return false
}
return true
}
// Strip the leading ./ marker so callers receive a clean worktree-relative path,
// while keeping ../ (which is meaningful) intact.
export function normalizeFilePath(path: string): string {
return path.replace(/^\.[\\/]/, '')
}
/**
* Given a plain inline text run, return ordered segments marking which substrings
* are openable file paths. Non-path text is preserved verbatim so the renderer can
* reassemble the run exactly. Returns a single text segment when nothing matches.
*/
export function detectFilePathSegments(text: string): FilePathSegment[] {
// Every real match ends in a name.ext, so a dot is mandatory; skip the regex when
// there is none, or when the run is too long to hold a chat path but long enough
// to drive CANDIDATE_PATTERN into super-linear backtracking.
if (text.length > MAX_DETECTION_LENGTH || !text.includes('.')) {
return [{ type: 'text', value: text }]
}
const segments: FilePathSegment[] = []
let lastIndex = 0
let match: RegExpExecArray | null
CANDIDATE_PATTERN.lastIndex = 0
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).
const prev = match.index > 0 ? text[match.index - 1]! : ''
if (prev === ':' || prev === '/' || /[\w.@]/.test(prev)) {
continue
}
if (!isOpenablePath(candidate)) {
continue
}
if (match.index > lastIndex) {
segments.push({ type: 'text', value: text.slice(lastIndex, match.index) })
}
segments.push({ type: 'file', value: candidate, path: normalizeFilePath(candidate) })
lastIndex = match.index + candidate.length
}
if (lastIndex < text.length) {
segments.push({ type: 'text', value: text.slice(lastIndex) })
}
// Collapse to a single text segment for the common no-match case.
if (segments.length === 0) {
return [{ type: 'text', value: text }]
}
return segments
}
/**
* True when an inline-code span's entire content is a single openable file path
* (e.g. `src/app/Main.tsx`). Code spans are a strong signal, so we also accept a
* bare `file.ts` here even without a slash.
*/
export function isFilePathCodeSpan(code: string): boolean {
const trimmed = code.trim()
if (!trimmed || /\s/.test(trimmed)) {
return false
}
if (trimmed.includes('://') || hasMidTokenAt(trimmed)) {
return false
}
if (isOpenablePath(trimmed)) {
return true
}
// Separator-less code span: accept a clean name.ext with a known extension.
if (/[\\/]/.test(trimmed)) {
return false
}
const dot = trimmed.lastIndexOf('.')
if (dot <= 0) {
return false
}
const name = trimmed.slice(0, dot)
const ext = trimmed.slice(dot + 1).toLowerCase()
if (/[^\w.@+-]/.test(name)) {
return false
}
return EXTENSION_SET.has(ext)
}
@@ -0,0 +1,150 @@
import { StyleSheet } from 'react-native'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
export const styles = StyleSheet.create({
root: {
gap: spacing.sm
},
paragraph: {
fontSize: 13,
lineHeight: 19,
color: colors.textPrimary
},
heading: {
fontSize: 14,
lineHeight: 20,
fontWeight: '700',
color: colors.textPrimary
},
headingLarge: {
fontSize: 15,
lineHeight: 21
},
bold: {
fontWeight: '700',
color: colors.textPrimary
},
italic: {
fontStyle: 'italic'
},
strike: {
textDecorationLine: 'line-through'
},
link: {
color: colors.accentBlue,
textDecorationLine: 'underline'
},
inlineCode: {
fontFamily: typography.monoFamily,
fontSize: 12,
color: colors.textPrimary,
backgroundColor: colors.bgRaised,
borderRadius: radii.row,
paddingHorizontal: 4
},
inlineCodeLink: {
color: colors.accentBlue,
textDecorationLine: 'underline'
},
quote: {
borderLeftWidth: 2,
borderLeftColor: colors.borderSubtle,
paddingLeft: spacing.sm
},
quoteText: {
fontSize: 13,
lineHeight: 19,
color: colors.textSecondary
},
codeBlock: {
backgroundColor: colors.bgRaised,
borderWidth: 1,
borderColor: colors.borderSubtle,
borderRadius: radii.input,
padding: spacing.sm
},
codeLanguage: {
fontSize: 10,
color: colors.textMuted,
marginBottom: spacing.xs,
textTransform: 'uppercase'
},
codeText: {
fontFamily: typography.monoFamily,
fontSize: 12,
lineHeight: 17,
color: colors.textPrimary
},
imageFrame: {
borderWidth: 1,
borderColor: colors.borderSubtle,
borderRadius: radii.input,
backgroundColor: colors.bgRaised,
overflow: 'hidden',
padding: spacing.sm
},
imageCaption: {
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
fontSize: 11,
color: colors.textSecondary
},
table: {
borderTopWidth: 1,
borderLeftWidth: 1,
borderColor: colors.borderSubtle,
borderRadius: radii.input,
overflow: 'hidden',
backgroundColor: colors.bgPanel
},
tableRow: {
flexDirection: 'row'
},
tableCell: {
minWidth: 112,
maxWidth: 220,
borderRightWidth: 1,
borderBottomWidth: 1,
borderColor: colors.borderSubtle,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
fontSize: 12,
lineHeight: 17,
color: colors.textPrimary
},
tableHeader: {
fontWeight: '700',
backgroundColor: colors.bgRaised
},
tableTruncated: {
padding: spacing.sm,
fontSize: 12,
color: colors.textMuted
},
list: {
gap: spacing.xs
},
listItem: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: spacing.sm
},
listMarker: {
width: 22,
fontSize: 13,
lineHeight: 19,
color: colors.textSecondary,
fontFamily: typography.monoFamily
},
listText: {
flex: 1,
minWidth: 0,
fontSize: 13,
lineHeight: 19,
color: colors.textPrimary
},
rule: {
height: StyleSheet.hairlineWidth,
backgroundColor: colors.borderSubtle
}
})
@@ -0,0 +1,64 @@
import { useEffect, useRef } from 'react'
import { Animated, StyleSheet, Text, View } from 'react-native'
import { colors, spacing, typography } from '../theme/mobile-theme'
/** Animated three-dot "agent is working" row, shown while the active agent is
* still producing a reply. Pure presentation — visibility is the caller's call. */
export function MobileAgentWorkingIndicator(): React.JSX.Element {
const dots = [
useRef(new Animated.Value(0.3)).current,
useRef(new Animated.Value(0.3)).current,
useRef(new Animated.Value(0.3)).current
]
useEffect(() => {
const animations = dots.map((dot, i) =>
Animated.loop(
Animated.sequence([
Animated.delay(i * 160),
Animated.timing(dot, { toValue: 1, duration: 320, useNativeDriver: true }),
Animated.timing(dot, { toValue: 0.3, duration: 320, useNativeDriver: true })
])
)
)
animations.forEach((a) => a.start())
return () => animations.forEach((a) => a.stop())
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return (
<View style={styles.row}>
<Text style={styles.label}>Agent is working</Text>
<View style={styles.dots}>
{dots.map((dot, i) => (
<Animated.View key={i} style={[styles.dot, { opacity: dot }]} />
))}
</View>
</View>
)
}
const styles = StyleSheet.create({
row: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm
},
label: {
color: colors.textMuted,
fontSize: typography.metaSize,
fontStyle: 'italic'
},
dots: {
flexDirection: 'row',
gap: 4
},
dot: {
width: 5,
height: 5,
borderRadius: 2.5,
backgroundColor: colors.textSecondary
}
})
@@ -0,0 +1,79 @@
import { ChevronLeft, ChevronRight, RefreshCw } from 'lucide-react-native'
import type { MobileSessionTab } from '../../app/h/[hostId]/session/mobile-session-route-types'
import { ActionSheetModal } from '../components/ActionSheetModal'
import { getMobileSessionTabTitle } from './mobile-terminal-tab-agent'
type BrowserTab = Extract<MobileSessionTab, { type: 'browser' }>
export type MobileBrowserNavigationMethod = 'browser.back' | 'browser.forward' | 'browser.reload'
/** Keeps browser-tab navigation actions out of the session route while preserving
* the target captured at the moment each drawer action is pressed. */
export function MobileBrowserTabActionSheet(props: {
target: BrowserTab | null
onClose: () => void
onNavigate: (target: BrowserTab, method: MobileBrowserNavigationMethod) => void
onCloseTab: (target: BrowserTab) => void
}): React.JSX.Element {
const { target, onClose, onNavigate, onCloseTab } = props
return (
<ActionSheetModal
visible={target != null}
title={target ? getMobileSessionTabTitle(target) : 'Browser'}
actions={[
...(target?.canGoBack
? [
{
label: 'Back',
icon: ChevronLeft,
onPress: () => {
const current = target
onClose()
if (current) {
onNavigate(current, 'browser.back')
}
}
}
]
: []),
...(target?.canGoForward
? [
{
label: 'Forward',
icon: ChevronRight,
onPress: () => {
const current = target
onClose()
if (current) {
onNavigate(current, 'browser.forward')
}
}
}
]
: []),
{
label: 'Reload',
icon: RefreshCw,
onPress: () => {
const current = target
onClose()
if (current) {
onNavigate(current, 'browser.reload')
}
}
},
{
label: 'Close',
destructive: true,
onPress: () => {
const current = target
onClose()
if (current) {
onCloseTab(current)
}
}
}
]}
onClose={onClose}
/>
)
}
+378
View File
@@ -0,0 +1,378 @@
import { useMemo, useRef, useState } from 'react'
import { Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native'
import { Check } from 'lucide-react-native'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import type { AskAnswerSelection, AskPrompt } from './mobile-native-chat-ask'
type Props = {
prompt: AskPrompt
/** Deliver the chosen answer (per-question option indices + free text) —
* index-based so Claude's arrow-navigate selector can be driven by the
* option's stable number instead of pasted label text (STA-1860). */
onAnswer: (selections: AskAnswerSelection[]) => Promise<boolean>
onCancel?: () => Promise<boolean>
}
// Sentinel index for the free-text "Other…" row (never a real option index).
const OTHER = -1
/** Native renderer for an agent's AskUserQuestion prompt as a wizard: one
* question per step with tabs across the top, a Next button that advances (Send
* on the last step), and a Cancel that dismisses the prompt. Neutral styling
* with a subtle green accent on the active choice to match the rest of the app. */
export function MobileNativeChatAsk({ prompt, onAnswer, onCancel }: Props): React.JSX.Element {
const [index, setIndex] = useState(0)
const [selections, setSelections] = useState<number[][]>(() => prompt.questions.map(() => []))
const [otherText, setOtherText] = useState<string[]>(() => prompt.questions.map(() => ''))
const [submitting, setSubmitting] = useState(false)
const submittingRef = useRef(false)
const toggle = (qi: number, optIndex: number, multi: boolean): void => {
setSelections((prev) => {
const next = prev.map((s) => [...s])
const cur = next[qi] ?? []
if (multi) {
next[qi] = cur.includes(optIndex) ? cur.filter((i) => i !== optIndex) : [...cur, optIndex]
} else {
next[qi] = cur.includes(optIndex) ? [] : [optIndex]
}
return next
})
}
const setOther = (qi: number, value: string): void => {
setOtherText((prev) => {
const next = [...prev]
next[qi] = value
return next
})
}
const selectionFor = (qi: number): AskAnswerSelection => {
const picked = (selections[qi] ?? []).filter((i) => i !== OTHER)
const other = (selections[qi] ?? []).includes(OTHER) ? (otherText[qi] ?? '').trim() : ''
return other ? { indices: picked, other } : { indices: picked }
}
const isAnswered = (qi: number): boolean => {
const sel = selectionFor(qi)
return sel.indices.length > 0 || (sel.other ?? '').length > 0
}
const total = prompt.questions.length
const isLast = index === total - 1
const currentAnswered = useMemo(
() => isAnswered(index),
// eslint-disable-next-line react-hooks/exhaustive-deps
[selections, otherText, index]
)
const allAnswered = useMemo(
() => prompt.questions.every((_, i) => isAnswered(i)),
// eslint-disable-next-line react-hooks/exhaustive-deps
[otherText, prompt.questions, selections]
)
const canAdvance = !submitting && (isLast ? allAnswered : currentAnswered)
const submit = async (): Promise<void> => {
if (!allAnswered || submittingRef.current) {
return
}
submittingRef.current = true
setSubmitting(true)
try {
await onAnswer(prompt.questions.map((_, i) => selectionFor(i)))
} finally {
submittingRef.current = false
setSubmitting(false)
}
}
const advance = async (): Promise<void> => {
if (isLast) {
await submit()
} else {
setIndex((i) => Math.min(i + 1, total - 1))
}
}
const q = prompt.questions[index]!
const otherSelected = (selections[index] ?? []).includes(OTHER)
return (
<View style={styles.card}>
{total > 1 ? (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.tabs}
contentContainerStyle={styles.tabsContent}
keyboardShouldPersistTaps="always"
>
{prompt.questions.map((qq, i) => (
<Pressable
key={i}
style={[styles.tab, i === index && styles.tabActive]}
onPress={() => setIndex(i)}
>
<Text style={[styles.tabText, i === index && styles.tabTextActive]} numberOfLines={1}>
{qq.header || `Step ${i + 1}`}
</Text>
{isAnswered(i) ? (
<Check size={11} color={colors.statusGreen} strokeWidth={3} />
) : null}
</Pressable>
))}
</ScrollView>
) : null}
<ScrollView style={styles.scroll} keyboardShouldPersistTaps="always">
<Text style={styles.questionText}>{q.question}</Text>
{q.options.map((opt, optIndex) => (
<OptionRow
key={`${optIndex}:${opt.label}`}
label={opt.label}
description={opt.description}
selected={(selections[index] ?? []).includes(optIndex)}
multi={q.multiSelect}
onPress={() => toggle(index, optIndex, q.multiSelect)}
/>
))}
<OptionRow
label="Other…"
selected={otherSelected}
multi={q.multiSelect}
onPress={() => toggle(index, OTHER, q.multiSelect)}
/>
{otherSelected ? (
<TextInput
style={styles.input}
value={otherText[index]}
onChangeText={(v) => setOther(index, v)}
placeholder="Type your answer"
placeholderTextColor={colors.textMuted}
multiline
autoFocus
/>
) : null}
</ScrollView>
<View style={styles.footer}>
<Pressable
style={styles.cancel}
onPress={async () => {
if (!submittingRef.current && onCancel) {
submittingRef.current = true
setSubmitting(true)
try {
await onCancel()
} finally {
submittingRef.current = false
setSubmitting(false)
}
}
}}
disabled={submitting}
hitSlop={8}
>
<Text style={styles.cancelText}>Cancel</Text>
</Pressable>
{total > 1 ? (
<Text style={styles.progress}>
{index + 1}/{total}
</Text>
) : null}
<Pressable
style={[styles.next, !canAdvance && styles.nextDisabled]}
onPress={advance}
disabled={!canAdvance}
>
<Text style={[styles.nextText, !canAdvance && styles.nextTextDisabled]}>
{isLast ? 'Send answer' : 'Next'}
</Text>
</Pressable>
</View>
</View>
)
}
function OptionRow({
label,
description,
selected,
multi,
onPress
}: {
label: string
description?: string
selected: boolean
multi?: boolean
onPress: () => void
}): React.JSX.Element {
return (
<Pressable style={[styles.option, selected && styles.optionSelected]} onPress={onPress}>
{/* Multi-select reads as a checkbox (square); single-select as a radio (circle). */}
<View
style={[
styles.check,
multi ? styles.checkSquare : styles.checkCircle,
selected && styles.checkOn
]}
>
{selected ? <Check size={12} color={colors.bgBase} strokeWidth={3} /> : null}
</View>
<View style={styles.optionBody}>
<Text style={styles.optionLabel}>{label}</Text>
{description ? (
<Text style={styles.optionDescription} numberOfLines={3}>
{description}
</Text>
) : null}
</View>
</Pressable>
)
}
const styles = StyleSheet.create({
card: {
maxHeight: 380,
backgroundColor: colors.bgPanel,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: colors.borderSubtle
},
tabs: {
flexGrow: 0,
paddingTop: spacing.sm,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.borderSubtle
},
tabsContent: {
paddingHorizontal: spacing.sm,
gap: spacing.xs,
alignItems: 'center'
},
tab: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
minHeight: 36,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
borderBottomWidth: 2,
borderBottomColor: 'transparent'
},
tabActive: {
borderBottomColor: colors.statusGreen
},
tabText: {
color: colors.textSecondary,
fontSize: typography.metaSize,
fontWeight: '600'
},
tabTextActive: {
color: colors.textPrimary
},
scroll: {
paddingHorizontal: spacing.md
},
questionText: {
color: colors.textPrimary,
fontSize: typography.bodySize + 1,
fontWeight: '600',
marginVertical: spacing.sm
},
option: {
flexDirection: 'row',
gap: spacing.sm,
padding: spacing.sm,
borderRadius: radii.card,
backgroundColor: colors.bgRaised,
borderWidth: 1,
borderColor: colors.borderSubtle,
marginBottom: spacing.xs
},
optionSelected: {
borderColor: colors.statusGreen
},
check: {
width: 18,
height: 18,
borderWidth: 1.5,
borderColor: colors.textMuted,
alignItems: 'center',
justifyContent: 'center',
marginTop: 1
},
checkCircle: {
borderRadius: 9
},
checkSquare: {
borderRadius: 4
},
checkOn: {
backgroundColor: colors.statusGreen,
borderColor: colors.statusGreen
},
optionBody: {
flex: 1,
gap: 2
},
optionLabel: {
color: colors.textPrimary,
fontSize: typography.bodySize,
fontWeight: '600'
},
optionDescription: {
color: colors.textSecondary,
fontSize: typography.metaSize
},
input: {
backgroundColor: colors.bgRaised,
borderWidth: 1,
borderColor: colors.borderSubtle,
borderRadius: radii.card,
color: colors.textPrimary,
fontSize: typography.bodySize,
padding: spacing.sm,
minHeight: 44,
marginBottom: spacing.xs
},
footer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
padding: spacing.md,
gap: spacing.sm,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: colors.borderSubtle
},
cancel: {
paddingVertical: spacing.sm,
paddingHorizontal: spacing.sm
},
cancelText: {
color: colors.textSecondary,
fontSize: typography.bodySize,
fontWeight: '600'
},
progress: {
color: colors.textMuted,
fontSize: typography.metaSize
},
next: {
paddingVertical: spacing.sm,
paddingHorizontal: spacing.lg,
borderRadius: radii.button,
backgroundColor: colors.textPrimary
},
nextDisabled: {
backgroundColor: colors.bgRaised
},
nextText: {
color: colors.bgBase,
fontSize: typography.bodySize,
fontWeight: '700'
},
nextTextDisabled: {
color: colors.textMuted
}
})
@@ -0,0 +1,206 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { MobileNativeChatComposer } from './MobileNativeChatComposer'
vi.mock('react-native', async () => {
const React = await import('react')
return {
ActivityIndicator: 'ActivityIndicator',
Pressable: 'Pressable',
ScrollView: ({ children, ...props }: { children?: unknown }) =>
React.createElement('ScrollView', props, children),
StyleSheet: {
create: (styles: unknown) => styles,
hairlineWidth: 1
},
Text: 'Text',
TextInput: 'TextInput',
View: 'View'
}
})
vi.mock('lucide-react-native', () => ({
ArrowUp: 'ArrowUp',
ImagePlus: 'ImagePlus',
Mic: 'Mic',
Square: 'Square'
}))
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()
}
describe('MobileNativeChatComposer', () => {
let renderer: ReactTestRenderer | null = null
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
})
async function render(
onSend: (text: string) => Promise<boolean>,
onChangeText: () => void,
isAttaching = false
) {
const restore = suppressRendererWarning()
try {
await act(async () => {
renderer = create(
createElement(MobileNativeChatComposer, {
value: ' hello ',
onChangeText,
onSend,
isAttaching
})
)
})
} finally {
restore()
}
}
function sendButton(): { props: { onPress: () => Promise<void> } } {
if (!renderer) {
throw new Error('Composer was not rendered')
}
return renderer.root.find(
(node) => node.type === 'Pressable' && node.props.accessibilityLabel === 'Send message'
) as { props: { onPress: () => Promise<void> } }
}
it('reports an accepted send without owning route-scoped draft cleanup', async () => {
const onChangeText = vi.fn()
const onSend = vi.fn().mockResolvedValue(true)
await render(onSend, onChangeText)
await act(async () => sendButton().props.onPress())
expect(onSend).toHaveBeenCalledWith('hello')
expect(onChangeText).not.toHaveBeenCalled()
})
it('keeps the draft when the send is rejected', async () => {
const onChangeText = vi.fn()
const onSend = vi.fn().mockResolvedValue(false)
await render(onSend, onChangeText)
await act(async () => sendButton().props.onPress())
expect(onSend).toHaveBeenCalledWith('hello')
expect(onChangeText).not.toHaveBeenCalled()
})
it('disables send while an attachment path is still being injected', async () => {
const onSend = vi.fn().mockResolvedValue(true)
await render(onSend, vi.fn(), true)
expect(sendButton().props).toMatchObject({ disabled: true })
await act(async () => sendButton().props.onPress())
expect(onSend).not.toHaveBeenCalled()
})
it('moves the caret to the insert point after an autocomplete pick, then releases control', async () => {
const restore = suppressRendererWarning()
try {
await act(async () => {
renderer = create(
createElement(MobileNativeChatComposer, {
value: '/c',
onChangeText: vi.fn(),
onSend: vi.fn().mockResolvedValue(true)
})
)
})
} finally {
restore()
}
const input = () =>
renderer!.root.find((node) => node.type === 'TextInput') as {
props: {
selection?: { start: number; end: number }
onSelectionChange: (e: { nativeEvent: { selection: { end: number } } }) => void
}
}
// Uncontrolled selection until a suggestion is applied.
expect(input().props.selection).toBeUndefined()
// Place the caret at the end so the slash trigger is active and suggestions render.
await act(async () =>
input().props.onSelectionChange({ nativeEvent: { selection: { end: 2 } } })
)
const firstSuggestion = renderer!.root.findAll(
(node) => node.type === 'Pressable' && !node.props.accessibilityLabel
)[0] as { props: { onPress: () => void } }
await act(async () => firstSuggestion.props.onPress())
// `/clear ` is 7 chars — the caret jumps just past the inserted command + space.
expect(input().props.selection).toEqual({ start: 7, end: 7 })
// The next native selection event releases control so manual placement still works.
await act(async () =>
input().props.onSelectionChange({ nativeEvent: { selection: { end: 7 } } })
)
expect(input().props.selection).toBeUndefined()
})
it('wires the mic for hold vs toggle dictation like the terminal composer', async () => {
const onMicPress = vi.fn()
const onMicPressIn = vi.fn()
const onMicPressOut = vi.fn()
const mic = () =>
renderer!.root.find(
(node) => node.type === 'Pressable' && node.props.accessibilityLabel === 'Dictate'
) as { props: { onPress?: unknown; onPressIn?: unknown; onPressOut?: unknown } }
const restore = suppressRendererWarning()
try {
await act(async () => {
renderer = create(
createElement(MobileNativeChatComposer, {
value: '',
onChangeText: vi.fn(),
onSend: vi.fn().mockResolvedValue(true),
onMicPress,
dictationMode: 'hold',
onMicPressIn,
onMicPressOut
})
)
})
// Hold mode is walkie-talkie: press-in/out drive dictation, tap is inert.
expect(mic().props.onPress).toBeUndefined()
expect(mic().props.onPressIn).toBe(onMicPressIn)
expect(mic().props.onPressOut).toBe(onMicPressOut)
await act(async () => {
renderer!.update(
createElement(MobileNativeChatComposer, {
value: '',
onChangeText: vi.fn(),
onSend: vi.fn().mockResolvedValue(true),
onMicPress,
dictationMode: 'toggle',
onMicPressIn,
onMicPressOut
})
)
})
// Toggle mode: tap drives dictation, press-in/out inert.
expect(mic().props.onPress).toBe(onMicPress)
expect(mic().props.onPressIn).toBeUndefined()
expect(mic().props.onPressOut).toBeUndefined()
} finally {
restore()
}
})
})
@@ -0,0 +1,285 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import {
ActivityIndicator,
Pressable,
ScrollView,
StyleSheet,
Text,
TextInput,
View
} from 'react-native'
import { ArrowUp, ImagePlus, Mic, Square } from 'lucide-react-native'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import {
applyAutocomplete,
detectAutocompleteTrigger,
rankSuggestions
} from './mobile-native-chat-autocomplete'
// Common agent slash commands offered as autocomplete; sending them is just text
// to the agent's terminal, so the set is intentionally provider-agnostic.
const SLASH_COMMANDS = [
'/clear',
'/compact',
'/review',
'/model',
'/help',
'/init',
'/cost',
'/diff'
]
const NO_FILE_PATHS: string[] = []
type Props = {
/** Controlled composer text — owned by the parent so dictation can write to it. */
value: string
onChangeText: (text: string) => void
onSend: (text: string) => Promise<boolean>
onAttachImage?: () => void
isAttaching?: boolean
onMicPress?: () => void
micActive?: boolean
/** Dictation trigger style — 'hold' uses press-in/out, 'toggle' uses tap. */
dictationMode?: 'toggle' | 'hold'
onMicPressIn?: () => void
onMicPressOut?: () => void
disabled?: boolean
placeholder?: string
filePaths?: string[]
onNeedFiles?: (query: string) => void
}
export function MobileNativeChatComposer({
value,
onChangeText,
onSend,
onAttachImage,
isAttaching = false,
onMicPress,
micActive = false,
dictationMode = 'toggle',
onMicPressIn,
onMicPressOut,
disabled = false,
placeholder = 'Message, @files, /commands',
filePaths = NO_FILE_PATHS,
onNeedFiles
}: Props): React.JSX.Element {
const [cursor, setCursor] = useState(0)
// Transiently drives the native caret after a mid-text autocomplete insert,
// then released on the next selection change so manual caret placement still
// works (a permanently controlled `selection` breaks it in React Native).
const [pendingSelection, setPendingSelection] = useState<{ start: number; end: number } | null>(
null
)
const sendingRef = useRef(false)
const [sending, setSending] = useState(false)
const trimmed = value.trim()
const canSend = trimmed.length > 0 && !disabled && !sending && !isAttaching
const trigger = useMemo(() => detectAutocompleteTrigger(value, cursor), [value, cursor])
const suggestions = useMemo(() => {
if (!trigger) {
return []
}
if (trigger.kind === 'slash') {
return rankSuggestions(SLASH_COMMANDS, trigger.query)
}
return rankSuggestions(filePaths, trigger.query).map((p) => `@${p}`)
}, [trigger, filePaths])
useEffect(() => {
if (trigger?.kind === 'file') {
onNeedFiles?.(trigger.query)
}
}, [onNeedFiles, trigger?.kind, trigger?.query])
const handleChange = (next: string): void => {
onChangeText(next)
}
const pickSuggestion = (suggestion: string): void => {
if (!trigger) {
return
}
const { text: nextText, cursor: nextCursor } = applyAutocomplete(value, trigger, suggestion)
onChangeText(nextText)
setCursor(nextCursor)
setPendingSelection({ start: nextCursor, end: nextCursor })
}
const handleSend = async (): Promise<void> => {
if (!canSend || sendingRef.current) {
return
}
sendingRef.current = true
setSending(true)
try {
const accepted = await onSend(trimmed)
if (accepted) {
setCursor(0)
}
} finally {
sendingRef.current = false
setSending(false)
}
}
return (
<View>
{suggestions.length > 0 ? (
<View style={styles.suggestions}>
<ScrollView keyboardShouldPersistTaps="always" style={styles.suggestionScroll}>
{suggestions.map((s) => (
<Pressable
key={s}
style={({ pressed }) => [styles.suggestion, pressed && styles.suggestionPressed]}
onPress={() => pickSuggestion(s)}
>
<Text style={styles.suggestionText} numberOfLines={1}>
{s}
</Text>
</Pressable>
))}
</ScrollView>
</View>
) : null}
<View style={styles.bar}>
{onAttachImage ? (
<Pressable
accessibilityLabel="Attach image"
style={({ pressed }) => [styles.iconButton, pressed && styles.pressed]}
onPress={onAttachImage}
disabled={isAttaching || disabled}
>
{isAttaching ? (
<ActivityIndicator size="small" color={colors.textSecondary} />
) : (
<ImagePlus size={20} color={colors.textSecondary} strokeWidth={2} />
)}
</Pressable>
) : null}
<TextInput
style={styles.input}
value={value}
onChangeText={handleChange}
// Controlled only transiently right after an autocomplete insert.
selection={pendingSelection ?? undefined}
onSelectionChange={(e) => {
setCursor(e.nativeEvent.selection.end)
setPendingSelection(null)
}}
placeholder={placeholder}
placeholderTextColor={colors.textMuted}
selectionColor={colors.accentBlue}
multiline
editable={!disabled}
textAlignVertical="top"
/>
{onMicPress ? (
<Pressable
accessibilityLabel={micActive ? 'Stop dictation' : 'Dictate'}
style={({ pressed }) => [styles.iconButton, pressed && styles.pressed]}
// Hold mode is walkie-talkie (press-in/out); toggle mode taps.
onPress={dictationMode === 'hold' ? undefined : onMicPress}
onPressIn={dictationMode === 'hold' ? onMicPressIn : undefined}
onPressOut={dictationMode === 'hold' ? onMicPressOut : undefined}
disabled={disabled}
>
{micActive ? (
<Square
size={18}
color={colors.statusRed}
strokeWidth={2.4}
fill={colors.statusRed}
/>
) : (
<Mic size={20} color={colors.textSecondary} strokeWidth={2} />
)}
</Pressable>
) : null}
<Pressable
accessibilityLabel="Send message"
style={({ pressed }) => [
styles.sendButton,
!canSend && styles.sendButtonDisabled,
pressed && canSend && styles.pressed
]}
onPress={handleSend}
disabled={!canSend}
>
<ArrowUp size={20} color={canSend ? colors.bgBase : colors.textMuted} strokeWidth={2.6} />
</Pressable>
</View>
</View>
)
}
const styles = StyleSheet.create({
suggestions: {
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: colors.borderSubtle,
backgroundColor: colors.bgPanel
},
suggestionScroll: {
maxHeight: 180
},
suggestion: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.borderSubtle
},
suggestionPressed: {
backgroundColor: colors.bgRaised
},
suggestionText: {
color: colors.textPrimary,
fontFamily: typography.monoFamily,
fontSize: typography.metaSize
},
bar: {
flexDirection: 'row',
alignItems: 'flex-end',
gap: spacing.sm,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: colors.borderSubtle,
backgroundColor: colors.bgPanel
},
input: {
flex: 1,
maxHeight: 140,
minHeight: 40,
color: colors.textPrimary,
fontSize: typography.bodySize + 1,
backgroundColor: colors.bgRaised,
borderRadius: radii.input,
paddingHorizontal: spacing.md,
paddingTop: spacing.sm,
paddingBottom: spacing.sm
},
iconButton: {
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center'
},
sendButton: {
width: 40,
height: 40,
borderRadius: 20,
alignItems: 'center',
justifyContent: 'center',
// White send affordance per design — dark arrow on a light circle.
backgroundColor: colors.textPrimary
},
sendButtonDisabled: {
backgroundColor: colors.bgRaised
},
pressed: {
opacity: 0.7
}
})
@@ -0,0 +1,371 @@
import { memo, useEffect, useRef, useState } from 'react'
import { Pressable, Text, View } from 'react-native'
import * as Clipboard from 'expo-clipboard'
import { ArrowUp, ChevronDown, Copy, SquareChevronRight } from 'lucide-react-native'
import type { NativeChatBlock, NativeChatMessage } from '../../../src/shared/native-chat-types'
import { MobileMarkdown } from '../components/MobileMarkdown'
import { colors } from '../theme/mobile-theme'
import {
isImageRefBlock,
isTextBlock,
pairToolBlocks,
splitNativeChatBlocks,
type ToolPair
} from './mobile-native-chat-blocks'
import { diffFromText, diffFromToolCall, type DiffLine } from './mobile-native-chat-diff'
import { MAX_TOOL_RESULT_CHARS, styles, TEXT_SIZE } from './mobile-native-chat-message-styles'
import { nativeChatMessageText } from './mobile-native-chat-message-text'
import {
summarizeToolInput,
summarizeToolRun,
toolFilePath
} from './mobile-native-chat-tool-summary'
const MAX_VISIBLE_TOOL_PAIRS = 6
const MAX_TOOL_RUN_DIFF_ROWS = 240
function DiffView({ lines }: { lines: DiffLine[] }): React.JSX.Element {
return (
<View style={styles.diff}>
{lines.map((line, i) => (
<Text
key={i}
style={[
styles.diffLine,
line.kind === 'add' && styles.diffAdd,
line.kind === 'del' && styles.diffDel,
line.kind === 'meta' && styles.diffMeta
]}
>
{line.kind === 'add' ? '+' : line.kind === 'del' ? '-' : ' '}
{line.text}
</Text>
))}
</View>
)
}
/** A single inline tool line — `▸ ToolName preview` — that expands in place to
* show the call's diff/input or the result's body. Mirrors the reference design
* where tool calls read as flat lines in the conversation, not boxed blocks. */
function ResultBody({
output,
isError,
diff
}: {
output: string
isError?: boolean
diff: DiffLine[] | null
}): React.JSX.Element {
if (diff) {
return <DiffView lines={diff} />
}
return (
<View style={[styles.toolResult, isError && styles.toolResultError]}>
<Text style={styles.mono}>
{output.length > MAX_TOOL_RESULT_CHARS
? `${output.slice(0, MAX_TOOL_RESULT_CHARS)}…`
: output}
</Text>
</View>
)
}
/** One request: a tool call and its result rendered together as a single
* expandable line. `defaultExpanded` lets the group toggle open every line. */
function ToolLine({
pair,
defaultExpanded,
diffLineLimit,
onOpenFile
}: {
pair: ToolPair
defaultExpanded: boolean
diffLineLimit: number
onOpenFile?: (relativePath: string) => void
}): React.JSX.Element {
const [expanded, setExpanded] = useState(defaultExpanded)
const { call, result } = pair
const name = call ? call.name : 'Result'
const preview = call
? summarizeToolInput(call.input)
: (result?.output.split('\n')[0]?.slice(0, 80) ?? '')
// Why: collapsed tool rows are the common path; defer bounded diff parsing
// until the user asks to reveal the detail.
const callDiff = expanded && call ? diffFromToolCall(call.name, call.input, diffLineLimit) : null
const resultDiff = expanded && result ? diffFromText(result.output, diffLineLimit) : null
const hasDetail = callDiff !== null || result !== undefined || preview.length > 40
// A tool that targets a file (Read/Edit/Write…) renders its preview as a
// tappable link that opens the file, independent of the line's expand tap.
const filePath = call ? toolFilePath(call.input) : null
const openable = filePath !== null && onOpenFile !== undefined
return (
<View>
<Pressable
style={styles.toolLine}
onPress={() => hasDetail && setExpanded((v) => !v)}
hitSlop={6}
>
{expanded ? (
<ChevronDown size={15} color={colors.textMuted} strokeWidth={2} />
) : (
<SquareChevronRight size={15} color={colors.textMuted} strokeWidth={2} />
)}
<Text style={styles.toolName}>{name}</Text>
{preview ? (
<Text
style={[styles.toolPreview, openable && styles.toolPreviewLink]}
numberOfLines={1}
onPress={openable ? () => onOpenFile!(filePath!) : undefined}
suppressHighlighting={!openable}
>
{preview}
</Text>
) : null}
</Pressable>
{expanded ? (
<View style={styles.toolDetail}>
{callDiff ? <DiffView lines={callDiff} /> : null}
{!callDiff && call && preview ? <Text style={styles.mono}>{preview}</Text> : null}
{result ? (
<ResultBody output={result.output} isError={result.isError} diff={resultDiff} />
) : null}
</View>
) : null}
</View>
)
}
function Prose({
block,
invert,
fontScale,
onOpenFile
}: {
block: NativeChatBlock
invert?: boolean
fontScale: number
onOpenFile?: (relativePath: string) => void
}): React.JSX.Element | null {
if (isTextBlock(block)) {
// Inverted (user) bubbles use a fixed dark-on-light text rather than the
// markdown renderer's light-on-dark palette.
if (invert) {
return (
<Text style={[styles.userText, { fontSize: TEXT_SIZE * fontScale }]}>{block.text}</Text>
)
}
return (
<MobileMarkdown content={block.text} textScale={1.25 * fontScale} onOpenFile={onOpenFile} />
)
}
if (isImageRefBlock(block)) {
return (
<Text style={[styles.imageRef, { fontSize: TEXT_SIZE * fontScale }]}>
🖼 {block.alt ?? block.path ?? block.url ?? 'image'}
</Text>
)
}
return null
}
/** A run of a message's tool calls/results, collapsed to a one-line summary that
* expands to the individual inline tool lines. `defaultExpanded` lets the global
* toolbar toggle drive every run at once while still allowing per-run override. */
function ToolRun({
blocks,
defaultExpanded,
trailing,
onOpenFile
}: {
blocks: NativeChatBlock[]
defaultExpanded: boolean
trailing?: React.ReactNode
onOpenFile?: (relativePath: string) => void
}): React.JSX.Element {
const [open, setOpen] = useState(defaultExpanded)
const pairs = pairToolBlocks(blocks, MAX_VISIBLE_TOOL_PAIRS)
const diffLineLimit = Math.max(1, Math.floor(MAX_TOOL_RUN_DIFF_ROWS / (pairs.length * 2 || 1)))
let callCount = 0
for (const block of blocks) {
if (block.type === 'tool-call') {
callCount++
}
}
callCount ||= pairs.length
const summary = summarizeToolRun(blocks)
return (
<View style={styles.toolRun}>
<View style={styles.toolRunHeader}>
<Pressable style={styles.toolRunToggle} onPress={() => setOpen((v) => !v)} hitSlop={6}>
{open ? (
<ChevronDown size={15} color={colors.textMuted} strokeWidth={2} />
) : (
<SquareChevronRight size={15} color={colors.textMuted} strokeWidth={2} />
)}
<Text style={styles.toolRunCount}>{callCount}×</Text>
<Text style={styles.toolRunLabel} numberOfLines={1}>
{summary || `${callCount} tool ${callCount === 1 ? 'call' : 'calls'}`}
</Text>
</Pressable>
{trailing}
</View>
{open ? (
<View style={styles.toolRunBody}>
{pairs.map((pair, i) => (
<ToolLine
key={i}
pair={pair}
defaultExpanded={defaultExpanded}
diffLineLimit={diffLineLimit}
onOpenFile={onOpenFile}
/>
))}
{callCount > pairs.length ? (
<Text style={styles.toolPreview}>… {callCount - pairs.length} more tool calls</Text>
) : null}
</View>
) : null}
</View>
)
}
/** Subtle top-right controls for an agent message: copy its prose, or scroll so
* this message's top aligns to the top of the viewport. */
function AgentControls({
onCopy,
onScrollToTop
}: {
onCopy: () => void
onScrollToTop?: () => void
}): React.JSX.Element {
return (
<View style={styles.controls}>
<Pressable
style={({ pressed }) => [styles.controlButton, pressed && styles.controlPressed]}
onPress={onCopy}
hitSlop={8}
accessibilityLabel="Copy message"
>
<Copy size={14} color={colors.textMuted} strokeWidth={2} />
</Pressable>
{onScrollToTop ? (
<Pressable
style={({ pressed }) => [styles.controlButton, pressed && styles.controlPressed]}
onPress={onScrollToTop}
hitSlop={8}
accessibilityLabel="Scroll this message to top"
>
<ArrowUp size={14} color={colors.textMuted} strokeWidth={2} />
</Pressable>
) : null}
</View>
)
}
function MobileNativeChatMessageImpl({
message,
queued,
toolsExpanded = false,
fontScale = 1,
messageIndex,
onScrollToMessage,
onOpenFile
}: {
message: NativeChatMessage
queued?: boolean
toolsExpanded?: boolean
/** Multiplies all chat text sizes for pinch-to-zoom (1 = no change). */
fontScale?: number
/** This message's index in the list, paired with onScrollToMessage. */
messageIndex?: number
/** Ask the list to align this message's top to the top of the viewport. */
onScrollToMessage?: (index: number) => void
onOpenFile?: (relativePath: string) => void
}): React.JSX.Element {
const isUser = message.role === 'user'
const isReasoning = message.role === 'reasoning'
const isAgent = !isUser
// Briefly tint the bubble to confirm a copy landed.
const [copied, setCopied] = useState(false)
const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(
() => () => {
if (copyTimer.current) {
clearTimeout(copyTimer.current)
}
},
[]
)
// Separate the agent's words from its tool activity: prose renders first, the
// tool calls fold into a collapsible run beneath. The user's own messages get
// an inverted (filled accent) bubble so they stand apart from agent prose.
const { prose, tools } = splitNativeChatBlocks(message.blocks)
const handleCopy = (): void => {
const text = nativeChatMessageText(message.blocks)
if (!text) {
return
}
void Clipboard.setStringAsync(text)
setCopied(true)
if (copyTimer.current) {
clearTimeout(copyTimer.current)
}
copyTimer.current = setTimeout(() => setCopied(false), 700)
}
// Copy + scroll-to-top, shown inline with the first tool call (or after the
// prose when there are no tools).
const controls =
isAgent && !queued ? (
<AgentControls
onCopy={handleCopy}
onScrollToTop={
onScrollToMessage && messageIndex !== undefined
? () => onScrollToMessage(messageIndex)
: undefined
}
/>
) : null
return (
<View style={[styles.row, isUser && styles.rowUser]}>
{isUser && queued ? <Text style={styles.queuedTag}>Queued</Text> : null}
<View
style={[
styles.content,
isUser && styles.userBubble,
isReasoning && styles.reasoning,
queued && styles.queued,
copied && styles.copied
]}
>
{prose.map((block, index) => (
<Prose
key={index}
block={block}
invert={isUser}
fontScale={fontScale}
onOpenFile={onOpenFile}
/>
))}
{tools.length > 0 ? (
<ToolRun
// Why: a global toggle intentionally resets all per-run/per-line
// overrides in one remount, avoiding an effect-driven second render.
key={toolsExpanded ? 'expanded' : 'collapsed'}
blocks={tools}
defaultExpanded={toolsExpanded}
trailing={controls}
onOpenFile={onOpenFile}
/>
) : controls ? (
<View style={styles.controlsRow}>{controls}</View>
) : null}
</View>
</View>
)
}
export const MobileNativeChatMessage = memo(MobileNativeChatMessageImpl)
@@ -0,0 +1,79 @@
import { StyleSheet, View } from 'react-native'
import { MobileNativeChatView, type MobileNativeChatInputLockReason } from './MobileNativeChatView'
import type { MobileNativeChatController } from './use-mobile-native-chat-controller'
type Props = {
controller: MobileNativeChatController
onAttachImage: () => void
isAttaching: boolean
onMicPress: () => void
micActive: boolean
dictationMode: 'toggle' | 'hold'
onMicPressIn: () => void
onMicPressOut: () => void
inputLockReason: MobileNativeChatInputLockReason | null
keyboardInset: number
}
/** Keeps the terminal mounted underneath chat so its PTY subscription survives
* view toggles while the native surface owns the visible composer. */
export function MobileNativeChatOverlay({
controller,
onAttachImage,
isAttaching,
onMicPress,
micActive,
dictationMode,
onMicPressIn,
onMicPressOut,
inputLockReason,
keyboardInset
}: Props): React.JSX.Element | null {
if (!controller.showNativeChat) {
return null
}
const session = controller.nativeChatSession
return (
<View style={styles.overlay}>
<MobileNativeChatView
messages={session.messages}
status={session.status}
error={session.error}
agent={controller.nativeChatAgent}
agentWorking={controller.nativeChatAgentWorking}
streamingText={controller.nativeChatStreamingText}
onStop={controller.handleNativeChatStop}
ask={controller.nativeChatAsk}
onAnswerAsk={controller.handleNativeChatAnswerAsk}
onCancelAsk={controller.handleNativeChatCancelAsk}
question={controller.nativeChatQuestion}
onAnswerQuestion={controller.handleNativeChatSend}
permission={controller.nativeChatPermission}
onRespondPermission={controller.handleNativeChatRespondPermission}
onOpenFile={controller.handleNativeChatOpenFile}
hasMore={session.hasMore}
loadingEarlier={session.loadingEarlier}
onLoadEarlier={session.loadEarlier}
onSend={controller.handleNativeChatSend}
pending={controller.chatPending}
composerText={controller.chatComposerText}
onComposerTextChange={controller.setChatComposerText}
onAttachImage={onAttachImage}
isAttaching={isAttaching}
onMicPress={onMicPress}
micActive={micActive}
dictationMode={dictationMode}
onMicPressIn={onMicPressIn}
onMicPressOut={onMicPressOut}
inputLockReason={inputLockReason}
filePaths={controller.nativeChatFilePaths}
onNeedFiles={controller.loadNativeChatFiles}
keyboardInset={keyboardInset}
/>
</View>
)
}
const styles = StyleSheet.create({
overlay: StyleSheet.absoluteFillObject
})
@@ -0,0 +1,60 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { MobileNativeChatPermission } from './MobileNativeChatPermission'
vi.mock('react-native', () => ({
Pressable: 'Pressable',
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 },
Text: 'Text',
View: 'View'
}))
vi.mock('lucide-react-native', () => ({ ShieldQuestion: 'ShieldQuestion' }))
describe('MobileNativeChatPermission', () => {
let renderer: ReactTestRenderer | null = null
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
})
it('accepts only one response when two presses land in the same render batch', async () => {
let resolveResponse: (accepted: boolean) => void = () => {}
const response = new Promise<boolean>((resolve) => (resolveResponse = resolve))
const onRespond = vi.fn(() => response)
const original = console.error
const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
return
}
original(...args)
})
try {
await act(async () => {
renderer = create(
createElement(MobileNativeChatPermission, {
permission: { title: 'Approve?', options: [{ label: 'Allow', send: '1' }] },
onRespond
})
)
})
} finally {
consoleSpy.mockRestore()
}
const button = renderer.root.findByType('Pressable')
act(() => {
button.props.onPress()
button.props.onPress()
})
expect(onRespond).toHaveBeenCalledOnce()
await act(async () => resolveResponse(true))
})
})
@@ -0,0 +1,123 @@
import { memo, useRef, useState } from 'react'
import { Pressable, StyleSheet, Text, View } from 'react-native'
import { ShieldQuestion } from 'lucide-react-native'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import type { MobileChatPermission } from './mobile-native-chat-permission'
// Renders a detected agent permission ask as a card with tappable options.
// The first option is treated as the primary (allow) action and gets a filled
// accent button so the affirmative choice reads as distinct from the rest.
function MobileNativeChatPermissionImpl({
permission,
onRespond
}: {
permission: MobileChatPermission
onRespond: (send: string) => Promise<boolean>
}): React.JSX.Element {
const [submitting, setSubmitting] = useState(false)
const submittingRef = useRef(false)
const respond = async (send: string): Promise<void> => {
if (submittingRef.current) {
return
}
submittingRef.current = true
setSubmitting(true)
const accepted = await onRespond(send)
if (!accepted) {
submittingRef.current = false
setSubmitting(false)
}
}
return (
<View style={styles.card}>
<View style={styles.header}>
<ShieldQuestion size={16} color={colors.accentBlue} strokeWidth={2} />
<Text style={styles.title}>{permission.title}</Text>
</View>
{permission.detail ? <Text style={styles.detail}>{permission.detail}</Text> : null}
<View style={styles.options}>
{permission.options.map((option, index) => {
const isPrimary = index === 0
return (
<Pressable
key={`${option.send}:${option.label}`}
style={({ pressed }) => [
styles.option,
isPrimary ? styles.optionPrimary : styles.optionSecondary,
pressed && !submitting && styles.optionPressed
]}
hitSlop={6}
onPress={() => respond(option.send)}
disabled={submitting}
>
<Text style={[styles.optionText, isPrimary && styles.optionTextPrimary]}>
{option.label}
</Text>
</Pressable>
)
})}
</View>
</View>
)
}
export const MobileNativeChatPermission = memo(MobileNativeChatPermissionImpl)
const styles = StyleSheet.create({
card: {
marginHorizontal: spacing.lg,
marginVertical: spacing.sm,
padding: spacing.md,
gap: spacing.sm,
borderRadius: radii.card,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.borderSubtle,
backgroundColor: colors.bgPanel
},
header: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm
},
title: {
color: colors.textPrimary,
fontSize: typography.bodySize,
fontWeight: '600'
},
detail: {
color: colors.textSecondary,
fontSize: typography.metaSize,
lineHeight: typography.metaSize + 5
},
options: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: spacing.sm
},
option: {
minHeight: 44,
justifyContent: 'center',
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radii.button
},
optionPrimary: {
backgroundColor: colors.accentBlue
},
optionSecondary: {
backgroundColor: colors.bgRaised,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.borderSubtle
},
optionPressed: {
opacity: 0.7
},
optionText: {
color: colors.textPrimary,
fontSize: typography.bodySize,
fontWeight: '600'
},
optionTextPrimary: {
color: colors.onAccent
}
})
@@ -0,0 +1,268 @@
import { useMemo, useRef, useState } from 'react'
import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native'
import { ArrowUp, Check, CircleHelp } from 'lucide-react-native'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import { formatQuestionAnswer, type MobileChatQuestion } from './mobile-native-chat-question'
type Props = {
question: MobileChatQuestion
onAnswer: (text: string) => Promise<boolean>
}
/** Renders an agent's choice prompt as a tappable card. Single-select answers
* on tap; multi-select toggles then Submits; an always-present text entry lets
* the user answer freely (the escape hatch) when the heuristic misreads the
* options or none apply. */
export function MobileNativeChatQuestion({ question, onAnswer }: Props): React.JSX.Element {
const [selected, setSelected] = useState<string[]>([])
const [freeText, setFreeText] = useState('')
const [sending, setSending] = useState(false)
const sendingRef = useRef(false)
const hasOptions = question.options.length > 0
const trimmedFreeText = freeText.trim()
const toggle = (option: string): void => {
setSelected((prev) =>
prev.includes(option) ? prev.filter((o) => o !== option) : [...prev, option]
)
}
const sendAnswer = async (text: string): Promise<boolean> => {
if (sendingRef.current) {
return false
}
sendingRef.current = true
setSending(true)
try {
return await onAnswer(text)
} finally {
sendingRef.current = false
setSending(false)
}
}
const answerSingle = async (option: string): Promise<void> => {
await sendAnswer(formatQuestionAnswer(question, [option]))
}
const submitMulti = async (): Promise<void> => {
if (selected.length === 0) {
return
}
await sendAnswer(formatQuestionAnswer(question, selected))
}
const submitFreeText = async (): Promise<void> => {
if (trimmedFreeText.length === 0) {
return
}
// Free text is an unknown entry; formatQuestionAnswer passes it through.
if (await sendAnswer(formatQuestionAnswer(question, [trimmedFreeText]))) {
setFreeText('')
}
}
const canSubmitMulti = selected.length > 0 && !sending
const canSendFreeText = trimmedFreeText.length > 0 && !sending
// Stable keys for option rows even if an agent repeats a label.
const optionRows = useMemo(
() => question.options.map((label, index) => ({ label, key: `${index}:${label}` })),
[question.options]
)
return (
<View style={styles.card}>
<View style={styles.header}>
<CircleHelp size={15} color={colors.accentBlue} strokeWidth={2.2} />
<Text style={styles.question}>{question.question}</Text>
</View>
{hasOptions ? (
<View style={styles.options}>
{optionRows.map(({ label, key }) => {
const isSelected = selected.includes(label)
return (
<Pressable
key={key}
accessibilityRole={question.multiSelect ? 'checkbox' : 'button'}
accessibilityState={question.multiSelect ? { checked: isSelected } : undefined}
style={({ pressed }) => [
styles.option,
isSelected && styles.optionSelected,
pressed && styles.pressed
]}
onPress={() => (question.multiSelect ? toggle(label) : answerSingle(label))}
>
{question.multiSelect ? (
<View style={[styles.checkbox, isSelected && styles.checkboxOn]}>
{isSelected ? <Check size={13} color={colors.bgBase} strokeWidth={3} /> : null}
</View>
) : null}
<Text style={styles.optionText}>{label}</Text>
</Pressable>
)
})}
</View>
) : null}
{question.multiSelect && hasOptions ? (
<Pressable
accessibilityLabel="Submit selected options"
style={({ pressed }) => [
styles.submit,
!canSubmitMulti && styles.submitDisabled,
pressed && canSubmitMulti && styles.pressed
]}
onPress={submitMulti}
disabled={!canSubmitMulti}
>
<Text style={[styles.submitText, !canSubmitMulti && styles.submitTextDisabled]}>
Submit{selected.length > 0 ? ` (${selected.length})` : ''}
</Text>
</Pressable>
) : null}
<View style={styles.freeTextRow}>
<TextInput
style={styles.freeInput}
value={freeText}
onChangeText={setFreeText}
placeholder={hasOptions ? 'Or type a reply…' : 'Type your reply…'}
placeholderTextColor={colors.textMuted}
selectionColor={colors.accentBlue}
onSubmitEditing={submitFreeText}
returnKeyType="send"
multiline
/>
<Pressable
accessibilityLabel="Send reply"
style={({ pressed }) => [
styles.freeSend,
!canSendFreeText && styles.freeSendDisabled,
pressed && canSendFreeText && styles.pressed
]}
onPress={submitFreeText}
disabled={!canSendFreeText}
>
<ArrowUp
size={18}
color={canSendFreeText ? colors.bgBase : colors.textMuted}
strokeWidth={2.6}
/>
</Pressable>
</View>
</View>
)
}
const styles = StyleSheet.create({
card: {
marginHorizontal: spacing.lg,
marginVertical: spacing.sm,
padding: spacing.md,
gap: spacing.sm,
backgroundColor: colors.bgPanel,
borderRadius: radii.card,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.borderSubtle
},
header: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm
},
question: {
flex: 1,
color: colors.textPrimary,
fontSize: typography.bodySize + 1,
fontWeight: '600',
lineHeight: typography.bodySize + 7
},
options: {
gap: spacing.xs
},
option: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
minHeight: 44,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
backgroundColor: colors.bgRaised,
borderRadius: radii.button,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.borderSubtle
},
optionSelected: {
borderColor: colors.accentBlue
},
optionText: {
flex: 1,
color: colors.textPrimary,
fontSize: typography.bodySize + 1
},
checkbox: {
width: 20,
height: 20,
borderRadius: radii.button,
borderWidth: 1.5,
borderColor: colors.textMuted,
alignItems: 'center',
justifyContent: 'center'
},
checkboxOn: {
backgroundColor: colors.accentBlue,
borderColor: colors.accentBlue
},
submit: {
minHeight: 44,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radii.button,
backgroundColor: colors.accentBlue
},
submitDisabled: {
backgroundColor: colors.bgRaised
},
submitText: {
color: colors.onMergeGreen,
fontSize: typography.bodySize + 1,
fontWeight: '600'
},
submitTextDisabled: {
color: colors.textMuted
},
freeTextRow: {
flexDirection: 'row',
alignItems: 'flex-end',
gap: spacing.sm
},
freeInput: {
flex: 1,
minHeight: 40,
maxHeight: 120,
color: colors.textPrimary,
fontSize: typography.bodySize + 1,
backgroundColor: colors.bgRaised,
borderRadius: radii.input,
paddingHorizontal: spacing.md,
paddingTop: spacing.sm,
paddingBottom: spacing.sm
},
freeSend: {
width: 40,
height: 40,
borderRadius: 20,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.textPrimary
},
freeSendDisabled: {
backgroundColor: colors.bgRaised
},
pressed: {
opacity: 0.7
}
})
+425
View File
@@ -0,0 +1,425 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
ActivityIndicator,
FlatList,
type NativeScrollEvent,
type NativeSyntheticEvent,
Pressable,
Text,
View
} from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler'
import { ArrowDown, ChevronsDownUp, ChevronsUpDown, Square } from 'lucide-react-native'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import { colors } from '../theme/mobile-theme'
import { styles } from './mobile-native-chat-view-styles'
import {
buildMobileNativeChatTransientData,
foldMobileNativeChatMessages,
mobileNativeChatEmptyState
} from './mobile-native-chat-render-data'
import { useMobileNativeChatAskDismiss } from './use-mobile-native-chat-ask-dismiss'
import { useMobileNativeChatPinchGesture } from './use-mobile-native-chat-pinch-gesture'
import { MobileAgentWorkingIndicator } from './MobileAgentWorkingIndicator'
import { MobileNativeChatComposer } from './MobileNativeChatComposer'
import { MobileNativeChatMessage } from './MobileNativeChatMessage'
import { MobileNativeChatAsk } from './MobileNativeChatAsk'
import type { AskAnswerSelection, AskPrompt } from './mobile-native-chat-ask'
import { MobileNativeChatPermission } from './MobileNativeChatPermission'
import type { MobileChatPermission } from './mobile-native-chat-permission'
import { MobileNativeChatQuestion } from './MobileNativeChatQuestion'
import { mobileChatQuestionKey, type MobileChatQuestion } from './mobile-native-chat-question'
import type { MobileNativeChatStatus } from './use-mobile-native-chat-session'
/** Why the composer input is locked: the transport is disconnected, or the
* terminal subscription has not acknowledged its input lease yet. */
export type MobileNativeChatInputLockReason = 'disconnected' | 'waiting'
type Props = {
messages: NativeChatMessage[]
status: MobileNativeChatStatus
error?: string
/** Resolved agent for this chat; names the empty-state copy (desktop parity). */
agent?: string | null
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
hasMore?: boolean
loadingEarlier?: boolean
onLoadEarlier?: () => void
onSend: (text: string) => Promise<boolean>
/** Optimistic queued sends (owned by the route so they survive view switches). */
pending: Array<{ id: string; text: string }>
/** Controlled composer text (owned by the route so dictation can write to it). */
composerText: string
onComposerTextChange: (text: string) => void
onAttachImage?: () => void
isAttaching?: boolean
onMicPress?: () => void
micActive?: boolean
dictationMode?: 'toggle' | 'hold'
onMicPressIn?: () => void
onMicPressOut?: () => void
inputLockReason?: MobileNativeChatInputLockReason | null
filePaths?: string[]
onNeedFiles?: (query: string) => void
/** A pending agent question/permission detected from live status, shown as a
* native card above the composer; answering sends text to the agent. */
/** Structured AskUserQuestion prompt parsed from the transcript (preferred over
* the heuristic question card). */
ask?: AskPrompt | null
/** Deliver the ask answer as per-question selections; the send hook turns them
* into selector keystrokes (Claude) or pasted label text (other agents). */
onAnswerAsk?: (prompt: AskPrompt, selections: AskAnswerSelection[]) => Promise<boolean>
onCancelAsk?: () => Promise<boolean>
question?: MobileChatQuestion | null
onAnswerQuestion?: (text: string) => Promise<boolean>
permission?: MobileChatPermission | null
onRespondPermission?: (send: string) => Promise<boolean>
/** Open a worktree file tapped in agent markdown. */
onOpenFile?: (relativePath: string) => void
/** Pixels to lift the composer by when the soft keyboard is open. The route
* owns keyboard tracking (the app uses manual lift, not KeyboardAvoidingView). */
keyboardInset?: number
}
export function MobileNativeChatView({
messages,
status,
error,
agent,
agentWorking,
onStop,
streamingText,
hasMore,
loadingEarlier,
onLoadEarlier,
onSend,
pending,
composerText,
onComposerTextChange,
onAttachImage,
isAttaching,
onMicPress,
micActive,
dictationMode,
onMicPressIn,
onMicPressOut,
inputLockReason,
filePaths,
onNeedFiles,
ask,
onAnswerAsk,
onCancelAsk,
question,
onAnswerQuestion,
permission,
onRespondPermission,
onOpenFile,
keyboardInset = 0
}: Props): React.JSX.Element {
const insets = useSafeAreaInsets()
const listRef = useRef<FlatList<NativeChatMessage>>(null)
const [toolsExpanded, setToolsExpanded] = useState(false)
// Dismiss the question card as soon as it's answered; the live status lingers
// briefly (the agent emits a post-tool event with the same prompt), so hide it
// until a genuinely different question arrives.
const { askKey, showAsk, dismissAsk } = useMobileNativeChatAskDismiss(ask)
// Lift the composer clear of the keyboard, plus the bottom safe-area so it
// never sits under the home indicator / nav bar (mirrors the terminal dock).
const bottomPad = keyboardInset > 0 ? keyboardInset + insets.bottom : insets.bottom
const [atBottom, setAtBottom] = useState(true)
const sendScrollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const { fontScale, pinchGesture } = useMobileNativeChatPinchGesture()
// Surface a rejected send inline above the composer — a bottom toast gets hidden
// behind the keyboard (the case that prompted this). Auto-dismisses after a beat.
const [sendFailed, setSendFailed] = useState(false)
useEffect(() => {
if (!sendFailed) {
return
}
const t = setTimeout(() => setSendFailed(false), 4000)
return () => clearTimeout(t)
}, [sendFailed])
useEffect(
() => () => {
if (sendScrollTimerRef.current) {
clearTimeout(sendScrollTimerRef.current)
}
},
[]
)
const pendingIds = useMemo(() => new Set(pending.map((p) => p.id)), [pending])
// `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]
)
// Follow the tail as the conversation grows and keep the newest message above
// the keyboard when it opens — but only when already pinned to the bottom, so
// we don't yank the user away while they read history. (Also fires on keyboard
// close, which is harmless while atBottom.)
useEffect(() => {
if (data.length === 0 || !atBottom) {
return
}
const t = setTimeout(() => listRef.current?.scrollToEnd({ animated: true }), 60)
return () => clearTimeout(t)
}, [data.length, atBottom, keyboardInset])
const handleSend = useCallback(
async (text: string): Promise<boolean> => {
const accepted = await onSend(text)
if (!accepted) {
setSendFailed(true)
return false
}
setSendFailed(false)
// Always jump to the newest message when the user sends.
setAtBottom(true)
if (sendScrollTimerRef.current) {
clearTimeout(sendScrollTimerRef.current)
}
sendScrollTimerRef.current = setTimeout(() => {
sendScrollTimerRef.current = null
listRef.current?.scrollToEnd({ animated: true })
}, 60)
return true
},
[onSend]
)
const onScroll = useCallback(
(e: NativeSyntheticEvent<NativeScrollEvent>) => {
const { contentOffset, contentSize, layoutMeasurement } = e.nativeEvent
const distanceFromBottom = contentSize.height - (contentOffset.y + layoutMeasurement.height)
setAtBottom(distanceFromBottom < 80)
// Near the top — page in older history.
if (contentOffset.y < 60 && hasMore && !loadingEarlier) {
onLoadEarlier?.()
}
},
[hasMore, loadingEarlier, onLoadEarlier]
)
// Align a single message's top to the top of the viewport.
const onScrollToMessage = useCallback((index: number) => {
listRef.current?.scrollToIndex({ index, viewPosition: 0, animated: true })
}, [])
const renderItem = useCallback(
({ item, index }: { item: NativeChatMessage; index: number }) => (
<MobileNativeChatMessage
message={item}
queued={pendingIds.has(item.id)}
toolsExpanded={toolsExpanded}
fontScale={fontScale}
messageIndex={index}
onScrollToMessage={onScrollToMessage}
onOpenFile={onOpenFile}
/>
),
[pendingIds, toolsExpanded, fontScale, onScrollToMessage, onOpenFile]
)
const emptyState = mobileNativeChatEmptyState(status, agent ?? null, error)
const showLoading = status === 'loading' && messages.length === 0
// Composer-lock flicker guard: on a remote link, brief connState blips or lease
// hand-offs would otherwise toggle the lock placeholder on and off. Only surface
// a lock once it has held ~600ms; drop it instantly so unlocking stays snappy.
const rawLockReason = inputLockReason ?? null
const [lockHeld, setLockHeld] = useState(false)
useEffect(() => {
if (rawLockReason === null) {
setLockHeld(false)
return
}
const timer = setTimeout(() => setLockHeld(true), 600)
return () => clearTimeout(timer)
}, [rawLockReason])
const lockReason = lockHeld ? rawLockReason : null
return (
<View style={[styles.root, { paddingBottom: bottomPad }]}>
{showLoading ? (
<View style={styles.center}>
<ActivityIndicator color={colors.textSecondary} />
</View>
) : (
<GestureHandlerRootView style={styles.listWrap}>
<GestureDetector gesture={pinchGesture}>
<FlatList
ref={listRef}
data={data}
keyExtractor={(item) => item.id}
renderItem={renderItem}
contentContainerStyle={styles.listContent}
onScroll={onScroll}
scrollEventThrottle={32}
onContentSizeChange={() => {
if (data.length > 0 && atBottom) {
listRef.current?.scrollToEnd({ animated: false })
}
}}
// scrollToIndex can fail before an off-screen row is measured —
// fall back to an estimated offset, then retry once it's laid out.
onScrollToIndexFailed={(info) => {
listRef.current?.scrollToOffset({
offset: info.averageItemLength * info.index,
animated: true
})
setTimeout(() => {
listRef.current?.scrollToIndex({
index: info.index,
viewPosition: 0,
animated: true
})
}, 120)
}}
ListHeaderComponent={
hasMore ? (
<Pressable
style={styles.loadEarlier}
onPress={onLoadEarlier}
disabled={loadingEarlier}
>
{loadingEarlier ? (
<ActivityIndicator size="small" color={colors.textMuted} />
) : (
<Text style={styles.loadEarlierText}>Load earlier messages</Text>
)}
</Pressable>
) : null
}
ListEmptyComponent={
emptyState ? (
<View style={styles.center}>
<Text style={styles.emptyTitle}>{emptyState.title}</Text>
<Text style={styles.emptySubtitle}>{emptyState.subtitle}</Text>
</View>
) : null
}
/>
</GestureDetector>
{/* Jump-to-latest control. The scroll-to-top affordance now lives
per-message (the up-arrow in each agent message's controls). */}
{!atBottom ? (
<Pressable
accessibilityLabel="Scroll to latest"
style={[styles.fab, styles.fabBottom]}
onPress={() => listRef.current?.scrollToEnd({ animated: true })}
>
<ArrowDown size={18} color={colors.textPrimary} strokeWidth={2.2} />
</Pressable>
) : null}
</GestureHandlerRootView>
)}
{/* Pending agent prompt: a structured AskUserQuestion wins, then a
heuristic permission, then a heuristic question. */}
{showAsk && ask ? (
<MobileNativeChatAsk
key={askKey ?? 'ask'}
prompt={ask}
onAnswer={async (selections) => {
const accepted = (await onAnswerAsk?.(ask, selections)) ?? false
if (accepted) {
dismissAsk()
}
return accepted
}}
onCancel={async () => {
const accepted = (await onCancelAsk?.()) ?? false
if (accepted) {
dismissAsk()
}
return accepted
}}
/>
) : permission ? (
<MobileNativeChatPermission
key={JSON.stringify(permission)}
permission={permission}
onRespond={async (send) => (await onRespondPermission?.(send)) ?? false}
/>
) : question ? (
<MobileNativeChatQuestion
key={mobileChatQuestionKey(question)}
question={question}
onAnswer={async (text) => (await onAnswerQuestion?.(text)) ?? false}
/>
) : null}
{/* Chrome row above the composer: the working indicator and the global
tool-calls expand/collapse toggle on the left, Stop in the far corner. */}
<View style={styles.chromeRow}>
<View style={styles.chromeLeft}>
{agentWorking ? <MobileAgentWorkingIndicator /> : null}
<Pressable
style={({ pressed }) => [styles.chromeToggle, pressed && styles.pressed]}
onPress={() => setToolsExpanded((v) => !v)}
hitSlop={8}
>
{toolsExpanded ? (
<ChevronsDownUp size={14} color={colors.textMuted} strokeWidth={2} />
) : (
<ChevronsUpDown size={14} color={colors.textMuted} strokeWidth={2} />
)}
<Text style={styles.chromeToggleLabel}>{toolsExpanded ? 'Collapse' : 'Tools'}</Text>
</Pressable>
</View>
{agentWorking ? (
<Pressable
style={({ pressed }) => [styles.stopButton, pressed && styles.pressed]}
onPress={onStop}
hitSlop={8}
accessibilityLabel="Stop the agent"
>
<Square size={13} color={colors.statusRed} strokeWidth={2.4} fill={colors.statusRed} />
<Text style={styles.stopLabel}>Stop</Text>
</Pressable>
) : null}
</View>
{sendFailed ? (
<View style={styles.sendError}>
<Text style={styles.sendErrorText}>
{rawLockReason === 'disconnected'
? 'Message not sent — reconnecting…'
: 'Message not sent'}
</Text>
</View>
) : null}
<MobileNativeChatComposer
value={composerText}
onChangeText={onComposerTextChange}
onSend={handleSend}
onAttachImage={onAttachImage}
isAttaching={isAttaching}
onMicPress={onMicPress}
micActive={micActive}
dictationMode={dictationMode}
onMicPressIn={onMicPressIn}
onMicPressOut={onMicPressOut}
disabled={lockReason !== null}
placeholder={
lockReason === 'disconnected'
? 'Reconnecting…'
: lockReason === 'waiting'
? 'Waiting for terminal…'
: 'Message, @files, /commands'
}
filePaths={filePaths}
onNeedFiles={onNeedFiles}
/>
</View>
)
}
@@ -8,9 +8,9 @@ function ok(id: string, result: unknown): RpcSuccess {
}
function clientWithResponses(responses: RpcResponse[]): Pick<RpcClient, 'sendRequest'> & {
calls: Array<{ method: string; params: unknown }>
calls: { method: string; params: unknown }[]
} {
const calls: Array<{ method: string; params: unknown }> = []
const calls: { method: string; params: unknown }[] = []
return {
calls,
sendRequest: vi.fn(async (method: string, params?: unknown) => {
@@ -35,7 +35,7 @@ describe('attachMobileImageToTerminal', () => {
_meta: { runtimeId: 'r' }
},
ok('save', '/tmp/orca-attach.png'),
ok('send', { ok: true })
ok('send', { send: { accepted: true } })
])
const sent = await attachMobileImageToTerminal('library', {
@@ -65,7 +65,7 @@ describe('attachMobileImageToTerminal', () => {
_meta: { runtimeId: 'r' }
},
ok('save', '/tmp/x.png'),
ok('send', { ok: true })
ok('send', { send: { accepted: true } })
])
await attachMobileImageToTerminal('files', {
@@ -104,7 +104,7 @@ describe('attachMobileImageToTerminal', () => {
_meta: { runtimeId: 'r' }
},
ok('save', '/tmp/y.png'),
ok('send', { ok: true })
ok('send', { send: { accepted: true } })
])
await attachMobileImageToTerminal('library', {
@@ -119,7 +119,7 @@ describe('attachMobileImageToTerminal', () => {
expect(sendCall?.params).not.toHaveProperty('client')
})
it('waits for pending live input before sending the image payload', async () => {
it('drops the image payload when the final pre-send check observes an input lease gap', async () => {
const client = clientWithResponses([
{
id: 'start',
@@ -129,6 +129,8 @@ describe('attachMobileImageToTerminal', () => {
},
ok('save', '/tmp/pending.png')
])
// Why: upload can outlive the stream subscription whose acknowledgement
// originally enabled the composer.
const beforeTerminalSend = vi.fn(async () => false)
const sent = await attachMobileImageToTerminal('library', {
@@ -144,4 +146,27 @@ describe('attachMobileImageToTerminal', () => {
expect(beforeTerminalSend).toHaveBeenCalledWith('term-pending')
expect(client.calls.some((call) => call.method === 'terminal.send')).toBe(false)
})
it('reports failure when the terminal rejects the uploaded image path', async () => {
const client = clientWithResponses([
{
id: 'start',
ok: false,
error: { code: 'method_not_found', message: 'no' },
_meta: { runtimeId: 'r' }
},
ok('save', '/tmp/rejected.png'),
ok('send', { send: { accepted: false } })
])
const sent = await attachMobileImageToTerminal('library', {
client,
terminal: 'term-rejected',
deviceToken: null,
getConnectionId: async () => null,
pickImage: vi.fn().mockResolvedValue({ base64: 'EEEE' })
})
expect(sent).toBe(false)
})
})
@@ -4,6 +4,7 @@ import {
saveMobileClipboardImageAsTempFile
} from './mobile-clipboard-image'
import type { MobileImageSource, PickedMobileImage } from './mobile-image-source-picker'
import { isTerminalSendRpcAccepted } from '../terminal/terminal-send-rpc-response'
export type AttachMobileImageDeps = {
readonly client: Pick<RpcClient, 'sendRequest'>
@@ -50,11 +51,11 @@ export async function attachMobileImageToTerminal(
if (beforeTerminalSend && !(await beforeTerminalSend(terminal))) {
return false
}
await client.sendRequest('terminal.send', {
const response = await client.sendRequest('terminal.send', {
terminal,
text: payload,
enter: false,
...(deviceToken ? { client: { id: deviceToken, type: 'mobile' as const } } : {})
})
return true
return isTerminalSendRpcAccepted(response)
}
@@ -0,0 +1,61 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
mobileNativeChatQuestionOffsets,
scheduleMobileClaudeAnswer,
MOBILE_NATIVE_CHAT_ADVANCE_BUFFER_MS,
MOBILE_NATIVE_CHAT_QUESTION_STEP_MS,
MOBILE_NATIVE_CHAT_SUBMIT_DELAY_MS
} from './mobile-native-chat-answer-stepping'
describe('mobileNativeChatQuestionOffsets', () => {
it('matches the desktop cadence constants', () => {
expect(MOBILE_NATIVE_CHAT_SUBMIT_DELAY_MS).toBe(500)
expect(MOBILE_NATIVE_CHAT_ADVANCE_BUFFER_MS).toBe(500)
expect(MOBILE_NATIVE_CHAT_QUESTION_STEP_MS).toBe(1000)
})
it('paces each question a full step apart, Enter 500ms after its body', () => {
expect(mobileNativeChatQuestionOffsets(0)).toEqual({ bodyAt: 0, enterAt: 500 })
expect(mobileNativeChatQuestionOffsets(1)).toEqual({ bodyAt: 1000, enterAt: 1500 })
expect(mobileNativeChatQuestionOffsets(2)).toEqual({ bodyAt: 2000, enterAt: 2500 })
})
})
describe('scheduleMobileClaudeAnswer', () => {
beforeEach(() => vi.useFakeTimers())
afterEach(() => vi.useRealTimers())
it('writes each question body then its Enter, paced per step, one Enter per line', () => {
const events: string[] = []
const timers = scheduleMobileClaudeAnswer(
['', 'b', 'c'], // blank middle answer must still get its own body + Enter
(line) => events.push(`body:${line}`),
() => events.push('enter')
)
expect(timers).toHaveLength(6)
vi.advanceTimersByTime(0)
expect(events).toEqual(['body:'])
vi.advanceTimersByTime(MOBILE_NATIVE_CHAT_SUBMIT_DELAY_MS)
expect(events).toEqual(['body:', 'enter'])
vi.advanceTimersByTime(MOBILE_NATIVE_CHAT_ADVANCE_BUFFER_MS)
expect(events).toEqual(['body:', 'enter', 'body:b'])
vi.runAllTimers()
expect(events).toEqual(['body:', 'enter', 'body:b', 'enter', 'body:c', 'enter'])
})
it('cancelling the returned timers stops all pending writes', () => {
const events: string[] = []
const timers = scheduleMobileClaudeAnswer(
['a', 'b'],
(line) => events.push(`body:${line}`),
() => events.push('enter')
)
for (const timer of timers) {
clearTimeout(timer)
}
vi.runAllTimers()
expect(events).toEqual([])
})
})
@@ -0,0 +1,13 @@
import {
nativeChatQuestionOffsets,
scheduleNativeChatAnswer,
NATIVE_CHAT_ADVANCE_BUFFER_MS,
NATIVE_CHAT_QUESTION_STEP_MS,
NATIVE_CHAT_SUBMIT_DELAY_MS
} from '../../../src/shared/native-chat-answer-stepping'
export const MOBILE_NATIVE_CHAT_ADVANCE_BUFFER_MS = NATIVE_CHAT_ADVANCE_BUFFER_MS
export const MOBILE_NATIVE_CHAT_QUESTION_STEP_MS = NATIVE_CHAT_QUESTION_STEP_MS
export const MOBILE_NATIVE_CHAT_SUBMIT_DELAY_MS = NATIVE_CHAT_SUBMIT_DELAY_MS
export const mobileNativeChatQuestionOffsets = nativeChatQuestionOffsets
export const scheduleMobileClaudeAnswer = scheduleNativeChatAnswer
@@ -0,0 +1,160 @@
import { describe, expect, it } from 'vitest'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import {
buildAskAnswerKeys,
extractPendingAsk,
formatAskAnswer,
parseAskFromStatus
} from './mobile-native-chat-ask'
function msg(blocks: NativeChatMessage['blocks'], id = 'm'): NativeChatMessage {
return { id, role: 'assistant', blocks, timestamp: 0, source: 'transcript' }
}
const askInput = {
questions: [
{
question: 'Pick one',
header: 'Choice',
multiSelect: false,
options: [
{ label: 'A', description: 'first' },
{ label: 'B', description: 'second' }
]
}
]
}
describe('extractPendingAsk', () => {
it('returns the structured prompt from a pending AskUserQuestion call', () => {
const ask = extractPendingAsk([
msg([{ type: 'tool-call', name: 'AskUserQuestion', input: askInput }])
])
expect(ask?.questions[0]).toMatchObject({
question: 'Pick one',
header: 'Choice',
multiSelect: false
})
expect(ask?.questions[0]!.options.map((o) => o.label)).toEqual(['A', 'B'])
})
it('returns null once a tool-result follows the ask', () => {
const ask = extractPendingAsk([
msg([{ type: 'tool-call', name: 'AskUserQuestion', input: askInput }], 'a'),
msg([{ type: 'tool-result', output: 'answered' }], 'r')
])
expect(ask).toBeNull()
})
it('ignores non-ask tool calls', () => {
expect(
extractPendingAsk([msg([{ type: 'tool-call', name: 'Bash', input: { command: 'ls' } }])])
).toBeNull()
})
it('survives an unrelated earlier tool result (FIFO, not adjacency)', () => {
// A parallel Bash call precedes the ask; its result resolves the Bash call
// (oldest outstanding), so the unanswered question must remain pending.
const ask = extractPendingAsk([
msg([{ type: 'tool-call', name: 'Bash', input: { command: 'ls' } }], 'c1'),
msg([{ type: 'tool-call', name: 'AskUserQuestion', input: askInput }], 'a1'),
msg([{ type: 'tool-result', output: 'ls output' }], 'r1')
])
expect(ask?.questions[0]!.question).toBe('Pick one')
})
it("clears the ask only when the ask's own result arrives", () => {
const ask = extractPendingAsk([
msg([{ type: 'tool-call', name: 'Bash', input: { command: 'ls' } }], 'c1'),
msg([{ type: 'tool-call', name: 'AskUserQuestion', input: askInput }], 'a1'),
msg([{ type: 'tool-result', output: 'ls output' }], 'r1'),
msg([{ type: 'tool-result', output: 'answered' }], 'r2')
])
expect(ask).toBeNull()
})
it('keeps the latest ask when several appear', () => {
const ask = extractPendingAsk([
msg([{ type: 'tool-call', name: 'AskUserQuestion', input: askInput }], 'a1'),
msg([{ type: 'tool-result', output: 'x' }], 'r1'),
msg([
{
type: 'tool-call',
name: 'AskUserQuestion',
input: {
questions: [{ question: 'Second', multiSelect: false, options: [{ label: 'Z' }] }]
}
}
])
])
expect(ask?.questions[0]!.question).toBe('Second')
})
})
describe('parseAskFromStatus', () => {
it('parses the live interactivePrompt JSON into a prompt', () => {
const ask = parseAskFromStatus(JSON.stringify(askInput))
expect(ask?.questions[0]!.options.map((o) => o.label)).toEqual(['A', 'B'])
})
it('returns null for empty or malformed input', () => {
expect(parseAskFromStatus(undefined)).toBeNull()
expect(parseAskFromStatus('')).toBeNull()
expect(parseAskFromStatus('{not json')).toBeNull()
expect(parseAskFromStatus('{"foo":1}')).toBeNull()
})
})
describe('formatAskAnswer', () => {
it('joins selected labels per question', () => {
const prompt = {
questions: [
{ question: 'q1', multiSelect: true, options: [{ label: 'A' }, { label: 'B' }] },
{ question: 'q2', multiSelect: false, options: [{ label: 'C' }] }
]
}
expect(formatAskAnswer(prompt, [{ indices: [0, 1] }, { indices: [0] }])).toBe('A, B\nC')
})
it('keeps one line per question with a blank middle answer (3 questions)', () => {
const prompt = {
questions: [
{ question: 'q1', multiSelect: false, options: [{ label: 'A' }] },
{ question: 'q2', multiSelect: false, options: [{ label: 'B' }] },
{ question: 'q3', multiSelect: false, options: [{ label: 'C' }] }
]
}
const answer = formatAskAnswer(prompt, [{ indices: [0] }, { indices: [] }, { indices: [0] }])
expect(answer).toBe('A\n\nC')
expect(answer.split('\n')).toHaveLength(3)
})
})
describe('buildAskAnswerKeys', () => {
it('answers a single-select pick with its option number only', () => {
const prompt = {
questions: [
{ question: 'q', multiSelect: false, options: [{ label: 'Tabs' }, { label: 'Spaces' }] }
]
}
expect(buildAskAnswerKeys(prompt, [{ indices: [1] }])).toEqual([{ raw: '2' }])
})
it('toggles multi-select numbers then steps to Submit and confirms', () => {
const prompt = {
questions: [
{
question: 'q',
multiSelect: true,
options: [{ label: 'A' }, { label: 'B' }, { label: 'C' }]
}
]
}
expect(buildAskAnswerKeys(prompt, [{ indices: [0, 2] }])).toEqual([
{ raw: '1' },
{ raw: '3' },
{ raw: '\x1b[C' },
{ raw: '\r' }
])
})
})
@@ -0,0 +1,2 @@
// Mobile and desktop share one parser/formatter so question-tool behavior cannot drift.
export * from '../../../src/shared/native-chat-ask'
@@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest'
import {
applyAutocomplete,
detectAutocompleteTrigger,
rankSuggestions
} from './mobile-native-chat-autocomplete'
describe('detectAutocompleteTrigger', () => {
it('detects a slash command only at the start', () => {
expect(detectAutocompleteTrigger('/rev', 4)).toEqual({
kind: 'slash',
query: 'rev',
start: 0,
end: 4
})
expect(detectAutocompleteTrigger('hi /rev', 7)).toBeNull()
})
it('detects an @ mention after whitespace or at start', () => {
expect(detectAutocompleteTrigger('@src', 4)).toMatchObject({ kind: 'file', query: 'src' })
expect(detectAutocompleteTrigger('look at @comp', 13)).toMatchObject({
kind: 'file',
query: 'comp'
})
})
it('does not trigger @ mid-word (email-like)', () => {
expect(detectAutocompleteTrigger('me@host', 7)).toBeNull()
})
it('closes the token once a space is typed', () => {
expect(detectAutocompleteTrigger('@src ', 5)).toBeNull()
})
it('returns empty query right after the trigger char', () => {
expect(detectAutocompleteTrigger('@', 1)).toMatchObject({ kind: 'file', query: '' })
})
})
describe('applyAutocomplete', () => {
it('replaces the trigger span and leaves a trailing space + cursor', () => {
const trigger = detectAutocompleteTrigger('look at @comp', 13)!
const { text, cursor } = applyAutocomplete('look at @comp', trigger, '@src/App.tsx')
expect(text).toBe('look at @src/App.tsx ')
expect(cursor).toBe(text.length)
})
})
describe('rankSuggestions', () => {
it('prefers prefix matches on the basename', () => {
const out = rankSuggestions(['src/app/Main.tsx', 'src/AppBar.tsx', 'lib/zapp.ts'], 'app')
expect(out[0]).toBe('src/AppBar.tsx')
expect(out).toContain('lib/zapp.ts')
})
it('returns the head of the list for an empty query', () => {
expect(rankSuggestions(['a', 'b', 'c'], '', 2)).toEqual(['a', 'b'])
})
})
@@ -0,0 +1,93 @@
// Why: the composer offers two kinds of autocomplete — `@file` mentions and
// `/slash` commands. Detection is pure (text + cursor → active trigger) so it's
// unit-testable and the composer stays a thin view over it.
export type AutocompleteKind = 'file' | 'slash'
export type AutocompleteTrigger = {
kind: AutocompleteKind
/** The query typed after the trigger char (may be empty). */
query: string
/** Index of the trigger char in the text (inclusive). */
start: number
/** Index just past the cursor / token end (exclusive) — the replace span end. */
end: number
}
const TOKEN_CHAR = /[^\s]/
/** Detect an active autocomplete trigger at the cursor. A `@` mention triggers
* anywhere it follows whitespace/start; a `/` command only at the very start of
* the input (slash commands are line-leading). Returns null when the cursor is
* not inside such a token. */
export function detectAutocompleteTrigger(
text: string,
cursor: number
): AutocompleteTrigger | null {
const pos = Math.max(0, Math.min(cursor, text.length))
// Walk left from the cursor over non-whitespace token chars to find the trigger.
let i = pos - 1
while (i >= 0 && TOKEN_CHAR.test(text[i]!)) {
i--
}
const triggerIndex = i + 1
const triggerChar = text[triggerIndex]
if (triggerChar !== '@' && triggerChar !== '/') {
return null
}
const before = triggerIndex === 0 ? '' : text[triggerIndex - 1]!
if (triggerChar === '/' && triggerIndex !== 0) {
// Slash commands are only offered at the very start of the message.
return null
}
if (triggerChar === '@' && triggerIndex !== 0 && !/\s/.test(before)) {
return null
}
const query = text.slice(triggerIndex + 1, pos)
// A space in the query means the token already closed.
if (/\s/.test(query)) {
return null
}
return {
kind: triggerChar === '@' ? 'file' : 'slash',
query,
start: triggerIndex,
end: pos
}
}
/** Replace the trigger span with `value`, leaving a trailing space and the
* cursor after it. Returns the new text and the new cursor position. */
export function applyAutocomplete(
text: string,
trigger: AutocompleteTrigger,
value: string
): { text: string; cursor: number } {
const inserted = `${value} `
const next = text.slice(0, trigger.start) + inserted + text.slice(trigger.end)
return { text: next, cursor: trigger.start + inserted.length }
}
/** Rank suggestions for a query: case-insensitive, prefix matches first, then
* substring matches, capped. Used for both file paths and command names. */
export function rankSuggestions(candidates: readonly string[], query: string, limit = 8): string[] {
const q = query.toLowerCase()
if (q.length === 0) {
return candidates.slice(0, limit)
}
const prefix: string[] = []
const substring: string[] = []
for (const candidate of candidates) {
const lower = candidate.toLowerCase()
const base = lower.split('/').pop() ?? lower
if (lower.startsWith(q) || base.startsWith(q)) {
prefix.push(candidate)
} else if (lower.includes(q)) {
substring.push(candidate)
}
if (prefix.length >= limit) {
break
}
}
return [...prefix, ...substring].slice(0, limit)
}
@@ -0,0 +1,153 @@
import { describe, expect, it } from 'vitest'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import {
foldToolMessages,
pairToolBlocks,
splitNativeChatBlocks
} from './mobile-native-chat-blocks'
function msg(
role: NativeChatMessage['role'],
blocks: NativeChatMessage['blocks'],
id: string = role
): NativeChatMessage {
return { id, role, blocks, timestamp: 0, source: 'transcript' }
}
describe('splitNativeChatBlocks', () => {
it('separates prose from tool blocks', () => {
const { prose, tools } = splitNativeChatBlocks([
{ type: 'text', text: 'hi' },
{ type: 'tool-call', name: 'Bash', input: {} },
{ type: 'tool-result', output: 'ok' }
])
expect(prose.map((b) => b.type)).toEqual(['text'])
expect(tools.map((b) => b.type)).toEqual(['tool-call', 'tool-result'])
})
})
describe('pairToolBlocks', () => {
it('pairs each call with the following result', () => {
const pairs = pairToolBlocks([
{ type: 'tool-call', name: 'Bash', input: { command: 'ls' } },
{ type: 'tool-result', output: 'a\nb' },
{ type: 'tool-call', name: 'Edit', input: {} },
{ type: 'tool-result', output: 'ok' }
])
expect(pairs).toHaveLength(2)
expect(pairs[0]!.call?.name).toBe('Bash')
expect(pairs[0]!.result?.output).toBe('a\nb')
expect(pairs[1]!.call?.name).toBe('Edit')
})
it('keeps an orphan result on its own', () => {
const pairs = pairToolBlocks([{ type: 'tool-result', output: 'x' }])
expect(pairs).toEqual([{ result: { type: 'tool-result', output: 'x' } }])
})
it('does not attach a second result to an already-filled pair', () => {
const pairs = pairToolBlocks([
{ type: 'tool-call', name: 'Bash', input: {} },
{ type: 'tool-result', output: '1' },
{ type: 'tool-result', output: '2' }
])
expect(pairs).toHaveLength(2)
expect(pairs[1]!.call).toBeUndefined()
expect(pairs[1]!.result?.output).toBe('2')
})
it('pairs batched parallel calls by ordinal, not adjacency', () => {
const call1 = { type: 'tool-call' as const, name: 'Bash', input: { command: 'a' } }
const call2 = { type: 'tool-call' as const, name: 'Bash', input: { command: 'b' } }
const result1 = { type: 'tool-result' as const, output: 'out-a' }
const result2 = { type: 'tool-result' as const, output: 'out-b' }
// Parallel calls stream as [call, call, result, result]; the Nth result
// answers the Nth call, so result1 must land on call1 (not the nearer call2).
const pairs = pairToolBlocks([call1, call2, result1, result2])
expect(pairs).toHaveLength(2)
expect(pairs[0]).toEqual({ call: call1, result: result1 })
expect(pairs[1]).toEqual({ call: call2, result: result2 })
})
it('does not graft a dropped over-limit call result onto a kept call', () => {
const call1 = { type: 'tool-call' as const, name: 'Bash', input: { command: 'a' } }
const call2 = { type: 'tool-call' as const, name: 'Bash', input: { command: 'b' } }
const call3 = { type: 'tool-call' as const, name: 'Bash', input: { command: 'c' } }
const result1 = { type: 'tool-result' as const, output: 'out-a' }
const result2 = { type: 'tool-result' as const, output: 'out-b' }
const result3 = { type: 'tool-result' as const, output: 'out-c' }
const pairs = pairToolBlocks([call1, call2, call3, result1, result2, result3], 2)
expect(pairs).toHaveLength(2)
expect(pairs[0]).toEqual({ call: call1, result: result1 })
// call3 was dropped past the limit, so result3 must not misgraft onto call2.
expect(pairs[1]).toEqual({ call: call2, result: result2 })
})
it('retains the last result while bounding rendered pairs', () => {
const blocks = Array.from({ length: 20 }, (_unused, index) => [
{ type: 'tool-call' as const, name: 'Bash', input: { index } },
{ type: 'tool-result' as const, output: String(index) }
]).flat()
const pairs = pairToolBlocks(blocks, 6)
expect(pairs).toHaveLength(6)
expect(pairs[5].result?.output).toBe('5')
})
})
describe('foldToolMessages', () => {
it('folds tool-call-only assistant messages and result messages into the prose turn', () => {
const folded = foldToolMessages([
msg('assistant', [{ type: 'text', text: 'doing' }], 'a0'),
msg('assistant', [{ type: 'tool-call', name: 'Bash', input: {} }], 'a1'),
msg('tool', [{ type: 'tool-result', output: 'done' }], 't1'),
msg('assistant', [{ type: 'tool-call', name: 'Edit', input: {} }], 'a2'),
msg('tool', [{ type: 'tool-result', output: 'ok' }], 't2')
])
expect(folded).toHaveLength(1)
expect(folded[0]!.blocks.map((b) => b.type)).toEqual([
'text',
'tool-call',
'tool-result',
'tool-call',
'tool-result'
])
})
it('starts a new turn at the next prose assistant message', () => {
const folded = foldToolMessages([
msg('assistant', [{ type: 'text', text: 't1' }], 'a0'),
msg('assistant', [{ type: 'tool-call', name: 'Bash', input: {} }], 'a1'),
msg('assistant', [{ type: 'text', text: 't2' }], 'a2')
])
expect(folded.map((m) => m.id)).toEqual(['a0', 'a2'])
})
it('keeps a tool message standalone when no assistant precedes it', () => {
const folded = foldToolMessages([msg('tool', [{ type: 'tool-result', output: 'x' }])])
expect(folded).toHaveLength(1)
expect(folded[0]!.role).toBe('tool')
})
it('does not merge across a user message', () => {
const folded = foldToolMessages([
msg('assistant', [{ type: 'tool-call', name: 'Bash', input: {} }], 'a'),
msg('user', [{ type: 'text', text: 'q' }], 'u'),
msg('tool', [{ type: 'tool-result', output: 'r' }], 't')
])
expect(folded.map((m) => m.role)).toEqual(['assistant', 'user', 'tool'])
})
it('folds a long tool run without mutating the source assistant', () => {
const assistant = msg('assistant', [{ type: 'text', text: 'working' }], 'a')
const tools = Array.from({ length: 1_000 }, (_unused, index) =>
msg('tool', [{ type: 'tool-result', output: String(index) }], `t-${index}`)
)
const folded = foldToolMessages([assistant, ...tools])
expect(folded[0].blocks).toHaveLength(1_001)
expect(assistant.blocks).toHaveLength(1)
})
})
@@ -0,0 +1,11 @@
export {
isImageRefBlock,
isTextBlock,
NATIVE_CHAT_SOURCE_PRIORITY
} from '../../../src/shared/native-chat-types'
export {
foldToolMessages,
pairToolBlocks,
splitNativeChatBlocks,
type NativeChatToolPair as ToolPair
} from '../../../src/shared/native-chat-tool-fold'
@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest'
import { diffFromText, diffFromToolCall } from './mobile-native-chat-diff'
describe('diffFromToolCall', () => {
it('builds del/add lines from an Edit tool call', () => {
const lines = diffFromToolCall('Edit', {
file_path: 'src/a.ts',
old_string: 'const a = 1\nconst b = 2',
new_string: 'const a = 10'
})
expect(lines).toEqual([
{ kind: 'meta', text: 'src/a.ts' },
{ kind: 'del', text: 'const a = 1' },
{ kind: 'del', text: 'const b = 2' },
{ kind: 'add', text: 'const a = 10' }
])
})
it('returns null for non-edit tools', () => {
expect(diffFromToolCall('Bash', { command: 'ls' })).toBeNull()
})
})
describe('diffFromText', () => {
it('parses unified diff lines with colours', () => {
const lines = diffFromText('@@ -1,2 +1,2 @@\n context\n-old\n+new')
expect(lines).toEqual([
{ kind: 'meta', text: '@@ -1,2 +1,2 @@' },
{ kind: 'context', text: ' context' },
{ kind: 'del', text: 'old' },
{ kind: 'add', text: 'new' }
])
})
it('does not treat ordinary prose as a diff', () => {
expect(diffFromText('Here is a list:\n- one bullet\nthat is all')).toBeNull()
})
it('caps large diffs before creating render rows', () => {
const text = Array.from(
{ length: 1_000 },
(_unused, index) => `${index % 2 === 0 ? '+' : '-'}line-${index}`
).join('\n')
const lines = diffFromText(text)
expect(lines).toHaveLength(120)
expect(lines?.at(-1)).toEqual({ kind: 'meta', text: '… diff truncated …' })
})
it('honors a smaller caller-owned aggregate row budget', () => {
const text = Array.from(
{ length: 100 },
(_unused, index) => `${index % 2 === 0 ? '+' : '-'}line-${index}`
).join('\n')
expect(diffFromText(text, 20)).toHaveLength(20)
})
})
@@ -0,0 +1,6 @@
export {
diffFromText,
diffFromToolCall,
type NativeChatDiffLine as DiffLine,
type NativeChatDiffLineKind as DiffLineKind
} from '../../../src/shared/native-chat-diff'
@@ -0,0 +1,112 @@
import { describe, expect, it } from 'vitest'
import type { AgentStatusEntry } from '../../../src/shared/agent-status-types'
import {
canShowMobileNativeChat,
isMobileNativeChatTranscriptReadable,
resolveMobileNativeChat
} from './mobile-native-chat-eligibility'
function status(overrides: Partial<AgentStatusEntry> = {}): AgentStatusEntry {
return {
state: 'working',
prompt: '',
updatedAt: 0,
stateStartedAt: 0,
paneKey: 'tab:leaf',
...overrides
} as AgentStatusEntry
}
describe('resolveMobileNativeChat', () => {
it('prefers the authoritative supported live agent over a stale launch hint', () => {
expect(
resolveMobileNativeChat({
type: 'terminal',
launchAgent: 'claude',
agentStatus: {
agentType: 'codex',
providerSession: { id: 'codex-session', transcriptPath: '/tmp/codex.jsonl' }
}
} as never)
).toMatchObject({ agent: 'codex', sessionId: 'codex-session' })
})
it('rejects an unsupported live agent instead of combining it with a stale hint', () => {
expect(
resolveMobileNativeChat({
type: 'terminal',
launchAgent: 'claude',
agentStatus: {
agentType: 'gemini',
providerSession: { id: 'gemini-session', transcriptPath: '/tmp/gemini.jsonl' }
}
} as never)
).toBeNull()
})
it('resolves agent + sessionId from launchAgent and provider session', () => {
expect(
resolveMobileNativeChat({
type: 'terminal',
launchAgent: 'claude',
agentStatus: status({
providerSession: {
key: 'session_id',
id: 'sess-1',
transcriptPath: '/tmp/claude-real-transcript.jsonl'
}
})
})
).toEqual({
agent: 'claude',
sessionId: 'sess-1',
transcriptPath: '/tmp/claude-real-transcript.jsonl'
})
})
it('falls back to agentStatus.agentType when no launchAgent', () => {
expect(
resolveMobileNativeChat({
type: 'terminal',
agentStatus: status({ agentType: 'codex' })
})
).toEqual({ agent: 'codex', sessionId: null, transcriptPath: null })
})
it('admits OpenClaude with its distinct agent identity', () => {
expect(resolveMobileNativeChat({ type: 'terminal', launchAgent: 'openclaude' })).toEqual({
agent: 'openclaude',
sessionId: null,
transcriptPath: null
})
})
it('returns null for unsupported agents', () => {
expect(resolveMobileNativeChat({ type: 'terminal', launchAgent: 'gemini' })).toBeNull()
})
it('admits Grok only when its transcript is readable by the serving host', () => {
const tab = { type: 'terminal', launchAgent: 'grok' }
expect(resolveMobileNativeChat(tab, isMobileNativeChatTranscriptReadable(null))).toMatchObject({
agent: 'grok'
})
expect(
resolveMobileNativeChat(tab, isMobileNativeChatTranscriptReadable('runtime-ssh-environment'))
).toMatchObject({ agent: 'grok' })
expect(
resolveMobileNativeChat(tab, isMobileNativeChatTranscriptReadable('model-a-ssh'))
).toBeNull()
})
it('returns null for a plain shell (no agent)', () => {
expect(resolveMobileNativeChat({ type: 'terminal' })).toBeNull()
})
it('returns null for non-terminal tabs', () => {
expect(resolveMobileNativeChat({ type: 'browser', launchAgent: 'claude' })).toBeNull()
})
it('canShowMobileNativeChat mirrors resolution', () => {
expect(canShowMobileNativeChat({ type: 'terminal', launchAgent: 'claude' })).toBe(true)
expect(canShowMobileNativeChat(null)).toBe(false)
})
})
@@ -0,0 +1,66 @@
import type { AgentStatusEntry } from '../../../src/shared/agent-status-types'
import { isRuntimeOwnedSshTargetId } from '../../../src/shared/execution-host'
import { isNativeChatSupportedAgent } from '../../../src/shared/native-chat-agent-support'
// Why: native chat renders an agent's own JSONL transcript, and the host
// resolver knows these transcript layouts. Grok is additionally gated on host
// readability because Model-A SSH stores its transcript on the remote target.
export function isMobileNativeChatTranscriptReadable(
connectionId: string | null | undefined
): boolean {
return connectionId === null || isRuntimeOwnedSshTargetId(connectionId)
}
export type MobileNativeChatResolution = {
agent: string
/** The agent's own session id, or null before it has reported one (the view
* then shows a waiting state instead of trying to read an unaddressable file). */
sessionId: string | null
/** Hook-reported transcript path. Recent Claude sessions cannot always be
* resolved from the provider session id, so mobile forwards this to runtime. */
transcriptPath: string | null
}
export type MobileNativeChatTab = {
type: string
launchAgent?: string | null
agentStatus?: AgentStatusEntry | null
}
/** Resolve a session tab to the transcript identity native chat needs, or
* null when the tab can't show native chat (not a terminal, no agent, or an
* agent whose transcript the host can't read). Agent comes from the launch
* hint or the live status; session id from the captured provider session. */
export function resolveMobileNativeChat(
tab: MobileNativeChatTab | null,
nativeChatTranscriptIsLocalReadable = false
): MobileNativeChatResolution | null {
if (!tab || tab.type !== 'terminal') {
return null
}
const liveAgent = tab.agentStatus?.agentType ?? null
const agent = liveAgent
? isNativeChatSupportedAgent(liveAgent)
? liveAgent
: null
: tab.launchAgent
if (!agent || !isNativeChatSupportedAgent(agent)) {
return null
}
if (agent === 'grok' && !nativeChatTranscriptIsLocalReadable) {
return null
}
return {
agent,
sessionId: tab.agentStatus?.providerSession?.id ?? null,
transcriptPath: tab.agentStatus?.providerSession?.transcriptPath ?? null
}
}
/** Whether the tab can toggle into native chat — gates the long-press item. */
export function canShowMobileNativeChat(
tab: MobileNativeChatTab | null,
nativeChatTranscriptIsLocalReadable = false
): boolean {
return resolveMobileNativeChat(tab, nativeChatTranscriptIsLocalReadable) !== null
}
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import { mergeNativeChatMessages } from './mobile-native-chat-merge'
function msg(id: string, overrides: Partial<NativeChatMessage> = {}): NativeChatMessage {
return {
id,
role: 'assistant',
blocks: [{ type: 'text', text: id }],
timestamp: 0,
source: 'transcript',
...overrides
}
}
describe('mergeNativeChatMessages', () => {
it('appends new ids in arrival order', () => {
const merged = mergeNativeChatMessages([msg('a')], [msg('b'), msg('c')])
expect(merged.map((m) => m.id)).toEqual(['a', 'b', 'c'])
})
it('replaces a re-emitted id in place without reordering', () => {
const merged = mergeNativeChatMessages(
[msg('a'), msg('b'), msg('c')],
[msg('b', { blocks: [{ type: 'text', text: 'updated' }] })]
)
expect(merged.map((m) => m.id)).toEqual(['a', 'b', 'c'])
expect(merged[1]!.blocks).toEqual([{ type: 'text', text: 'updated' }])
})
it('does not let a lower-priority source overwrite a higher one', () => {
const merged = mergeNativeChatMessages(
[msg('a', { source: 'transcript', blocks: [{ type: 'text', text: 'real' }] })],
[msg('a', { source: 'scrape', blocks: [{ type: 'text', text: 'scraped' }] })]
)
expect(merged[0]!.blocks).toEqual([{ type: 'text', text: 'real' }])
})
it('returns the existing array unchanged for an empty batch', () => {
const existing = [msg('a')]
expect(mergeNativeChatMessages(existing, [])).toBe(existing)
})
it('does not mutate the existing array', () => {
const existing = [msg('a')]
mergeNativeChatMessages(existing, [msg('b')])
expect(existing.map((m) => m.id)).toEqual(['a'])
})
})
@@ -0,0 +1,9 @@
// Keep the established mobile import path while executing the shared desktop/mobile merger.
export {
applyAppend,
boundNativeChatWindow,
createNativeChatMerger,
mergeNativeChatMessages,
replaceList,
type NativeChatMerger
} from '../../../src/shared/native-chat-merge'
@@ -0,0 +1,77 @@
import { describe, expect, it } from 'vitest'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import {
applyAppend,
boundNativeChatWindow,
createNativeChatMerger,
mergeNativeChatMessages,
replaceList
} from './mobile-native-chat-merge'
function msg(id: string, overrides: Partial<NativeChatMessage> = {}): NativeChatMessage {
return {
id,
role: 'assistant',
blocks: [{ type: 'text', text: id }],
timestamp: 0,
source: 'transcript',
...overrides
}
}
// ORACLE: the stateful merger (cached index, applyAppend) must deep-equal the
// pure mergeNativeChatMessages for every prefix of an adversarial subscribe
// sequence — the cache must never diverge from a full rebuild (#18).
describe('mobile stateful merger — oracle vs pure merge', () => {
const base = [msg('a'), msg('b', { source: 'hook' })]
const batches: NativeChatMessage[][] = [
[msg('c')], // pure tail
[], // empty frame
[msg('b', { source: 'transcript', blocks: [{ type: 'text', text: 'final' }] })], // supersede
[msg('b', { source: 'scrape' })], // lower priority — ignored
[msg('a')], // re-emit, no change
[msg('d'), msg('e')] // multi tail
]
it('matches the pure merge for every prefix', () => {
const merger = createNativeChatMerger()
replaceList(merger, base)
let pure = mergeNativeChatMessages(base, [])
expect(merger.list).toEqual(pure)
for (const batch of batches) {
const out = applyAppend(merger, batch)
pure = mergeNativeChatMessages(pure, batch)
expect(out).toEqual(pure)
}
})
it('keeps prior row identity on a pure tail append', () => {
const merger = createNativeChatMerger()
replaceList(merger, base)
const before = merger.list
const tail = msg('z')
const after = applyAppend(merger, [tail])
expect(after).not.toBe(before)
expect(after[0]).toBe(before[0])
expect(after[after.length - 1]).toBe(tail)
})
it('bounds the window to the most-recent tail', () => {
const long = Array.from({ length: 10 }, (_, i) => msg(`m${i}`))
expect(boundNativeChatWindow(long, 3).map((m) => m.id)).toEqual(['m7', 'm8', 'm9'])
expect(boundNativeChatWindow(long, 0)).toBe(long)
expect(boundNativeChatWindow(long, 20)).toBe(long)
})
it('bounds live appends and rebuilds the cached indexes for the retained tail', () => {
const merger = createNativeChatMerger()
replaceList(merger, [msg('a'), msg('b'), msg('c')])
expect(applyAppend(merger, [msg('d'), msg('e')], 3).map((m) => m.id)).toEqual(['c', 'd', 'e'])
expect(merger.indexById.has('a')).toBe(false)
expect(merger.indexById.get('c')).toBe(0)
expect(
applyAppend(merger, [msg('d', { blocks: [{ type: 'text', text: 'updated' }] })], 3)[1]
).toMatchObject({ id: 'd', blocks: [{ type: 'text', text: 'updated' }] })
})
})
@@ -0,0 +1,168 @@
import { StyleSheet } from 'react-native'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
export const TEXT_SIZE = 17
export const MONO_SIZE = 12
export const MAX_TOOL_RESULT_CHARS = 4000
export const styles = StyleSheet.create({
row: {
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm
},
rowUser: {
alignItems: 'flex-end'
},
content: {
maxWidth: '100%',
gap: spacing.sm
},
userBubble: {
maxWidth: '88%',
backgroundColor: colors.textPrimary,
borderRadius: radii.card,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm
},
userText: {
color: colors.bgBase,
fontSize: TEXT_SIZE,
lineHeight: TEXT_SIZE + 6,
fontWeight: '500'
},
controls: {
flexDirection: 'row',
justifyContent: 'flex-end',
gap: spacing.xs,
marginBottom: 2,
opacity: 0.7
},
controlButton: {
padding: 3
},
controlPressed: {
opacity: 0.5
},
copied: {
backgroundColor: colors.diffAddedBg,
borderRadius: radii.card
},
reasoning: {
opacity: 0.7
},
queued: {
opacity: 0.55
},
queuedTag: {
color: colors.textMuted,
fontSize: 11,
fontWeight: '600',
marginBottom: 2
},
toolRun: {
marginTop: spacing.xs
},
toolRunHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm
},
toolRunToggle: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingVertical: 3
},
controlsRow: {
flexDirection: 'row',
justifyContent: 'flex-end'
},
toolRunCount: {
color: colors.statusGreen,
fontFamily: typography.monoFamily,
fontSize: MONO_SIZE,
fontWeight: '700'
},
toolRunLabel: {
flex: 1,
color: colors.textMuted,
fontFamily: typography.monoFamily,
fontSize: MONO_SIZE
},
toolRunBody: {
paddingLeft: spacing.sm,
borderLeftWidth: 2,
borderLeftColor: colors.borderSubtle,
marginTop: spacing.xs
},
toolLine: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingVertical: 3
},
toolName: {
color: colors.textPrimary,
fontFamily: typography.monoFamily,
fontSize: MONO_SIZE + 1,
fontWeight: '600'
},
toolPreview: {
flex: 1,
color: colors.textMuted,
fontFamily: typography.monoFamily,
fontSize: MONO_SIZE
},
toolPreviewLink: {
color: colors.accentBlue,
textDecorationLine: 'underline'
},
toolDetail: {
paddingLeft: spacing.lg,
paddingBottom: spacing.xs,
gap: spacing.xs
},
mono: {
color: colors.textSecondary,
fontFamily: typography.monoFamily,
fontSize: MONO_SIZE,
lineHeight: MONO_SIZE + 5
},
toolResult: {
borderRadius: radii.button,
backgroundColor: colors.bgPanel,
padding: spacing.md
},
toolResultError: {
backgroundColor: colors.diffDeletedBg
},
imageRef: {
color: colors.textSecondary,
fontSize: TEXT_SIZE
},
diff: {
borderRadius: radii.button,
backgroundColor: colors.bgPanel,
paddingVertical: spacing.xs,
overflow: 'hidden'
},
diffLine: {
color: colors.textSecondary,
fontFamily: typography.monoFamily,
fontSize: MONO_SIZE,
lineHeight: MONO_SIZE + 5,
paddingHorizontal: spacing.sm
},
diffAdd: {
color: colors.gitDecorationAdded,
backgroundColor: colors.diffAddedBg
},
diffDel: {
color: colors.gitDecorationDeleted,
backgroundColor: colors.diffDeletedBg
},
diffMeta: {
color: colors.textMuted
}
})
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest'
import type { NativeChatBlock } from '../../../src/shared/native-chat-types'
import {
clampFontScale,
FONT_SCALE_MAX,
FONT_SCALE_MIN,
nativeChatMessageText
} from './mobile-native-chat-message-text'
describe('nativeChatMessageText', () => {
it('joins text blocks and skips non-text blocks', () => {
const blocks: NativeChatBlock[] = [
{ type: 'text', text: 'Hello' },
{ type: 'tool-call', name: 'Read', input: {} },
{ type: 'text', text: 'World' }
]
expect(nativeChatMessageText(blocks)).toBe('Hello\n\nWorld')
})
it('returns an empty string when there is no prose', () => {
const blocks: NativeChatBlock[] = [{ type: 'tool-call', name: 'Read', input: {} }]
expect(nativeChatMessageText(blocks)).toBe('')
})
it('trims surrounding whitespace', () => {
expect(nativeChatMessageText([{ type: 'text', text: ' hi ' }])).toBe('hi')
})
})
describe('clampFontScale', () => {
it('clamps below the minimum', () => {
expect(clampFontScale(0.1)).toBe(FONT_SCALE_MIN)
})
it('clamps above the maximum', () => {
expect(clampFontScale(5)).toBe(FONT_SCALE_MAX)
})
it('passes through an in-range value', () => {
expect(clampFontScale(1.2)).toBe(1.2)
})
it('falls back to 1 for NaN', () => {
expect(clampFontScale(Number.NaN)).toBe(1)
})
})
@@ -0,0 +1,24 @@
import type { NativeChatBlock } from '../../../src/shared/native-chat-types'
import { isTextBlock } from './mobile-native-chat-blocks'
/** Concatenate a message's text blocks into a single copyable string. Tool
* calls/results and image refs are skipped — Copy is for the agent's prose. */
export function nativeChatMessageText(blocks: readonly NativeChatBlock[]): string {
return blocks
.filter(isTextBlock)
.map((b) => b.text)
.join('\n\n')
.trim()
}
/** Pinch-to-zoom font bounds. Default 1 means no visible change until pinched. */
export const FONT_SCALE_MIN = 0.8
export const FONT_SCALE_MAX = 1.8
/** Clamp a proposed font scale into the supported range. */
export function clampFontScale(scale: number): number {
if (Number.isNaN(scale)) {
return 1
}
return Math.min(FONT_SCALE_MAX, Math.max(FONT_SCALE_MIN, scale))
}
@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import { isNoiseMessage, stripNoiseMessages } from './mobile-native-chat-noise'
function msg(
role: NativeChatMessage['role'],
text: string,
blocks?: NativeChatMessage['blocks']
): NativeChatMessage {
return {
id: text.slice(0, 8),
role,
blocks: blocks ?? [{ type: 'text', text }],
timestamp: 0,
source: 'transcript'
}
}
describe('isNoiseMessage', () => {
it('flags task-notification and system-reminder user turns', () => {
expect(isNoiseMessage(msg('user', '<task-notification>\n<task-id>x</task-id>'))).toBe(true)
expect(isNoiseMessage(msg('user', '<system-reminder>\nbe careful'))).toBe(true)
expect(
isNoiseMessage(
msg(
'user',
'Caveat: The messages below were generated by the user while running local commands'
)
)
).toBe(true)
expect(isNoiseMessage(msg('user', '[Request interrupted by user]'))).toBe(true)
})
it('flags inter-agent harness turns (teammate/agent messages)', () => {
expect(isNoiseMessage(msg('user', '<teammate-message>ping</teammate-message>'))).toBe(true)
expect(isNoiseMessage(msg('user', '<agent-message>status update'))).toBe(true)
})
it('keeps real user messages', () => {
expect(isNoiseMessage(msg('user', 'make it work for codex'))).toBe(false)
// A genuine custom tag is not a known harness tag, so it stays a user turn.
expect(isNoiseMessage(msg('user', '<bash-brothers> is my band'))).toBe(false)
})
it('keeps assistant and tool turns', () => {
expect(isNoiseMessage(msg('assistant', '<system-reminder> in prose'))).toBe(false)
})
it('keeps a user turn that carries tool results', () => {
expect(isNoiseMessage(msg('user', '', [{ type: 'tool-result', output: 'ok' }]))).toBe(false)
})
it('stripNoiseMessages removes only the noise', () => {
const out = stripNoiseMessages([
msg('user', 'hello'),
msg('user', '<task-notification>done'),
msg('assistant', 'hi')
])
expect(out.map((m) => m.role)).toEqual(['user', 'assistant'])
})
})
@@ -0,0 +1 @@
export { isNoiseMessage, stripNoiseMessages } from '../../../src/shared/native-chat-noise'
@@ -0,0 +1,93 @@
import { describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import {
openMobileNativeChatFile,
resolveMobileNativeChatWorktreePath
} 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'
})
})
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' }
}
})
.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'
})
})
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
})
).resolves.toBeNull()
})
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'))
await expect(
openMobileNativeChatFile({
client: { sendRequest } as unknown as RpcClient,
worktreeId: 'worktree',
pathText: 'src/app.ts',
terminal: null
})
).resolves.toBeUndefined()
})
})
@@ -0,0 +1,51 @@
import type { RuntimeTerminalPathResolution } from '../../../src/shared/runtime-types'
import type { RpcClient } from '../transport/rpc-client'
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 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.
}
}
}
@@ -0,0 +1,27 @@
import { describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import { sendMobileNativeChatPermissionResponse } from './mobile-native-chat-permission-send'
describe('sendMobileNativeChatPermissionResponse', () => {
it('writes an approval as raw bytes without appending Return', async () => {
const sendRequest = vi.fn().mockResolvedValue({
ok: true,
result: { send: { handle: 'terminal', accepted: true, bytesWritten: 1 } }
})
await expect(
sendMobileNativeChatPermissionResponse({
client: { sendRequest } as unknown as RpcClient,
terminal: 'terminal',
deviceToken: 'phone',
text: '1'
})
).resolves.toBe(true)
expect(sendRequest).toHaveBeenCalledWith('terminal.send', {
terminal: 'terminal',
text: '1',
enter: false,
client: { id: 'phone', type: 'mobile' }
})
})
})
@@ -0,0 +1,49 @@
import { useCallback, type MutableRefObject } from 'react'
import type { RpcClient } from '../transport/rpc-client'
import { sendMobileNativeChatMessage } from './mobile-native-chat-send'
export function sendMobileNativeChatPermissionResponse(args: {
client: RpcClient
terminal: string
deviceToken: string | null
text: string
}): Promise<boolean> {
// Why: approval choices are already complete terminal control sequences;
// appending Return changes both numbered choices and Escape denial.
return sendMobileNativeChatMessage({
client: args.client,
terminal: args.terminal,
text: args.text,
enter: false,
...(args.deviceToken ? { mobileClient: { id: args.deviceToken, type: 'mobile' as const } } : {})
})
}
export function useMobileNativeChatPermissionSend(args: {
client: RpcClient | null
enabled: boolean
handleRef: MutableRefObject<string | null>
deviceTokenRef: MutableRefObject<string | null>
onSendError: (message: string) => void
}): (text: string) => Promise<boolean> {
return useCallback(
async (text: string): Promise<boolean> => {
const terminal = args.handleRef.current
if (!args.client || !terminal || !args.enabled) {
args.onSendError('Response not sent (disconnected)')
return false
}
const accepted = await sendMobileNativeChatPermissionResponse({
client: args.client,
terminal,
deviceToken: args.deviceTokenRef.current,
text
})
if (!accepted) {
args.onSendError('Response not sent')
}
return accepted
},
[args.client, args.deviceTokenRef, args.enabled, args.handleRef, args.onSendError]
)
}
@@ -0,0 +1,144 @@
import { describe, expect, it } from 'vitest'
import { detectAgentPermission, parseApprovalFromStatus } from './mobile-native-chat-permission'
describe('detectAgentPermission', () => {
it('returns null for a working agent even with permission-like text', () => {
expect(
detectAgentPermission({
state: 'working',
lastAssistantMessage: 'Do you want to proceed? (y/n)'
})
).toBeNull()
})
it('returns null when state is undefined', () => {
expect(
detectAgentPermission({ lastAssistantMessage: 'Do you want to proceed? (y/n)' })
).toBeNull()
})
it('returns null for ordinary prose while blocked', () => {
expect(
detectAgentPermission({
state: 'blocked',
lastAssistantMessage: 'I finished editing the file and ran the tests.'
})
).toBeNull()
})
it('returns null when the message is empty', () => {
expect(detectAgentPermission({ state: 'waiting', lastAssistantMessage: ' ' })).toBeNull()
})
it('detects a (y/n) prompt and offers Allow/Deny with y/n send values', () => {
const result = detectAgentPermission({
state: 'waiting',
lastAssistantMessage: 'Allow running `rm -rf build`? (y/n)'
})
expect(result).not.toBeNull()
expect(result?.title).toBe('Permission requested')
expect(result?.options).toEqual([
{ label: 'Allow', send: 'y' },
{ label: 'Deny', send: 'n' }
])
expect(result?.detail).toContain('Allow running')
})
it('detects a numbered allow/deny menu and sends the digit', () => {
const result = detectAgentPermission({
state: 'blocked',
lastAssistantMessage:
'Claude wants to edit App.tsx. Do you want to proceed?\n' +
'1. Yes\n' +
'2. No, and tell Claude what to do differently'
})
expect(result).not.toBeNull()
expect(result?.options).toHaveLength(2)
expect(result?.options[0]).toEqual({ label: 'Yes', send: '1' })
expect(result?.options[1]?.send).toBe('2')
expect(result?.options[1]?.label).toContain('No')
})
it('handles parenthesized numbered options "1) Yes 2) No"', () => {
const result = detectAgentPermission({
state: 'waiting',
lastAssistantMessage: 'Approve this command?\n1) Yes\n2) No'
})
expect(result?.options.map((o) => o.send)).toEqual(['1', '2'])
})
it('supports a three-option numbered menu (Yes / Yes always / No)', () => {
const result = detectAgentPermission({
state: 'blocked',
lastAssistantMessage:
"Allow this tool call?\n1. Yes\n2. Yes, and don't ask again this session\n3. No"
})
expect(result?.options).toHaveLength(3)
expect(result?.options.map((o) => o.send)).toEqual(['1', '2', '3'])
})
it('adds an "Allow always" option when the text offers a persistent grant', () => {
const result = detectAgentPermission({
state: 'waiting',
lastAssistantMessage:
'Do you want to allow this? You can allow always for this session. (y/n)'
})
expect(result?.options.map((o) => o.label)).toEqual(['Allow', 'Allow always', 'Deny'])
expect(result?.options.map((o) => o.send)).toEqual(['y', 'a', 'n'])
})
it('detects keyword-only permission asks without explicit y/n tokens', () => {
const result = detectAgentPermission({
state: 'blocked',
lastAssistantMessage: 'I need your permission to run this Bash command.'
})
expect(result).not.toBeNull()
expect(result?.options.map((o) => o.send)).toEqual(['y', 'n'])
})
it('detects "approve" / "deny" phrasing', () => {
const result = detectAgentPermission({
state: 'waiting',
lastAssistantMessage: 'Please approve or deny this write to /etc/hosts.'
})
expect(result).not.toBeNull()
expect(result?.options).toHaveLength(2)
})
it('truncates a very long detail line', () => {
const long = `Do you want to proceed? ${'x'.repeat(300)}`
const result = detectAgentPermission({ state: 'waiting', lastAssistantMessage: long })
expect(result).not.toBeNull()
expect((result?.detail ?? '').length).toBeLessThanOrEqual(160)
})
it('shortens long numbered option labels', () => {
const result = detectAgentPermission({
state: 'blocked',
lastAssistantMessage:
'Proceed?\n1. Yes\n2. No, and explain in great detail exactly why this particular approach is wrong'
})
const second = result?.options[1]
expect(second?.send).toBe('2')
expect((second?.label ?? '').length).toBeLessThanOrEqual(40)
})
})
describe('parseApprovalFromStatus', () => {
it('parses an approval envelope into an Allow/Deny card', () => {
const card = parseApprovalFromStatus(
JSON.stringify({ approval: { tool: 'Bash', summary: 'rm -rf build' } })
)
expect(card?.title).toBe('Allow Bash?')
expect(card?.detail).toBe('rm -rf build')
expect(card?.options.map((o) => o.label)).toEqual(['Allow', 'Deny'])
expect(card?.options[0]!.send).toBe('1')
})
it('returns null for non-approval / malformed input', () => {
expect(parseApprovalFromStatus(undefined)).toBeNull()
expect(parseApprovalFromStatus('{bad')).toBeNull()
expect(parseApprovalFromStatus(JSON.stringify({ questions: [] }))).toBeNull()
expect(parseApprovalFromStatus(JSON.stringify({ approval: {} }))).toBeNull()
})
})
@@ -0,0 +1,179 @@
// Agent permission asks (e.g. Claude/Codex "Do you want to proceed?") surface
// as plain TUI text in the agent's last assistant message — there is no
// structured permission event on mobile. We detect them heuristically so the
// native chat can render tappable Allow/Deny buttons instead of forcing the
// user to type into the composer. Be conservative: only fire when the agent is
// actually paused (blocked/waiting) AND the text reads like an approval ask.
/** A detected permission prompt, rendered as a card with tappable options.
* Each option's `send` is the literal string to write back to the agent
* (e.g. "y", "1") when the user taps it. */
export type MobileChatPermission = {
title: string
detail?: string
options: Array<{ label: string; send: string }>
}
const ESCAPE = String.fromCharCode(27)
/** Parse the live `agentStatus.interactivePrompt` approval envelope
* (`{ approval: { tool, summary } }`, emitted by the host on a PermissionRequest)
* into an Allow/Deny card. This is the reliable, agent-emitted signal — unlike
* detectAgentPermission it doesn't depend on heuristic text parsing. The default
* sends (number for allow, Escape for deny) match the common TUI approval prompt;
* detectAgentPermission still takes precedence when it can read the real numbered
* options from the prompt text. */
export function parseApprovalFromStatus(
interactivePrompt: string | undefined | null
): MobileChatPermission | null {
if (!interactivePrompt) {
return null
}
let parsed: unknown
try {
parsed = JSON.parse(interactivePrompt)
} catch {
return null
}
if (!parsed || typeof parsed !== 'object') {
return null
}
const approval = (parsed as { approval?: unknown }).approval
if (!approval || typeof approval !== 'object') {
return null
}
const tool = (approval as { tool?: unknown }).tool
if (typeof tool !== 'string' || tool.length === 0) {
return null
}
const summary = (approval as { summary?: unknown }).summary
return {
title: `Allow ${tool}?`,
detail: typeof summary === 'string' && summary.length > 0 ? summary : undefined,
options: [
{ label: 'Allow', send: '1' },
{ label: 'Deny', send: ESCAPE }
]
}
}
type PermissionInput = {
state?: string
lastAssistantMessage?: string
toolName?: string
toolInput?: unknown
}
// States where the agent is paused waiting on the human. Only these can yield a
// permission prompt — a "working" agent is mid-turn and must not be answered.
const PAUSED_STATES: ReadonlySet<string> = new Set(['blocked', 'waiting'])
// Phrases that read as an approval request. Kept broad but anchored to
// approval/permission language so ordinary prose doesn't trip detection.
const PERMISSION_PATTERNS: RegExp[] = [
/\bpermission\b/i,
/\bapprove\b/i,
/\bapproval\b/i,
/\ballow\b/i,
/\bdeny\b/i,
/\bgrant\b/i,
/\bauthorize\b/i,
/\bdo you want to\b/i,
/\bwould you like to\b/i,
/\bproceed\?/i,
/\bconfirm\b/i,
/\(y\/n\)/i,
/\by\/n\b/i,
/\byes\/no\b/i,
/\bplease confirm\b/i
]
function looksLikePermissionAsk(text: string): boolean {
return PERMISSION_PATTERNS.some((re) => re.test(text))
}
// A numbered-choice prompt like "1. Yes 2. No" / "2) No, and tell Claude…".
// Captures the option number and its label so we can send the literal digit.
const NUMBERED_OPTION_RE = /(?:^|\n)\s*(\d+)[.)]\s*([^\n]+)/g
type NumberedOption = { num: string; text: string }
function parseNumberedOptions(text: string): NumberedOption[] {
const out: NumberedOption[] = []
for (const match of text.matchAll(NUMBERED_OPTION_RE)) {
const num = match[1]
const body = match[2]?.trim()
if (num && body) {
out.push({ num, text: body })
}
}
return out
}
// Whether a label reads as "allow for every future call" rather than just once.
function isAlwaysLabel(text: string): boolean {
return /\balways\b|don't ask again|do not ask again|for the rest|this session/i.test(text)
}
function shortLabel(text: string, max = 40): string {
const trimmed = text.replace(/\s+/g, ' ').trim()
return trimmed.length > max ? `${trimmed.slice(0, max - 1)}…` : trimmed
}
function buildNumberedPermission(
options: NumberedOption[],
detail: string | undefined
): MobileChatPermission {
return {
title: 'Permission requested',
detail,
options: options.map((opt) => ({ label: shortLabel(opt.text), send: opt.num }))
}
}
function firstLine(text: string): string {
return text.split('\n')[0]?.trim() ?? ''
}
/**
* Heuristically detect an agent permission ask from its paused-state context.
* Returns a renderable prompt, or null when the agent is working or the text
* doesn't read like an approval request.
*/
export function detectAgentPermission(input: PermissionInput): MobileChatPermission | null {
// Only answer a genuinely paused agent. A working agent is mid-turn.
if (!input.state || !PAUSED_STATES.has(input.state)) {
return null
}
const text = typeof input.lastAssistantMessage === 'string' ? input.lastAssistantMessage : ''
if (!text.trim()) {
return null
}
if (!looksLikePermissionAsk(text)) {
return null
}
const detail = shortLabel(firstLine(text), 160) || undefined
// Prefer an explicit numbered menu ("1. Yes 2. No, and tell…") — its labels
// and send-digits come straight from the agent, so no guessing.
const numbered = parseNumberedOptions(text)
if (numbered.length >= 2) {
return buildNumberedPermission(numbered, detail)
}
// Otherwise fall back to a y/n prompt. We surface "Allow always" only when the
// text actually offers a persistent option, to avoid sending a token the agent
// doesn't understand.
const options: MobileChatPermission['options'] = [
{ label: 'Allow', send: 'y' },
{ label: 'Deny', send: 'n' }
]
if (isAlwaysLabel(text)) {
options.splice(1, 0, { label: 'Allow always', send: 'a' })
}
return { title: 'Permission requested', detail, options }
}
@@ -0,0 +1,158 @@
import { describe, expect, it } from 'vitest'
import {
formatQuestionAnswer,
mobileChatQuestionKey,
parseAgentQuestion,
type MobileChatQuestion
} from './mobile-native-chat-question'
describe('parseAgentQuestion', () => {
it('parses a numbered list with a question line', () => {
const text = ['Which database should I use?', '1. PostgreSQL', '2. MySQL', '3. SQLite'].join(
'\n'
)
const q = parseAgentQuestion(text)
expect(q).not.toBeNull()
expect(q?.question).toBe('Which database should I use?')
expect(q?.options).toEqual(['PostgreSQL', 'MySQL', 'SQLite'])
expect(q?.optionTokens).toEqual(['1', '2', '3'])
expect(q?.multiSelect).toBe(false)
})
it('parses numbered lists using ) markers', () => {
const q = parseAgentQuestion('Pick:\n1) Yes\n2) No')
expect(q?.options).toEqual(['Yes', 'No'])
expect(q?.optionTokens).toEqual(['1', '2'])
})
it('parses a bulleted list', () => {
const text = ['How should we proceed?', '- Rebase', '- Merge', '* Squash'].join('\n')
const q = parseAgentQuestion(text)
expect(q?.options).toEqual(['Rebase', 'Merge', 'Squash'])
expect(q?.optionTokens).toEqual([null, null, null])
expect(q?.question).toBe('How should we proceed?')
})
it('parses lettered options in brackets and parens', () => {
const q = parseAgentQuestion('Choose one:\n[a] Alpha\n[b] Beta')
expect(q?.options).toEqual(['Alpha', 'Beta'])
expect(q?.optionTokens).toEqual(['a', 'b'])
const q2 = parseAgentQuestion('Choose one:\na) Alpha\nb) Beta')
expect(q2?.options).toEqual(['Alpha', 'Beta'])
expect(q2?.optionTokens).toEqual(['a', 'b'])
})
it('strips a leading pointer/highlight glyph from the selected row', () => {
const text = ['Select a target:', '❯ 1. main', ' 2. develop'].join('\n')
const q = parseAgentQuestion(text)
expect(q?.options).toEqual(['main', 'develop'])
expect(q?.optionTokens).toEqual(['1', '2'])
})
it('drops a trailing colon but keeps a question mark in the title', () => {
expect(parseAgentQuestion('Options:\n1. A\n2. B')?.question).toBe('Options')
expect(parseAgentQuestion('Ready?\n1. A\n2. B')?.question).toBe('Ready?')
})
it('returns null for ordinary prose with no option list', () => {
expect(parseAgentQuestion('I finished the refactor and ran the tests.')).toBeNull()
expect(parseAgentQuestion('')).toBeNull()
expect(parseAgentQuestion(' ')).toBeNull()
})
it('returns null for a single bare option with no introducing prompt', () => {
// A lone "- item" in prose must not be treated as a question.
expect(parseAgentQuestion('Here is what I did:\nsome work\n- one stray bullet')).toBeNull()
})
it('accepts a single option when introduced by a prompt line', () => {
const q = parseAgentQuestion('Apply this change?\n1. Yes, apply it')
expect(q).not.toBeNull()
expect(q?.options).toEqual(['Yes, apply it'])
})
it('uses a fallback title when no introducing line exists', () => {
const q = parseAgentQuestion('1. Alpha\n2. Beta')
expect(q?.question).toBe('Choose an option')
expect(q?.options).toEqual(['Alpha', 'Beta'])
})
it('detects multi-select hints', () => {
expect(parseAgentQuestion('Select all that apply:\n1. A\n2. B')?.multiSelect).toBe(true)
expect(parseAgentQuestion('Choose multiple:\n- A\n- B')?.multiSelect).toBe(true)
expect(parseAgentQuestion('Pick one:\n1. A\n2. B')?.multiSelect).toBe(false)
})
it('does not flag multi-select when only one option is present', () => {
const q = parseAgentQuestion('Select all that apply?\n1. Only one')
expect(q?.multiSelect).toBe(false)
})
it('finds the question line nearest the options, skipping blanks', () => {
const text = ['Some preamble.', '', 'Which branch?', '', '1. main', '2. dev'].join('\n')
const q = parseAgentQuestion(text)
expect(q?.question).toBe('Which branch?')
})
})
describe('formatQuestionAnswer', () => {
const numbered: MobileChatQuestion = {
question: 'Pick',
options: ['Alpha', 'Beta', 'Gamma'],
multiSelect: false,
optionTokens: ['1', '2', '3']
}
it('echoes the leading token for single-select', () => {
expect(formatQuestionAnswer(numbered, ['Beta'])).toBe('2')
})
it('comma-joins tokens for multi-select', () => {
const multi: MobileChatQuestion = { ...numbered, multiSelect: true }
expect(formatQuestionAnswer(multi, ['Alpha', 'Gamma'])).toBe('1, 3')
})
it('sends the label text when the option had no token (bullet list)', () => {
const bullets: MobileChatQuestion = {
question: 'Pick',
options: ['Rebase', 'Merge'],
multiSelect: false,
optionTokens: [null, null]
}
expect(formatQuestionAnswer(bullets, ['Merge'])).toBe('Merge')
})
it('echoes letter tokens', () => {
const lettered: MobileChatQuestion = {
question: 'Pick',
options: ['Alpha', 'Beta'],
multiSelect: false,
optionTokens: ['a', 'b']
}
expect(formatQuestionAnswer(lettered, ['Beta'])).toBe('b')
})
it('passes free-text / unknown entries through verbatim', () => {
expect(formatQuestionAnswer(numbered, ['something custom'])).toBe('something custom')
})
it('returns empty string when nothing is selected', () => {
expect(formatQuestionAnswer(numbered, [])).toBe('')
expect(formatQuestionAnswer(numbered, [' '])).toBe('')
})
})
describe('mobileChatQuestionKey', () => {
it('changes when a replacement prompt changes any positional content', () => {
const first: MobileChatQuestion = {
question: 'Pick',
options: ['A', 'B'],
multiSelect: true,
optionTokens: ['1', '2']
}
expect(mobileChatQuestionKey({ ...first, options: ['A', 'C'] })).not.toBe(
mobileChatQuestionKey(first)
)
})
})
@@ -0,0 +1,154 @@
// Heuristic detection of an agent's "pick an option" prompt from its status /
// assistant text. Agents (Claude et al.) render these as a TUI choice list; we
// have no structured signal, so we parse the text conservatively and only treat
// it as a question when a clear option list is present.
export type MobileChatQuestion = {
question: string
options: string[]
multiSelect: boolean
/** Per-option leading marker ("1", "b", …) when the source line carried one,
* parallel to `options`. Null where the option was a plain bullet. Used to
* echo the exact choice the agent listed back to the terminal. */
optionTokens: (string | null)[]
}
export function mobileChatQuestionKey(question: MobileChatQuestion): string {
return JSON.stringify(question)
}
type ParsedOption = {
label: string
token: string | null
}
// A pointer/highlight glyph some TUIs prefix onto the *currently selected* row.
// Stripped first so "❯ 2. Foo" parses identically to "2. Foo".
const POINTER_PREFIX = /^(\s*)(?:❯|›|»)\s+/
// Ordered most-specific-first so a numbered/lettered marker wins over the
// bullet fallback. `token` is the capture index of the leading marker (0 = none),
// `label` the capture index of the choice text.
const OPTION_PATTERNS: { re: RegExp; token: number; label: number }[] = [
// 1. Option 12) Option
{ re: /^\s*(\d{1,2})[.)]\s+(\S.*?)\s*$/, token: 1, label: 2 },
// [a] Option [1] Option
{ re: /^\s*\[([0-9a-zA-Z])\]\s+(\S.*?)\s*$/, token: 1, label: 2 },
// a) Option a. Option (single letter, to avoid eating prose like "e.g.")
{ re: /^\s*([a-zA-Z])[.)]\s+(\S.*?)\s*$/, token: 1, label: 2 },
// - Option * Option • Option > Option (no token)
{ re: /^\s*(?:[-*•>])\s+(\S.*?)\s*$/, token: 0, label: 1 }
]
function parseOptionLine(line: string): ParsedOption | null {
const stripped = line.replace(POINTER_PREFIX, '$1')
for (const { re, token, label } of OPTION_PATTERNS) {
const m = stripped.match(re)
if (!m) {
continue
}
const text = m[label].trim()
if (text.length === 0) {
continue
}
return { label: text, token: token > 0 ? m[token] : null }
}
return null
}
const MULTI_SELECT_HINT =
/\b(select all|choose all|choose multiple|select multiple|pick multiple|all that apply|one or more|comma[- ]separated|multiple options)\b/i
// Question-like introducing line: ends in ? or :.
const QUESTION_LINE = /[?:]\s*$/
// Drop a trailing ":" off a card title but keep a meaningful "?".
function cleanQuestionText(raw: string): string {
const trimmed = raw.trim()
return trimmed.endsWith(':') ? trimmed.slice(0, -1).trim() : trimmed
}
/**
* Heuristically parse a question + its option list from agent text. Returns null
* when no clear option list is present (so ordinary prose is never treated as a
* question). Conservative on purpose: requires at least two option lines, or one
* option line introduced by a question-like prompt line.
*/
export function parseAgentQuestion(text: string): MobileChatQuestion | null {
if (typeof text !== 'string' || text.trim().length === 0) {
return null
}
const lines = text.replace(/\r\n/g, '\n').split('\n')
const parsed: { index: number; option: ParsedOption }[] = []
lines.forEach((line, index) => {
const option = parseOptionLine(line)
if (option) {
parsed.push({ index, option })
}
})
if (parsed.length === 0) {
return null
}
const firstOptionIndex = parsed[0].index
const options = parsed.map((p) => p.option.label)
const optionTokens = parsed.map((p) => p.option.token)
// Find the introducing question: nearest non-empty, non-option line above the
// first option.
let question = ''
let questionLooksLikePrompt = false
for (let i = firstOptionIndex - 1; i >= 0; i--) {
if (lines[i].trim().length === 0 || parseOptionLine(lines[i])) {
continue
}
question = lines[i]
questionLooksLikePrompt = QUESTION_LINE.test(lines[i])
break
}
// Conservative gate: a single bare option with no introducing prompt is more
// likely stray prose (a lone "- item") than a real choice list.
if (options.length < 2 && !questionLooksLikePrompt) {
return null
}
const multiSelect = MULTI_SELECT_HINT.test(text) && options.length > 1
return {
question: question.length > 0 ? cleanQuestionText(question) : 'Choose an option',
options,
multiSelect,
optionTokens
}
}
/**
* Build the text to send to the agent terminal for the selected option(s).
* Convention: echo the option's leading marker (number/letter) when the list had
* one; otherwise send the option label text (TUIs accept the literal choice).
* Multi-select answers are comma-joined; single-select is sent as-is. Unknown
* entries (free-text escape hatch) pass through verbatim. Returns '' when
* nothing is selected.
*/
export function formatQuestionAnswer(question: MobileChatQuestion, selected: string[]): string {
const labels = selected.map((s) => s.trim()).filter((s) => s.length > 0)
if (labels.length === 0) {
return ''
}
const parts = labels.map((label) => {
const index = question.options.indexOf(label)
if (index === -1) {
// Free-text / unknown entry: pass the user's text straight through.
return label
}
const token = question.optionTokens[index]
return token != null && token.length > 0 ? token : label
})
return parts.join(question.multiSelect ? ', ' : ' ')
}
@@ -0,0 +1,104 @@
import { describe, expect, it } from 'vitest'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import {
buildMobileNativeChatData,
mobileNativeChatEmptyState
} from './mobile-native-chat-render-data'
function assistant(id: string, text: string): NativeChatMessage {
return {
id,
role: 'assistant',
blocks: [{ type: 'text', text }],
timestamp: 0,
source: 'transcript'
}
}
function user(id: string, text: string): NativeChatMessage {
return { id, role: 'user', blocks: [{ type: 'text', text }], timestamp: 0, source: 'transcript' }
}
describe('mobileNativeChatEmptyState', () => {
it('invites a first message naming the agent, matching desktop copy', () => {
// waiting-session (live agent, no transcript) and ready (loaded, empty) both
// resolve to the shared "empty" copy with the agent label substituted.
const waiting = mobileNativeChatEmptyState('waiting-session', 'claude')
expect(waiting).toEqual({
title: 'Start a chat with Claude',
subtitle: 'Ask Claude to inspect code, explain output, or make a change.'
})
expect(mobileNativeChatEmptyState('ready', 'codex')?.title).toBe('Start a chat with Codex')
})
it('falls back to "the agent" when the agent is unknown', () => {
expect(mobileNativeChatEmptyState('waiting-session', null)?.title).toBe(
'Start a chat with the agent'
)
})
it('prefers the provided error message over the default subtitle', () => {
expect(mobileNativeChatEmptyState('error', 'claude', 'boom')?.subtitle).toBe('boom')
expect(mobileNativeChatEmptyState('error', 'claude')?.subtitle).toBe(
'The transcript could not be read. Toggle back to the terminal to keep working.'
)
})
it('returns null for states that show no empty copy', () => {
expect(mobileNativeChatEmptyState('loading', 'claude')).toBeNull()
expect(mobileNativeChatEmptyState('idle', 'claude')).toBeNull()
})
})
describe('buildMobileNativeChatData', () => {
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 last = data[data.length - 1]
expect(last.id).toBe('p1')
expect(last.role).toBe('user')
expect(last.blocks).toEqual([{ type: 'text', text: 'queued' }])
})
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('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()
})
})
@@ -0,0 +1,115 @@
import { formatAgentTypeLabel } from '../../../src/shared/agent-type-label'
import {
formatNativeChatEmptyStateCopy,
type NativeChatEmptyStateCopy
} from '../../../src/shared/native-chat-empty-state'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import { foldToolMessages } from './mobile-native-chat-blocks'
import { stripNoiseMessages } from './mobile-native-chat-noise'
import type { MobileNativeChatStatus } from './use-mobile-native-chat-session'
/** The centered empty-state copy for a chat with no messages, mirroring the
* desktop `NativeChatEmptyState` (shared copy + agent label) so the two surfaces
* stay in lockstep. Returns null when the list should stay bare (idle, or the
* loading spinner owns the view). */
export function mobileNativeChatEmptyState(
status: MobileNativeChatStatus,
agent: string | null,
error?: string
): NativeChatEmptyStateCopy | null {
const agentLabel = agent ? formatAgentTypeLabel(agent) : 'the agent'
switch (status) {
// A live agent with no transcript yet — and a loaded-but-empty transcript —
// are both "start a chat"; invite the first message instead of implying the
// agent is still starting up.
case 'waiting-session':
case 'ready':
return formatNativeChatEmptyStateCopy('empty', agentLabel)
case 'error': {
const copy = formatNativeChatEmptyStateCopy('error', agentLabel)
return error ? { ...copy, subtitle: error } : copy
}
default:
return null
}
}
/** 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: Array<{ id: string; text: string }>
}): { folded: NativeChatMessage[]; streaming: string | null; data: NativeChatMessage[] } {
const folded = foldMobileNativeChatMessages(messages)
return buildMobileNativeChatTransientData({ folded, streamingText, pending })
}
export function foldMobileNativeChatMessages(messages: NativeChatMessage[]): NativeChatMessage[] {
return foldToolMessages(stripNoiseMessages(messages))
}
export function buildMobileNativeChatTransientData({
folded,
streamingText,
pending
}: {
folded: NativeChatMessage[]
streamingText?: string
pending: Array<{ id: string; text: string }>
}): { 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
? [
{
id: 'streaming',
role: 'assistant' as const,
blocks: [{ type: 'text' as const, text: streaming }],
timestamp: null,
source: 'hook' as const
}
]
: []),
...pending.map((p) => ({
id: p.id,
role: 'user' as const,
blocks: [{ type: 'text' as const, text: p.text }],
timestamp: null,
source: 'transcript' as const
}))
]
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,79 @@
import { describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import { sendMobileNativeChatMessage } from './mobile-native-chat-send'
function clientWithResponse(response: unknown): RpcClient {
return {
sendRequest: vi.fn().mockResolvedValue(response)
} as unknown as RpcClient
}
describe('sendMobileNativeChatMessage', () => {
it('returns true only when the terminal accepts the send', async () => {
const client = clientWithResponse({
id: 'request',
ok: true,
result: { send: { accepted: true } },
_meta: { runtimeId: 'runtime' }
})
await expect(
sendMobileNativeChatMessage({
client,
terminal: 'term',
text: 'hello',
mobileClient: { id: 'device', type: 'mobile' }
})
).resolves.toBe(true)
expect(client.sendRequest).toHaveBeenCalledWith('terminal.send', {
terminal: 'term',
text: 'hello',
enter: true,
client: { id: 'device', type: 'mobile' }
})
})
it('returns false when the terminal rejects the send', async () => {
const client = clientWithResponse({
id: 'request',
ok: true,
result: { send: { accepted: false } },
_meta: { runtimeId: 'runtime' }
})
await expect(
sendMobileNativeChatMessage({ client, terminal: 'term', text: 'hello' })
).resolves.toBe(false)
})
it('returns false when the RPC fails', async () => {
const client = {
sendRequest: vi.fn().mockRejectedValue(new Error('disconnected'))
} as unknown as RpcClient
await expect(
sendMobileNativeChatMessage({ client, terminal: 'term', text: 'hello' })
).resolves.toBe(false)
})
it('sends a single non-submitting Escape for prompt cancellation', async () => {
const client = clientWithResponse({
id: 'request',
ok: true,
result: { send: { accepted: true } },
_meta: { runtimeId: 'runtime' }
})
await sendMobileNativeChatMessage({
client,
terminal: 'term',
text: String.fromCharCode(27),
enter: false
})
expect(client.sendRequest).toHaveBeenCalledWith('terminal.send', {
terminal: 'term',
text: String.fromCharCode(27),
enter: false
})
})
})
@@ -0,0 +1,27 @@
import type { RpcClient } from '../transport/rpc-client'
import { isTerminalSendRpcAccepted } from '../terminal/terminal-send-rpc-response'
type MobileTerminalClient = {
id: string
type: 'mobile'
}
export async function sendMobileNativeChatMessage(args: {
client: RpcClient
terminal: string
text: string
enter?: boolean
mobileClient?: MobileTerminalClient
}): Promise<boolean> {
try {
const response = await args.client.sendRequest('terminal.send', {
terminal: args.terminal,
text: args.text,
enter: args.enter ?? true,
...(args.mobileClient ? { client: args.mobileClient } : {})
})
return isTerminalSendRpcAccepted(response)
} catch {
return false
}
}
@@ -0,0 +1,112 @@
import { describe, expect, it } from 'vitest'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import { createNativeChatMerger, replaceList } from './mobile-native-chat-merge'
import { applyMobileNativeChatStreamFrame } from './mobile-native-chat-stream-frame'
function message(id: string): NativeChatMessage {
return {
id,
role: 'assistant',
blocks: [{ type: 'text', text: id }],
timestamp: 0,
source: 'transcript'
}
}
describe('applyMobileNativeChatStreamFrame', () => {
it('uses the first snapshot as the ordered base and carries pagination state', () => {
const merger = createNativeChatMerger()
const result = applyMobileNativeChatStreamFrame({
merger,
frame: {
type: 'snapshot',
messages: [message('a'), message('b')],
hasMore: true,
beforeOffset: 123
},
limit: 40,
replaceSnapshot: true
})
expect(result).toEqual({
kind: 'messages',
messages: [message('a'), message('b')],
hasMore: true,
beforeOffset: 123
})
})
it('merges reconnect snapshots and live appends into the bounded window', () => {
const merger = createNativeChatMerger()
replaceList(merger, [message('a'), message('b')])
const result = applyMobileNativeChatStreamFrame({
merger,
frame: { type: 'snapshot', messages: [message('b'), message('c'), message('d')] },
limit: 3,
replaceSnapshot: false
})
expect(result).toMatchObject({
kind: 'messages',
messages: [message('b'), message('c'), message('d')],
cursorInvalidated: true
})
})
it('keeps the pagination cursor when an append does not trim history', () => {
const merger = createNativeChatMerger()
replaceList(merger, [message('a')])
expect(
applyMobileNativeChatStreamFrame({
merger,
frame: { type: 'appended', messages: [message('b')] },
limit: 3,
replaceSnapshot: false
})
).toEqual({ kind: 'messages', messages: [message('a'), message('b')] })
})
it('replaces stale history for an explicit transcript replacement frame', () => {
const merger = createNativeChatMerger()
replaceList(merger, [message('old')])
expect(
applyMobileNativeChatStreamFrame({
merger,
frame: { type: 'replacement', messages: [message('new')], hasMore: false },
limit: 40,
replaceSnapshot: false
})
).toEqual({ kind: 'messages', messages: [message('new')], hasMore: false })
})
it('surfaces snapshot errors and ignores unrelated frames', () => {
const merger = createNativeChatMerger()
expect(
applyMobileNativeChatStreamFrame({
merger,
frame: { type: 'snapshot', error: 'Transcript unavailable' },
limit: 40,
replaceSnapshot: true
})
).toEqual({ kind: 'error', error: 'Transcript unavailable' })
expect(
applyMobileNativeChatStreamFrame({
merger,
frame: { type: 'error', message: 'Socket closed' },
limit: 40,
replaceSnapshot: false
})
).toEqual({ kind: 'error', error: 'Socket closed' })
expect(
applyMobileNativeChatStreamFrame({
merger,
frame: { type: 'end' },
limit: 40,
replaceSnapshot: true
})
).toEqual({ kind: 'ignored' })
})
})
@@ -0,0 +1,63 @@
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import { applyAppend, replaceList, type NativeChatMerger } from './mobile-native-chat-merge'
export type MobileNativeChatStreamFrame = {
type?: string
messages?: NativeChatMessage[]
hasMore?: boolean
beforeOffset?: number
error?: string
message?: string
}
export type AppliedMobileNativeChatFrame =
| { kind: 'ignored' }
| { kind: 'error'; error: string }
| {
kind: 'messages'
messages: NativeChatMessage[]
hasMore?: boolean
beforeOffset?: number
cursorInvalidated?: boolean
}
/** Applies runtime stream frames while preserving the initial-snapshot versus
* reconnect-replay distinction owned by the session hook. */
export function applyMobileNativeChatStreamFrame(args: {
merger: NativeChatMerger
frame: MobileNativeChatStreamFrame
limit: number
replaceSnapshot: boolean
}): AppliedMobileNativeChatFrame {
const { merger, frame, limit, replaceSnapshot } = args
if (frame.type === 'error') {
return { kind: 'error', error: frame.message ?? frame.error ?? 'Transcript stream failed' }
}
if (frame.type !== 'snapshot' && frame.type !== 'replacement' && frame.type !== 'appended') {
return { kind: 'ignored' }
}
if (frame.error) {
return { kind: 'error', error: frame.error }
}
if (!Array.isArray(frame.messages)) {
return { kind: 'ignored' }
}
if (frame.type === 'replacement' || (frame.type === 'snapshot' && replaceSnapshot)) {
replaceList(merger, frame.messages)
return {
kind: 'messages',
messages: merger.list,
hasMore: frame.hasMore,
...(frame.beforeOffset == null ? {} : { beforeOffset: frame.beforeOffset })
}
}
const previousFirstId = merger.list[0]?.id
const messages = applyAppend(merger, frame.messages, limit)
return {
kind: 'messages',
messages,
// Why: once the bounded live window drops its oldest row, the snapshot's
// byte cursor no longer describes the oldest retained message.
...(previousFirstId && messages[0]?.id !== previousFirstId ? { cursorInvalidated: true } : {})
}
}
@@ -0,0 +1,88 @@
import { describe, expect, it } from 'vitest'
import {
isTerminalCoveredByNativeChat,
mobileNativeChatTerminalCapabilities,
resolveMobileNativeChatTerminalStreamAction
} from './mobile-native-chat-terminal-stream'
const base = {
showNativeChat: false,
activeHandle: 'pty-1',
activeTabType: 'terminal',
streamActive: true,
streamCovered: false,
webViewReady: true
}
describe('mobile native-chat terminal stream lifecycle', () => {
it('pauses an active terminal stream while chat covers it', () => {
expect(resolveMobileNativeChatTerminalStreamAction({ ...base, showNativeChat: true })).toBe(
'pause'
)
expect(
resolveMobileNativeChatTerminalStreamAction({
...base,
showNativeChat: true,
streamActive: false
})
).toBe('pause')
expect(isTerminalCoveredByNativeChat(true, 'pty-1', 'pty-1')).toBe(true)
expect(mobileNativeChatTerminalCapabilities(true)).toEqual({
terminalBinaryStream: 1,
mobileInputLeaseOnly: 1
})
expect(mobileNativeChatTerminalCapabilities(false)).toEqual({ terminalBinaryStream: 1 })
})
it('records a cold-start cover before WebView readiness so return refreshes', () => {
expect(
resolveMobileNativeChatTerminalStreamAction({
...base,
showNativeChat: true,
streamActive: false,
webViewReady: false
})
).toBe('pause')
expect(
resolveMobileNativeChatTerminalStreamAction({
...base,
streamActive: true,
streamCovered: true
})
).toBe('resume')
})
it('resumes only the ready active terminal after chat closes', () => {
expect(resolveMobileNativeChatTerminalStreamAction({ ...base, streamActive: false })).toBe(
'resume'
)
expect(
resolveMobileNativeChatTerminalStreamAction({
...base,
streamActive: false,
webViewReady: false
})
).toBe('none')
expect(resolveMobileNativeChatTerminalStreamAction({ ...base, streamCovered: true })).toBe(
'resume'
)
})
it('does nothing for non-terminal tabs, missing handles, or settled states', () => {
expect(resolveMobileNativeChatTerminalStreamAction(base)).toBe('none')
expect(
resolveMobileNativeChatTerminalStreamAction({
...base,
showNativeChat: true,
streamActive: false,
streamCovered: true
})
).toBe('none')
expect(resolveMobileNativeChatTerminalStreamAction({ ...base, activeTabType: 'browser' })).toBe(
'none'
)
expect(resolveMobileNativeChatTerminalStreamAction({ ...base, activeHandle: null })).toBe(
'none'
)
})
})
@@ -0,0 +1,37 @@
export type MobileNativeChatTerminalStreamAction = 'pause' | 'resume' | 'none'
/** Decides whether the active mobile terminal stream should run while native chat
* covers its WebView. Resume is allowed only once the mounted WebView is ready. */
export function resolveMobileNativeChatTerminalStreamAction(args: {
showNativeChat: boolean
activeHandle: string | null
activeTabType: string | null
streamActive: boolean
streamCovered: boolean
webViewReady: boolean
}): MobileNativeChatTerminalStreamAction {
if (!args.activeHandle || args.activeTabType !== 'terminal') {
return 'none'
}
if (args.showNativeChat) {
return !args.streamCovered ? 'pause' : 'none'
}
return (args.streamCovered || !args.streamActive) && args.webViewReady ? 'resume' : 'none'
}
export function isTerminalCoveredByNativeChat(
showNativeChat: boolean,
activeHandle: string | null,
handle: string
): boolean {
return showNativeChat && activeHandle === handle
}
export function mobileNativeChatTerminalCapabilities(covered: boolean): {
terminalBinaryStream: 1
mobileInputLeaseOnly?: 1
} {
return covered
? { terminalBinaryStream: 1, mobileInputLeaseOnly: 1 }
: { terminalBinaryStream: 1 }
}
@@ -0,0 +1,37 @@
import { MessageSquare, SquareTerminal } from 'lucide-react-native'
import type { ActionSheetAction } from '../components/ActionSheetModal'
import { resolveMobileNativeChat, type MobileNativeChatTab } from './mobile-native-chat-eligibility'
type ToggleTab = MobileNativeChatTab & {
id: string
terminal: string | null
}
/** Builds the optional terminal/chat switch shown in a terminal's long-press menu. */
export function getMobileNativeChatToggleActions(args: {
terminalHandle: string | null
tabs: readonly ToggleTab[]
chatTabIds: ReadonlySet<string>
nativeChatTranscriptIsLocalReadable: boolean
onClose: () => void
onToggle: (tabId: string) => void
}): ActionSheetAction[] {
const { terminalHandle, tabs, chatTabIds, onClose, onToggle } = args
const tab = terminalHandle
? tabs.find((candidate) => candidate.terminal === terminalHandle)
: null
if (!tab || !resolveMobileNativeChat(tab, args.nativeChatTranscriptIsLocalReadable)) {
return []
}
const isChat = chatTabIds.has(tab.id)
return [
{
label: isChat ? 'Switch to terminal view' : 'Switch to chat view',
icon: isChat ? SquareTerminal : MessageSquare,
onPress: () => {
onClose()
onToggle(tab.id)
}
}
]
}
@@ -0,0 +1,64 @@
import { describe, expect, it, vi } from 'vitest'
import { briefToolArg, summarizeToolInput, toolFilePath } from './mobile-native-chat-tool-summary'
describe('summarizeToolInput', () => {
it('passes short strings through, collapsing whitespace', () => {
expect(summarizeToolInput('ls -la\n/tmp')).toBe('ls -la /tmp')
})
it('serializes objects to compact JSON', () => {
expect(summarizeToolInput({ path: 'a.ts', limit: 5 })).toBe('{"path":"a.ts","limit":5}')
})
it('truncates long previews with an ellipsis', () => {
const long = 'x'.repeat(200)
const out = summarizeToolInput(long)
expect(out.length).toBe(80)
expect(out.endsWith('…')).toBe(true)
})
it('returns empty string for null/undefined', () => {
expect(summarizeToolInput(null)).toBe('')
expect(summarizeToolInput(undefined)).toBe('')
})
it('handles numbers and booleans', () => {
expect(summarizeToolInput(42)).toBe('42')
expect(summarizeToolInput(true)).toBe('true')
})
it('does not traverse an unbounded tool-input object for a short preview', () => {
const input: Record<string, unknown> = Object.fromEntries(
Array.from({ length: 8 }, (_unused, index) => [`key${index}`, index])
)
const lateGetter = vi.fn(() => 'should not be read')
Object.defineProperty(input, 'late', { enumerable: true, get: lateGetter })
expect(summarizeToolInput(input)).not.toBe('')
expect(lateGetter).not.toHaveBeenCalled()
})
})
describe('toolFilePath', () => {
it('extracts file_path / filePath / path / notebook_path', () => {
expect(toolFilePath({ file_path: 'src/a.ts' })).toBe('src/a.ts')
expect(toolFilePath({ filePath: 'src/b.ts' })).toBe('src/b.ts')
expect(toolFilePath({ path: 'src/c.ts' })).toBe('src/c.ts')
expect(toolFilePath({ notebook_path: 'n.ipynb' })).toBe('n.ipynb')
})
it('returns null when there is no file target', () => {
expect(toolFilePath({ command: 'ls' })).toBeNull()
expect(toolFilePath('x')).toBeNull()
expect(toolFilePath(null)).toBeNull()
})
})
describe('briefToolArg', () => {
it('takes the basename of a forward-slash path', () => {
expect(briefToolArg({ path: 'src/session/app.ts' })).toBe('app.ts')
})
it('takes the basename of a Windows backslash path from a raw transcript', () => {
expect(briefToolArg({ file_path: 'C:\\Users\\me\\project\\app.tsx' })).toBe('app.tsx')
})
})
@@ -0,0 +1,7 @@
export {
briefToolArg,
countToolCalls,
summarizeToolInput,
summarizeToolRun,
toolFilePath
} from '../../../src/shared/native-chat-tool-summary'
@@ -0,0 +1,109 @@
import { StyleSheet } from 'react-native'
import { colors, spacing, typography } from '../theme/mobile-theme'
export const styles = StyleSheet.create({
root: {
flex: 1,
backgroundColor: colors.bgBase
},
chromeRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
minHeight: 28,
paddingHorizontal: spacing.md
},
chromeLeft: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm
},
stopButton: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs
},
stopLabel: {
color: colors.statusRed,
fontSize: typography.metaSize,
fontWeight: '700'
},
sendError: {
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingBottom: spacing.xs
},
sendErrorText: {
color: colors.statusRed,
fontSize: typography.metaSize,
fontWeight: '600'
},
chromeToggle: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
paddingVertical: 4,
paddingHorizontal: spacing.xs
},
chromeToggleLabel: {
color: colors.textMuted,
fontSize: typography.metaSize,
fontWeight: '600'
},
pressed: {
opacity: 0.6
},
listWrap: {
flex: 1,
position: 'relative'
},
listContent: {
paddingVertical: spacing.sm,
flexGrow: 1
},
center: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
padding: spacing.xl
},
emptyTitle: {
color: colors.textSecondary,
fontSize: typography.bodySize,
fontWeight: '600',
textAlign: 'center',
marginBottom: spacing.xs
},
emptySubtitle: {
color: colors.textMuted,
fontSize: typography.metaSize,
textAlign: 'center'
},
fab: {
position: 'absolute',
right: spacing.md,
width: 38,
height: 38,
borderRadius: 19,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.bgRaised,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.borderSubtle
},
fabBottom: {
bottom: spacing.md
},
loadEarlier: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: spacing.md,
minHeight: 36
},
loadEarlierText: {
color: colors.textMuted,
fontSize: typography.metaSize,
fontWeight: '600'
}
})
@@ -0,0 +1,69 @@
import { Eraser, Monitor, Smartphone } from 'lucide-react-native'
import type { ActionSheetAction } from '../components/ActionSheetModal'
import type { MobileNativeChatTab } from './mobile-native-chat-eligibility'
import { getMobileNativeChatToggleActions } from './mobile-native-chat-toggle-action'
type TerminalTab = MobileNativeChatTab & { id: string; terminal: string | null }
/** Builds the terminal long-press menu without adding another action block to the
* already dense session route. Native chat stays first as the view switch. */
export function getMobileTerminalActionSheetActions<Target extends { handle: string }>(args: {
target: Target | null
tabs: readonly TerminalTab[]
chatTabIds: ReadonlySet<string>
nativeChatTranscriptIsLocalReadable: boolean
onDismiss: () => void
onToggleChat: (tabId: string) => void
isPhoneMode: (handle: string) => boolean
onToggleDisplayMode: (handle: string) => void
onRename: (target: Target) => void
onClear: (target: Target) => void
onClose: (target: Target) => void
}): ActionSheetAction[] {
const { target } = args
if (!target) {
return []
}
const phoneMode = args.isPhoneMode(target.handle)
return [
...getMobileNativeChatToggleActions({
terminalHandle: target.handle,
tabs: args.tabs,
chatTabIds: args.chatTabIds,
nativeChatTranscriptIsLocalReadable: args.nativeChatTranscriptIsLocalReadable,
onClose: args.onDismiss,
onToggle: args.onToggleChat
}),
{
label: phoneMode ? 'Switch to Desktop' : 'Switch to Phone',
icon: phoneMode ? Monitor : Smartphone,
onPress: () => {
args.onDismiss()
args.onToggleDisplayMode(target.handle)
}
},
{
label: 'Rename',
onPress: () => {
args.onDismiss()
args.onRename(target)
}
},
{
label: 'Clear Terminal',
icon: Eraser,
onPress: () => {
args.onDismiss()
args.onClear(target)
}
},
{
label: 'Close',
destructive: true,
onPress: () => {
args.onDismiss()
args.onClose(target)
}
}
]
}
@@ -0,0 +1,24 @@
import { describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import { subscribeMobileTerminalSafely } from './mobile-terminal-stream-subscribe'
describe('subscribeMobileTerminalSafely', () => {
it('reports a synchronous subscribe failure and returns a safe no-op cleanup', () => {
const onSynchronousError = vi.fn()
const client = {
subscribe: vi.fn(() => {
throw new Error('socket closed')
})
} as unknown as Pick<RpcClient, 'subscribe'>
const unsubscribe = subscribeMobileTerminalSafely(
client,
{ terminal: 'terminal-1' },
vi.fn(),
onSynchronousError
)
expect(onSynchronousError).toHaveBeenCalledOnce()
expect(unsubscribe).not.toThrow()
})
})
@@ -0,0 +1,17 @@
import type { RpcClient } from '../transport/rpc-client'
export function subscribeMobileTerminalSafely(
client: Pick<RpcClient, 'subscribe'>,
params: unknown,
onData: Parameters<RpcClient['subscribe']>[2],
onSynchronousError: () => void
): () => void {
try {
return client.subscribe('terminal.subscribe', params, onData)
} catch {
// Why: a transport mock or closing socket can reject before returning an
// unsubscribe handle; callers must still release their subscribing marker.
onSynchronousError()
return () => {}
}
}
@@ -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 { useMobileAttachmentInputLeaseGate } from './use-mobile-attachment-input-lease-gate'
type Gate = (targetHandle: string) => Promise<boolean>
describe('useMobileAttachmentInputLeaseGate', () => {
let renderer: ReactTestRenderer | null = null
let errorSpy: ReturnType<typeof vi.spyOn> | null = null
beforeEach(() => {
const original = console.error
errorSpy = vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => {
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
return
}
original(...(args as Parameters<typeof console.error>))
})
vi.useFakeTimers()
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
vi.useRealTimers()
errorSpy?.mockRestore()
})
function renderGate(args: {
connState: { current: string }
activeHandle: { current: string | null }
tabType: { current: string | null }
leaseReady: { current: boolean }
showToast: (message: string, durationMs?: number) => void
}): { gate: () => Gate } {
let gate: Gate = () => Promise.resolve(false)
function Probe(): null {
gate = useMobileAttachmentInputLeaseGate({
flushPendingLiveInputBeforeExternalSend: () => Promise.resolve(true),
connStateRef: args.connState,
activeHandleRef: args.activeHandle,
activeSessionTabTypeRef: args.tabType,
nativeChatInputLeaseReadyRef: args.leaseReady,
showToast: args.showToast
})
return null
}
act(() => {
renderer = create(createElement(Probe))
})
return { gate: () => gate }
}
function baseRefs(): {
connState: { current: string }
activeHandle: { current: string | null }
tabType: { current: string | null }
leaseReady: { current: boolean }
} {
return {
connState: { current: 'connected' },
activeHandle: { current: 'terminal-1' },
tabType: { current: 'terminal' },
leaseReady: { current: true }
}
}
it('passes immediately when the lease is ready and the target is active', async () => {
const refs = baseRefs()
const showToast = vi.fn()
const { gate } = renderGate({ ...refs, showToast })
await expect(gate()('terminal-1')).resolves.toBe(true)
expect(showToast).not.toHaveBeenCalled()
})
it('waits out a lease-not-ready window and then sends', async () => {
const refs = baseRefs()
refs.leaseReady.current = false
const showToast = vi.fn()
const { gate } = renderGate({ ...refs, showToast })
const result = gate()('terminal-1')
await vi.advanceTimersByTimeAsync(200)
refs.leaseReady.current = true
await vi.advanceTimersByTimeAsync(100)
await expect(result).resolves.toBe(true)
expect(showToast).not.toHaveBeenCalled()
})
it('surfaces a toast when the lease never recovers', async () => {
const refs = baseRefs()
refs.leaseReady.current = false
const showToast = vi.fn()
const { gate } = renderGate({ ...refs, showToast })
const result = gate()('terminal-1')
await vi.advanceTimersByTimeAsync(3200)
await expect(result).resolves.toBe(false)
expect(showToast).toHaveBeenCalledWith('Attach failed (reconnecting)', 1500)
})
it('drops silently when the target changes while waiting for the lease', async () => {
const refs = baseRefs()
refs.leaseReady.current = false
const showToast = vi.fn()
const { gate } = renderGate({ ...refs, showToast })
const result = gate()('terminal-1')
// Mid-wait the user switches tabs: the lease recovers, but for a different
// target — the attach must not proceed against the stale handle.
await vi.advanceTimersByTimeAsync(200)
refs.activeHandle.current = 'terminal-2'
refs.leaseReady.current = true
await vi.advanceTimersByTimeAsync(100)
await expect(result).resolves.toBe(false)
expect(showToast).not.toHaveBeenCalled()
})
it('drops silently when the connection is lost while waiting', async () => {
const refs = baseRefs()
refs.leaseReady.current = false
const showToast = vi.fn()
const { gate } = renderGate({ ...refs, showToast })
const result = gate()('terminal-1')
await vi.advanceTimersByTimeAsync(200)
refs.connState.current = 'reconnecting'
await vi.advanceTimersByTimeAsync(3200)
await expect(result).resolves.toBe(false)
expect(showToast).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,75 @@
import { useCallback } from 'react'
type CurrentRef<T> = { readonly current: T }
type AttachmentInputLeaseGateArgs = {
readonly flushPendingLiveInputBeforeExternalSend: (handle: string) => Promise<boolean>
readonly connStateRef: CurrentRef<string>
readonly activeHandleRef: CurrentRef<string | null>
readonly activeSessionTabTypeRef: CurrentRef<string | null>
readonly nativeChatInputLeaseReadyRef: CurrentRef<boolean>
readonly showToast: (message: string, durationMs?: number) => void
}
// Poll cadence + ceiling for riding out a terminal resubscribe (WS reconnect or
// return-to-terminal) during which the input lease is briefly not ready.
const LEASE_READY_POLL_MS = 100
const LEASE_READY_TIMEOUT_MS = 3000
/** Gates an image attachment's terminal.send on a ready input lease. Flushes any
* pending IME/live input, confirms the send still targets the connected terminal
* tab, then waits out a short lease-not-ready window so a finished upload isn't
* dropped as if the picker were cancelled. Returns false only when the lease
* never recovers — surfacing a toast, since the caller treats a bare false as a
* silent picker-cancel (no error path). */
export function useMobileAttachmentInputLeaseGate({
flushPendingLiveInputBeforeExternalSend,
connStateRef,
activeHandleRef,
activeSessionTabTypeRef,
nativeChatInputLeaseReadyRef,
showToast
}: AttachmentInputLeaseGateArgs): (targetHandle: string) => Promise<boolean> {
return useCallback(
async (targetHandle: string): Promise<boolean> => {
const flushedPendingInput = await flushPendingLiveInputBeforeExternalSend(targetHandle)
// Why: image picking/upload and IME flushing can outlive the original tab.
if (
!flushedPendingInput ||
connStateRef.current !== 'connected' ||
targetHandle !== activeHandleRef.current ||
activeSessionTabTypeRef.current !== 'terminal'
) {
return false
}
const deadline = Date.now() + LEASE_READY_TIMEOUT_MS
while (!nativeChatInputLeaseReadyRef.current && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, LEASE_READY_POLL_MS))
}
// Why: the wait can outlive the target too — re-check so a tab/host switch
// or disconnect mid-wait doesn't send into the wrong (or dead) terminal.
// A moved-away target drops silently like the pre-wait guard; only a lease
// that never recovered warrants the toast.
if (
connStateRef.current !== 'connected' ||
targetHandle !== activeHandleRef.current ||
activeSessionTabTypeRef.current !== 'terminal'
) {
return false
}
if (nativeChatInputLeaseReadyRef.current) {
return true
}
showToast('Attach failed (reconnecting)', 1500)
return false
},
[
activeHandleRef,
activeSessionTabTypeRef,
connStateRef,
flushPendingLiveInputBeforeExternalSend,
nativeChatInputLeaseReadyRef,
showToast
]
)
}
@@ -0,0 +1,243 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AgentType } from '../../../src/shared/native-chat-types'
import type { RpcClient } from '../transport/rpc-client'
import { MOBILE_NATIVE_CHAT_QUESTION_STEP_MS } from './mobile-native-chat-answer-stepping'
import type { AskPrompt } from './mobile-native-chat-ask'
import { useMobileNativeChatAnswerSend } from './use-mobile-native-chat-answer-send'
type AnswerSend = ReturnType<typeof useMobileNativeChatAnswerSend>
function acceptedResponse() {
return {
id: 'send',
ok: true as const,
result: { send: { accepted: true } },
_meta: { runtimeId: 'runtime' }
}
}
const TABS_OR_SPACES: AskPrompt = {
questions: [
{
question: 'Tabs or spaces?',
multiSelect: false,
options: [{ label: 'Tabs' }, { label: 'Spaces' }]
}
]
}
describe('useMobileNativeChatAnswerSend', () => {
let renderer: ReactTestRenderer | null = null
let answerSend: AnswerSend | null = null
let mountedClient: RpcClient | null = null
let mountedOnSendError: ((message: string) => void) | null = null
let mountedAgent: AgentType = 'claude'
beforeEach(() => {
vi.useFakeTimers()
globalThis.IS_REACT_ACT_ENVIRONMENT = true
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
answerSend = null
mountedClient = null
mountedOnSendError = null
mountedAgent = 'claude'
vi.useRealTimers()
})
function Harness({ enabled }: { enabled: boolean }): null {
answerSend = useMobileNativeChatAnswerSend({
client: mountedClient,
enabled,
handleRef: { current: 'terminal' },
deviceTokenRef: { current: 'device' },
agentRef: { current: mountedAgent },
sessionId: 'session',
streamIdentity: 'host\0worktree\0tab\0session',
onSendError: mountedOnSendError!
})
return null
}
async function mount(
client: RpcClient,
onSendError: (message: string) => void,
agent: AgentType = 'claude'
): Promise<void> {
mountedClient = client
mountedOnSendError = onSendError
mountedAgent = agent
const original = console.error
const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
return
}
original(...args)
})
try {
await act(async () => {
renderer = create(createElement(Harness, { enabled: true }))
})
} finally {
consoleSpy.mockRestore()
}
}
async function setEnabled(enabled: boolean): Promise<void> {
await act(async () => {
renderer?.update(createElement(Harness, { enabled }))
})
}
it('single-select: sends the picked option NUMBER (not the label), no trailing Enter', async () => {
const sendRequest = vi.fn().mockResolvedValue(acceptedResponse())
await mount({ sendRequest } as unknown as RpcClient, vi.fn())
// Spaces is option 2 — the STA-1860 case where label text committed Tabs.
await expect(answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])).resolves.toBe(true)
expect(sendRequest).toHaveBeenCalledTimes(1)
expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ text: '2', enter: false })
})
it('multi-select: toggles each option number, steps to Submit, confirms — paced apart', async () => {
const sendRequest = vi.fn().mockResolvedValue(acceptedResponse())
await mount({ sendRequest } as unknown as RpcClient, vi.fn())
const prompt: AskPrompt = {
questions: [
{
question: 'Pick fruits',
multiSelect: true,
options: [{ label: 'Apple' }, { label: 'Banana' }, { label: 'Cherry' }]
}
]
}
let result: Promise<boolean> | undefined
await act(async () => {
result = answerSend?.answerAsk(prompt, [{ indices: [0, 2] }])
})
expect(sendRequest).toHaveBeenCalledTimes(1)
expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ text: '1', enter: false })
await act(async () => vi.advanceTimersByTimeAsync(MOBILE_NATIVE_CHAT_QUESTION_STEP_MS))
expect(sendRequest.mock.calls[1]?.[1]).toMatchObject({ text: '3', enter: false })
await act(async () => vi.advanceTimersByTimeAsync(MOBILE_NATIVE_CHAT_QUESTION_STEP_MS))
expect(sendRequest.mock.calls[2]?.[1]).toMatchObject({ text: '\x1b[C', enter: false })
await act(async () => vi.advanceTimersByTimeAsync(MOBILE_NATIVE_CHAT_QUESTION_STEP_MS))
await expect(result).resolves.toBe(true)
expect(sendRequest.mock.calls[3]?.[1]).toMatchObject({ text: '\r', enter: false })
})
it('multi-question: option numbers auto-advance, one final submit Enter', 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 result: Promise<boolean> | undefined
await act(async () => {
result = answerSend?.answerAsk(prompt, [{ indices: [1] }, { indices: [0] }])
})
await act(async () => vi.runAllTimersAsync())
await expect(result).resolves.toBe(true)
expect(sendRequest.mock.calls.map((call) => call[1])).toEqual([
expect.objectContaining({ text: '2', enter: false }),
expect.objectContaining({ text: '1', enter: false }),
expect.objectContaining({ text: '\r', enter: false })
])
})
it('free text: opens "Type something", types the sanitized answer, then Enter', async () => {
const sendRequest = vi.fn().mockResolvedValue(acceptedResponse())
await mount({ sendRequest } as unknown as RpcClient, vi.fn())
let result: Promise<boolean> | undefined
await act(async () => {
// A newline in raw keystrokes would submit early — must collapse to space.
result = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [], other: 'zeta\nspaces' }])
})
await act(async () => vi.runAllTimersAsync())
await expect(result).resolves.toBe(true)
expect(sendRequest.mock.calls.map((call) => call[1])).toEqual([
expect.objectContaining({ text: '3', enter: false }),
expect.objectContaining({ text: 'zeta spaces', enter: false }),
expect.objectContaining({ text: '\r', enter: false })
])
})
it('answers OpenClaude asks with Claude selector keystrokes', async () => {
const sendRequest = vi.fn().mockResolvedValue(acceptedResponse())
await mount({ sendRequest } as unknown as RpcClient, vi.fn(), 'openclaude')
await expect(answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])).resolves.toBe(true)
expect(sendRequest).toHaveBeenCalledTimes(1)
expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ text: '2', enter: false })
})
it('submits a non-Claude answer as pasted label text with a single Enter', async () => {
const sendRequest = vi.fn().mockResolvedValue(acceptedResponse())
await mount({ sendRequest } as unknown as RpcClient, vi.fn(), 'codex')
await expect(answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])).resolves.toBe(true)
// Codex's question tool commits the pasted answer: label text + one Enter.
expect(sendRequest).toHaveBeenCalledTimes(1)
expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ text: 'Spaces', enter: true })
})
it('stops at the first rejected write and reports failure', async () => {
const onSendError = vi.fn()
const sendRequest = vi.fn().mockResolvedValue({
id: 'send',
ok: true,
result: { send: { accepted: false } },
_meta: { runtimeId: 'runtime' }
})
await mount({ sendRequest } as unknown as RpcClient, onSendError)
await expect(answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])).resolves.toBe(false)
expect(sendRequest).toHaveBeenCalledTimes(1)
expect(onSendError).toHaveBeenCalledWith('Answer not sent')
})
it('rejects an empty selection without writing anything', async () => {
const sendRequest = vi.fn().mockResolvedValue(acceptedResponse())
await mount({ sendRequest } as unknown as RpcClient, vi.fn())
await expect(answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [] }])).resolves.toBe(false)
expect(sendRequest).not.toHaveBeenCalled()
})
it('cancels delayed keystrokes when the acknowledged input lease is lost', 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 result: Promise<boolean> | undefined
await act(async () => {
result = answerSend?.answerAsk(prompt, [{ indices: [1] }, { indices: [0] }])
})
expect(sendRequest).toHaveBeenCalledTimes(1)
await setEnabled(false)
await act(async () => vi.runAllTimersAsync())
await expect(result).resolves.toBe(false)
expect(sendRequest).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,176 @@
import { useCallback, useEffect, useRef, type MutableRefObject } from 'react'
import type { RpcClient } from '../transport/rpc-client'
import { MOBILE_NATIVE_CHAT_QUESTION_STEP_MS } from './mobile-native-chat-answer-stepping'
import {
buildAskAnswerKeys,
formatAskAnswer,
hasAskAnswer,
type AskAnswerSelection,
type AskPrompt
} from './mobile-native-chat-ask'
import { sendMobileNativeChatMessage } from './mobile-native-chat-send'
import { shouldStepNativeChatAskAnswer } from '../../../src/shared/native-chat-agent-support'
/** Sends an AskUserQuestion answer to the active chat pane. Claude's selector is
* answered by option-number keystrokes; other agents get pasted label text.
* Extracted from the session route to keep that file under its line cap and to
* own the pending-timer lifecycle in one place. */
export type MobileNativeChatAnswerSend = {
/** Answer the current question(s) from the card's per-question selections. */
answerAsk: (prompt: AskPrompt, selections: AskAnswerSelection[]) => Promise<boolean>
/** Drop any in-flight per-keystroke writes (call on Stop). */
cancelPending: () => void
}
// A free-text answer is written as raw keystrokes into Claude's "Type something"
// input (terminal.send has no paste framing), so an embedded newline would
// submit it early — collapse line breaks to spaces.
function sanitizeAskFreeText(text: string): string {
return text.replace(/[\r\n]+/g, ' ')
}
/**
* Owns the ask-answer send sequence for the mobile native chat. Reads the live
* pane/agent through refs (the route already keeps them current) so the returned
* callbacks stay stable. Claude answers are delivered as `buildAskAnswerKeys`
* keystroke groups written one selector-step apart over the EXISTING
* `terminal.send` passthrough (raw text, no enter) — same contract the
* permission card already uses, so old runtimes replay them verbatim (no new
* RPC; keystrokes are built client-side). The scheduled wait chain is cancelled
* on a new answer, on `cancelPending` (Stop), and on unmount / session swap — so
* a detached chain can never write PTY bytes to a stale pane.
*/
export function useMobileNativeChatAnswerSend(args: {
client: RpcClient | null
enabled: boolean
handleRef: MutableRefObject<string | null>
deviceTokenRef: MutableRefObject<string | null>
agentRef: MutableRefObject<string | null>
/** Changes on chat session swap; cancels pending writes when it does. */
sessionId: string | null
streamIdentity: string
onSendError: (message: string) => void
}): MobileNativeChatAnswerSend {
const {
client,
enabled,
handleRef,
deviceTokenRef,
agentRef,
sessionId,
streamIdentity,
onSendError
} = args
const generationRef = useRef(0)
const activeRouteRef = useRef({ client, enabled, sessionId, streamIdentity })
activeRouteRef.current = { client, enabled, sessionId, streamIdentity }
const delaysRef = useRef<
Set<{ timer: ReturnType<typeof setTimeout>; resolve: (completed: boolean) => void }>
>(new Set())
const cancelPending = useCallback(() => {
generationRef.current += 1
for (const delay of delaysRef.current) {
clearTimeout(delay.timer)
delay.resolve(false)
}
delaysRef.current.clear()
}, [])
// Cancel pending writes on unmount and whenever the chat session swaps.
useEffect(() => {
if (!enabled) {
cancelPending()
}
return cancelPending
}, [client, enabled, sessionId, streamIdentity, cancelPending])
const answerAsk = useCallback(
async (prompt: AskPrompt, selections: AskAnswerSelection[]): Promise<boolean> => {
const handle = handleRef.current
if (!client || !handle || !enabled) {
onSendError('Answer not sent (disconnected)')
return false
}
if (!hasAskAnswer(prompt, selections)) {
return false
}
// A new answer supersedes any still-pending keystroke writes.
cancelPending()
const generation = generationRef.current
const sendTerminal = (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 Promise.resolve(false)
}
return sendMobileNativeChatMessage({
client,
terminal: handle,
text: body,
enter,
...(deviceTokenRef.current
? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } }
: {})
})
}
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) {
onSendError('Answer not sent')
}
return false
}
// Non-Claude question tools commit a pasted answer, so send the label text
// with one Enter. Claude's arrow-navigate selector ignores pasted labels
// (STA-1860): drive it by option-number keystrokes instead, one group per
// selector step so each renders before the next lands.
if (!shouldStepNativeChatAskAnswer(agentRef.current)) {
return (await sendTerminal(formatAskAnswer(prompt, selections), true)) || fail()
}
const groups = 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 && !(await wait(MOBILE_NATIVE_CHAT_QUESTION_STEP_MS))) {
return false
}
}
return groups.length > 0
},
[
agentRef,
enabled,
cancelPending,
client,
deviceTokenRef,
handleRef,
onSendError,
sessionId,
streamIdentity
]
)
return { answerAsk, cancelPending }
}
@@ -0,0 +1,60 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AskPrompt } from './mobile-native-chat-ask'
import { useMobileNativeChatAskDismiss } from './use-mobile-native-chat-ask-dismiss'
describe('useMobileNativeChatAskDismiss', () => {
let renderer: ReactTestRenderer | null = null
let state: ReturnType<typeof useMobileNativeChatAskDismiss> | null = null
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
state = null
})
function Harness({ prompt }: { prompt: AskPrompt }): null {
state = useMobileNativeChatAskDismiss(prompt)
return null
}
const first: AskPrompt = {
questions: [
{ question: 'same first', multiSelect: false, options: [] },
{ question: 'old second', multiSelect: false, options: [] }
]
}
const replacement: AskPrompt = {
questions: [
{ question: 'same first', multiSelect: false, options: [] },
{ question: 'new second', multiSelect: false, options: [] }
]
}
it('shows a structurally different replacement without an intervening null', async () => {
const original = console.error
const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
return
}
original(...args)
})
try {
await act(async () => {
renderer = create(createElement(Harness, { prompt: first }))
})
} finally {
consoleSpy.mockRestore()
}
act(() => state?.dismissAsk())
expect(state?.showAsk).toBe(false)
await act(async () => renderer?.update(createElement(Harness, { prompt: replacement })))
expect(state?.showAsk).toBe(true)
})
})
@@ -0,0 +1,26 @@
import { useEffect, useState } from 'react'
import type { AskPrompt } from './mobile-native-chat-ask'
/** Track the answered-ask key so the lingering live status doesn't re-show the
* same card. The agent emits a post-tool event with the same prompt right after
* an answer, so the card is hidden until a genuinely different question arrives. */
export function useMobileNativeChatAskDismiss(ask?: AskPrompt | null): {
askKey: string | null
showAsk: boolean
dismissAsk: () => void
} {
const askKey = ask ? JSON.stringify(ask.questions) : null
const [dismissedAskKey, setDismissedAskKey] = useState<string | null>(null)
// Once the prompt clears (agent moved on), forget the dismissal so a later
// question — even an identical one — shows again instead of staying hidden.
const askPresent = ask != null
useEffect(() => {
if (!askPresent) {
setDismissedAskKey(null)
}
}, [askPresent])
const showAsk = askPresent && askKey !== dismissedAskKey
const dismissAsk = (): void => setDismissedAskKey(askKey)
return { askKey, showAsk, dismissAsk }
}
@@ -0,0 +1,314 @@
import {
useCallback,
useEffect,
useRef,
useState,
type Dispatch,
type MutableRefObject,
type SetStateAction
} from 'react'
import { loadNativeChatTabIds, saveNativeChatTabIds } from '../storage/preferences'
import type { RpcClient } from '../transport/rpc-client'
import {
parseAskFromStatus,
type AskAnswerSelection,
type AskPrompt
} from './mobile-native-chat-ask'
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 { sendMobileNativeChatMessage } from './mobile-native-chat-send'
import { useMobileNativeChatAnswerSend } from './use-mobile-native-chat-answer-send'
import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts'
import { useMobileNativeChatFileSearch } from './use-mobile-native-chat-file-search'
import { useMobileNativeChatSession } from './use-mobile-native-chat-session'
import { useMobileNativeChatPrompts } from './use-mobile-native-chat-prompts'
import { useMobileNativeChatStop } from './use-mobile-native-chat-stop'
import { useThrottledLatestValue } from './use-throttled-latest-value'
const NATIVE_CHAT_STREAM_THROTTLE_MS = 50
export type MobileNativeChatController = {
chatTabIds: Set<string>
toggleTabChatView: (tabId: string) => void
showNativeChat: boolean
showNativeChatRef: MutableRefObject<boolean>
/** Resolved agent for the active chat tab (names the empty-state copy). */
nativeChatAgent: string | null
chatComposerText: string
setChatComposerText: Dispatch<SetStateAction<string>>
chatPending: Array<{ id: string; text: string }>
nativeChatSession: ReturnType<typeof useMobileNativeChatSession>
nativeChatAgentWorking: boolean
nativeChatStreamingText?: string
nativeChatPermission: ReturnType<typeof detectAgentPermission>
nativeChatQuestion: ReturnType<typeof parseAgentQuestion>
nativeChatAsk: ReturnType<typeof parseAskFromStatus>
handleNativeChatOpenFile: (relativePath: string) => void
handleNativeChatAnswerAsk: (
prompt: AskPrompt,
selections: AskAnswerSelection[]
) => Promise<boolean>
handleNativeChatCancelAsk: () => Promise<boolean>
handleNativeChatRespondPermission: (text: string) => Promise<boolean>
handleNativeChatStop: () => void
nativeChatFilePaths: string[]
loadNativeChatFiles: (query: string) => void
handleNativeChatSend: (text: string) => Promise<boolean>
}
/** Owns mobile native-chat state and teardown outside the already dense session
* route. The route remains responsible only for choosing and rendering the view. */
export function useMobileNativeChatController(args: {
client: RpcClient | null
hostId: string
worktreeId: string
activeSessionTab: MobileNativeChatTab | null
activeSessionTabId: string | null
activeHandleRef: MutableRefObject<string | null>
deviceTokenRef: MutableRefObject<string | null>
nativeChatTranscriptIsLocalReadable: boolean
nativeChatInputLeaseReady: boolean
onSendError: (message: string) => void
}): MobileNativeChatController {
const {
client,
hostId,
worktreeId,
activeSessionTab,
activeSessionTabId,
activeHandleRef,
deviceTokenRef,
nativeChatTranscriptIsLocalReadable,
nativeChatInputLeaseReady,
onSendError
} = args
const [chatTabIds, setChatTabIds] = useState<Set<string>>(new Set())
const chatTabIdsToggledRef = useRef(false)
useEffect(() => {
let active = true
// Re-arm per host/worktree so the fresh load can seed the new selection.
chatTabIdsToggledRef.current = false
void loadNativeChatTabIds(hostId, worktreeId).then((ids) => {
// Why: a toggle before this load resolves is authoritative; the load must
// not revert the user's in-memory choice back to the persisted state.
if (active && !chatTabIdsToggledRef.current) {
// Why: route reuse must clear the prior worktree's selection even when
// the newly loaded worktree has no saved chat tabs.
setChatTabIds(new Set(ids))
}
})
return () => {
active = false
}
}, [hostId, worktreeId])
const toggleTabChatView = useCallback(
(tabId: string) => {
chatTabIdsToggledRef.current = true
setChatTabIds((previous) => {
const next = new Set(previous)
if (next.has(tabId)) {
next.delete(tabId)
} else {
next.add(tabId)
}
void saveNativeChatTabIds(hostId, worktreeId, [...next])
return next
})
},
[hostId, worktreeId]
)
const activeChatResolution =
activeSessionTab && activeSessionTabId && chatTabIds.has(activeSessionTabId)
? resolveMobileNativeChat(activeSessionTab, nativeChatTranscriptIsLocalReadable)
: null
const showNativeChat = activeChatResolution != null
const showNativeChatRef = useRef(showNativeChat)
showNativeChatRef.current = showNativeChat
const activeChatAgentRef = useRef<string | null>(activeChatResolution?.agent ?? null)
activeChatAgentRef.current = activeChatResolution?.agent ?? null
const activeChatSessionId = activeChatResolution?.sessionId ?? null
const streamIdentity = `${hostId}\0${worktreeId}\0${activeSessionTabId ?? ''}\0${activeChatSessionId ?? ''}\0${activeHandleRef.current ?? ''}`
const nativeChatSession = useMobileNativeChatSession({
client,
agent: activeChatResolution?.agent ?? null,
sessionId: activeChatSessionId,
transcriptPath: activeChatResolution?.transcriptPath ?? null
})
const {
composerText: chatComposerText,
setComposerText: setChatComposerText,
pending: chatPending,
captureSendOrigin,
acceptSend
} = useMobileNativeChatDrafts({
hostId,
worktreeId,
tabId: activeSessionTabId,
sessionId: activeChatSessionId,
messages: nativeChatSession.messages
})
const nativeChatStatus = activeChatResolution ? activeSessionTab?.agentStatus : null
const nativeChatAgentWorking = nativeChatStatus?.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(
nativeChatAgentWorking ? nativeChatStatus?.lastAssistantMessage : undefined,
NATIVE_CHAT_STREAM_THROTTLE_MS
)
const {
permission: nativeChatPermission,
question: nativeChatQuestion,
ask: nativeChatAsk
} = useMobileNativeChatPrompts({
enabled: activeChatResolution != null,
status: nativeChatStatus,
messages: nativeChatSession.messages
})
const handleNativeChatOpenFile = useCallback(
(pathText: string) => {
if (!client) {
return
}
void openMobileNativeChatFile({
client,
worktreeId,
pathText,
terminal: activeHandleRef.current
})
},
[activeHandleRef, client, worktreeId]
)
const { answerAsk: handleNativeChatAnswerAsk, cancelPending: cancelNativeChatAnswer } =
useMobileNativeChatAnswerSend({
client,
enabled: nativeChatInputLeaseReady,
handleRef: activeHandleRef,
deviceTokenRef,
agentRef: activeChatAgentRef,
sessionId: activeChatSessionId,
streamIdentity,
onSendError
})
const handleNativeChatCancelAsk = useCallback(async (): Promise<boolean> => {
const handle = activeHandleRef.current
if (!client || !handle || !nativeChatInputLeaseReady) {
onSendError('Cancel not sent (disconnected)')
return false
}
cancelNativeChatAnswer()
const accepted = await sendMobileNativeChatMessage({
client,
terminal: handle,
text: String.fromCharCode(27),
enter: false,
...(deviceTokenRef.current
? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } }
: {})
})
if (!accepted) {
onSendError('Cancel not sent')
}
return accepted
}, [
activeHandleRef,
cancelNativeChatAnswer,
client,
deviceTokenRef,
nativeChatInputLeaseReady,
onSendError
])
const handleNativeChatRespondPermission = useMobileNativeChatPermissionSend({
client,
enabled: nativeChatInputLeaseReady,
handleRef: activeHandleRef,
deviceTokenRef,
onSendError
})
const handleNativeChatStop = useMobileNativeChatStop({
client,
enabled: nativeChatInputLeaseReady,
handleRef: activeHandleRef,
deviceTokenRef,
streamIdentity,
cancelPending: cancelNativeChatAnswer,
onSendError
})
const { nativeChatFilePaths, loadNativeChatFiles } = useMobileNativeChatFileSearch({
client,
worktreeId
})
const handleNativeChatSend = useCallback(
async (text: string): Promise<boolean> => {
const handle = activeHandleRef.current
const origin = captureSendOrigin(text)
if (!client || !handle || !origin || !nativeChatInputLeaseReady) {
onSendError('Message not sent (disconnected)')
return false
}
const accepted = await sendMobileNativeChatMessage({
client,
terminal: handle,
text,
...(deviceTokenRef.current
? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } }
: {})
})
if (!accepted) {
onSendError('Message not sent')
return false
}
acceptSend(origin, text)
return true
},
[
acceptSend,
activeHandleRef,
captureSendOrigin,
client,
deviceTokenRef,
nativeChatInputLeaseReady,
onSendError
]
)
return {
chatTabIds,
toggleTabChatView,
showNativeChat,
showNativeChatRef,
nativeChatAgent: activeChatResolution?.agent ?? null,
chatComposerText,
setChatComposerText,
chatPending,
nativeChatSession,
nativeChatAgentWorking,
nativeChatStreamingText,
nativeChatPermission,
nativeChatQuestion,
nativeChatAsk,
handleNativeChatOpenFile,
handleNativeChatAnswerAsk,
handleNativeChatCancelAsk,
handleNativeChatRespondPermission,
handleNativeChatStop,
nativeChatFilePaths,
loadNativeChatFiles,
handleNativeChatSend
}
}
@@ -0,0 +1,192 @@
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 { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts'
type DraftState = ReturnType<typeof useMobileNativeChatDrafts>
function userTextMessage(id: string, text: string): NativeChatMessage {
return {
id,
role: 'user',
blocks: [{ type: 'text', text }],
timestamp: null,
source: 'transcript'
}
}
function assistantTextMessage(id: string, text: string): NativeChatMessage {
return {
id,
role: 'assistant',
blocks: [{ type: 'text', text }],
timestamp: null,
source: 'transcript'
}
}
describe('useMobileNativeChatDrafts', () => {
let renderer: ReactTestRenderer | null = null
let state: DraftState | null = null
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
state = null
})
function Harness({
tabId,
sessionId = `session-${tabId}`,
messages = []
}: {
tabId: string
sessionId?: string | null
messages?: NativeChatMessage[]
}): null {
state = useMobileNativeChatDrafts({
hostId: 'host',
worktreeId: 'worktree',
tabId,
sessionId,
messages
})
return null
}
async function mount(tabId: string): Promise<void> {
const original = console.error
const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
return
}
original(...args)
})
try {
await act(async () => {
renderer = create(createElement(Harness, { tabId }))
})
} finally {
consoleSpy.mockRestore()
}
}
async function switchTo(tabId: string): Promise<void> {
await act(async () => renderer?.update(createElement(Harness, { tabId })))
}
it('keeps drafts and accepted pending messages on their originating tabs', async () => {
await mount('a')
act(() => state?.setComposerText('from a'))
const originA = state?.captureSendOrigin('from a')
expect(originA).not.toBeNull()
await switchTo('b')
act(() => state?.setComposerText('from b'))
act(() => {
if (originA) {
state?.acceptSend(originA, 'from a')
}
})
expect(state?.composerText).toBe('from b')
expect(state?.pending).toEqual([])
await switchTo('a')
expect(state?.composerText).toBe('')
expect(state?.pending.map((pending) => pending.text)).toEqual(['from a'])
})
it('clears one pending per landed message so duplicate sends are not all dropped', async () => {
await mount('a')
const origin = state?.captureSendOrigin('ping')
act(() => {
if (origin) {
state?.acceptSend(origin, 'ping')
state?.acceptSend(origin, 'ping')
}
})
expect(state?.pending.map((pending) => pending.text)).toEqual(['ping', 'ping'])
await act(async () =>
renderer?.update(
createElement(Harness, { tabId: 'a', messages: [userTextMessage('m1', 'ping')] })
)
)
expect(state?.pending.map((pending) => pending.text)).toEqual(['ping'])
})
it('does not reconcile a repeated send against an older identical turn', async () => {
await mount('a')
await act(async () =>
renderer?.update(
createElement(Harness, { tabId: 'a', messages: [userTextMessage('old', 'ping')] })
)
)
const origin = state?.captureSendOrigin('ping')
act(() => {
if (origin) {
state?.acceptSend(origin, 'ping')
}
})
await act(async () =>
renderer?.update(
createElement(Harness, {
tabId: 'a',
messages: [userTextMessage('old', 'ping'), assistantTextMessage('other', 'working')]
})
)
)
expect(state?.pending.map((pending) => pending.text)).toEqual(['ping'])
await act(async () =>
renderer?.update(
createElement(Harness, {
tabId: 'a',
messages: [
userTextMessage('old', 'ping'),
assistantTextMessage('other', 'working'),
userTextMessage('new', 'ping')
]
})
)
)
expect(state?.pending).toEqual([])
})
it('does not erase newer edits when an older send settles', async () => {
await mount('a')
act(() => state?.setComposerText('submitted'))
const origin = state?.captureSendOrigin('submitted')
act(() => state?.setComposerText('new edit'))
act(() => {
if (origin) {
state?.acceptSend(origin, 'submitted')
}
})
expect(state?.composerText).toBe('new edit')
})
it('accepts and clears the first send before a provider session id exists', async () => {
await mount('a')
await act(async () => renderer?.update(createElement(Harness, { tabId: 'a', sessionId: null })))
act(() => state?.setComposerText('start the session'))
const origin = state?.captureSendOrigin('start the session')
expect(origin).toMatchObject({ pendingKey: null })
act(() => {
if (origin) {
state?.acceptSend(origin, 'start the session')
}
})
expect(state?.composerText).toBe('')
expect(state?.pending).toEqual([])
})
})
@@ -0,0 +1,165 @@
import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
export type MobileNativeChatPendingMessage = {
id: string
text: string
expectedOccurrence: number
}
export type MobileNativeChatSendOrigin = {
draftKey: string
pendingKey: string | null
normalizedText: string
baselineOccurrences: number
}
const NO_PENDING_MESSAGES: MobileNativeChatPendingMessage[] = []
function normalizedUserText(message: NativeChatMessage): string | null {
if (message.role !== 'user') {
return null
}
const text = message.blocks
.filter((block) => block.type === 'text')
.map((block) => (block.type === 'text' ? block.text : ''))
.join('')
.trim()
return text || null
}
function countUserTextOccurrences(messages: readonly NativeChatMessage[], text: string): number {
let count = 0
for (const message of messages) {
if (normalizedUserText(message) === text) {
count++
}
}
return count
}
export function useMobileNativeChatDrafts(args: {
hostId: string
worktreeId: string
tabId: string | null
sessionId: string | null
messages: readonly NativeChatMessage[]
}): {
composerText: string
setComposerText: Dispatch<SetStateAction<string>>
pending: MobileNativeChatPendingMessage[]
captureSendOrigin: (text: string) => MobileNativeChatSendOrigin | null
acceptSend: (origin: MobileNativeChatSendOrigin, text: string) => void
} {
const { hostId, worktreeId, tabId, sessionId, messages } = args
const draftKey = tabId ? `${hostId}\0${worktreeId}\0${tabId}` : null
const pendingKey = draftKey && sessionId ? `${draftKey}\0${sessionId}` : null
const [drafts, setDrafts] = useState<Record<string, string>>({})
const [pendingBySession, setPendingBySession] = useState<
Record<string, MobileNativeChatPendingMessage[]>
>({})
const pendingCounterRef = useRef(0)
const messagesRef = useRef(messages)
messagesRef.current = messages
const setComposerText: Dispatch<SetStateAction<string>> = useCallback(
(value) => {
if (!draftKey) {
return
}
setDrafts((previous) => {
const current = previous[draftKey] ?? ''
const next = typeof value === 'function' ? value(current) : value
return next === current ? previous : { ...previous, [draftKey]: next }
})
},
[draftKey]
)
const captureSendOrigin = useCallback(
(text: string) => {
if (!draftKey) {
return null
}
const normalizedText = text.trim()
return {
draftKey,
pendingKey,
normalizedText,
baselineOccurrences: countUserTextOccurrences(messagesRef.current, normalizedText)
}
},
[draftKey, pendingKey]
)
const acceptSend = useCallback((origin: MobileNativeChatSendOrigin, text: string) => {
// Why: an RPC may settle after a tab switch; mutate only the tab that
// originated the send, without erasing edits typed after it began.
setDrafts((previous) =>
(previous[origin.draftKey] ?? '').trim() === text.trim()
? { ...previous, [origin.draftKey]: '' }
: previous
)
// Why: the first prompt can be sent before the provider reports a session
// id; clear its draft, but wait for an id before keying an optimistic echo.
if (!origin.pendingKey) {
return
}
const pendingKey = origin.pendingKey
pendingCounterRef.current += 1
setPendingBySession((previous) => {
const current = previous[pendingKey] ?? NO_PENDING_MESSAGES
const earlierOutstanding = current.filter(
(pending) =>
pending.text.trim() === origin.normalizedText &&
pending.expectedOccurrence > origin.baselineOccurrences
).length
const pending = {
id: `pending-${pendingCounterRef.current}`,
text,
expectedOccurrence: origin.baselineOccurrences + earlierOutstanding + 1
}
return { ...previous, [pendingKey]: [...current, pending] }
})
}, [])
const pending = pendingKey
? (pendingBySession[pendingKey] ?? NO_PENDING_MESSAGES)
: NO_PENDING_MESSAGES
useEffect(() => {
if (!pendingKey || pending.length === 0) {
return
}
setPendingBySession((previous) => {
const current = previous[pendingKey] ?? []
const landedCounts = new Map<string, number>()
for (const message of messages) {
const text = normalizedUserText(message)
if (text) {
landedCounts.set(text, (landedCounts.get(text) ?? 0) + 1)
}
}
// Why: compare against the count captured before send; historical equal
// turns cannot clear a new echo, while duplicates land one occurrence each.
const next = current.filter(
(item) => (landedCounts.get(item.text.trim()) ?? 0) < item.expectedOccurrence
)
if (next.length === current.length) {
return previous
}
if (next.length > 0) {
return { ...previous, [pendingKey]: next }
}
const remaining = { ...previous }
delete remaining[pendingKey]
return remaining
})
}, [messages, pending, pendingKey])
return {
composerText: draftKey ? (drafts[draftKey] ?? '') : '',
setComposerText,
pending,
captureSendOrigin,
acceptSend
}
}
@@ -0,0 +1,165 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import { useMobileNativeChatFileSearch } from './use-mobile-native-chat-file-search'
type SearchState = ReturnType<typeof useMobileNativeChatFileSearch>
function rpcSuccess(files: string[]): Awaited<ReturnType<RpcClient['sendRequest']>> {
return {
id: 'files',
ok: true,
result: { files: files.map((relativePath) => ({ relativePath })) },
_meta: { runtimeId: 'runtime-1' }
}
}
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()
}
describe('useMobileNativeChatFileSearch', () => {
let renderer: ReactTestRenderer | null = null
let state: SearchState | null = null
async function mount(client: RpcClient): Promise<void> {
function Harness(): null {
state = useMobileNativeChatFileSearch({ client, worktreeId: 'wt-1' })
return null
}
const restore = suppressRendererWarning()
try {
await act(async () => {
renderer = create(createElement(Harness))
})
} finally {
restore()
}
}
beforeEach(() => {
vi.useFakeTimers()
globalThis.IS_REACT_ACT_ENVIRONMENT = true
state = null
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
vi.useRealTimers()
})
it('coalesces rapid queries and retains only the bounded host result', async () => {
const sendRequest = vi.fn().mockResolvedValue(rpcSuccess(['src/app.ts', 'src/app.test.ts']))
await mount({ sendRequest } as unknown as RpcClient)
act(() => {
state?.loadNativeChatFiles('a')
state?.loadNativeChatFiles('app')
})
await act(async () => vi.advanceTimersByTimeAsync(119))
expect(sendRequest).not.toHaveBeenCalled()
await act(async () => vi.advanceTimersByTimeAsync(1))
expect(sendRequest).toHaveBeenCalledTimes(1)
expect(sendRequest).toHaveBeenCalledWith('files.searchPaths', {
worktree: 'id:wt-1',
query: 'app',
limit: 16
})
expect(state?.nativeChatFilePaths).toEqual(['src/app.ts', 'src/app.test.ts'])
})
it('loads the legacy inventory once when an older host lacks searchPaths', async () => {
const sendRequest = vi.fn(async (method: string) => {
if (method === 'files.searchPaths') {
return {
id: 'missing',
ok: false as const,
error: { code: 'method_not_found', message: 'Unknown method' },
_meta: { runtimeId: 'runtime-1' }
}
}
return rpcSuccess(['src/apple.ts', 'docs/readme.md'])
})
await mount({ sendRequest } as unknown as RpcClient)
act(() => state?.loadNativeChatFiles('apple'))
await act(async () => vi.advanceTimersByTimeAsync(120))
expect(state?.nativeChatFilePaths).toEqual(['src/apple.ts'])
act(() => state?.loadNativeChatFiles('readme'))
await act(async () => vi.advanceTimersByTimeAsync(120))
expect(state?.nativeChatFilePaths).toEqual(['docs/readme.md'])
expect(sendRequest.mock.calls.map(([method]) => method)).toEqual([
'files.searchPaths',
'files.list'
])
})
it('cancels an in-flight query on a cache hit so a stale result cannot clobber it', async () => {
const sendRequest = vi.fn(async (_method: string, params: { query: string }) =>
rpcSuccess(params.query === 'app' ? ['src/app.ts'] : ['src/beta.ts'])
)
await mount({ sendRequest } as unknown as RpcClient)
// Populate the cache for 'app'.
act(() => state?.loadNativeChatFiles('app'))
await act(async () => vi.advanceTimersByTimeAsync(120))
expect(state?.nativeChatFilePaths).toEqual(['src/app.ts'])
// Schedule 'beta' (debounced, unresolved), then hit the cache for 'app'.
act(() => {
state?.loadNativeChatFiles('beta')
state?.loadNativeChatFiles('app')
})
expect(state?.nativeChatFilePaths).toEqual(['src/app.ts'])
// The cancelled 'beta' request must never fire and overwrite the cached result.
await act(async () => vi.advanceTimersByTimeAsync(120))
expect(state?.nativeChatFilePaths).toEqual(['src/app.ts'])
expect(
sendRequest.mock.calls.filter(([, params]) => (params as { query: string }).query === 'beta')
).toHaveLength(0)
})
it('coalesces overlapping legacy inventory requests on a slow host', async () => {
let resolveList: (value: Awaited<ReturnType<RpcClient['sendRequest']>>) => void = () => {}
const listResponse = new Promise<Awaited<ReturnType<RpcClient['sendRequest']>>>((resolve) => {
resolveList = resolve
})
const sendRequest = vi.fn((method: string) => {
if (method === 'files.searchPaths') {
return Promise.resolve({
id: 'missing',
ok: false as const,
error: { code: 'method_not_found', message: 'Unknown method' },
_meta: { runtimeId: 'runtime-1' }
})
}
return listResponse
})
await mount({ sendRequest } as unknown as RpcClient)
act(() => state?.loadNativeChatFiles('apple'))
await act(async () => vi.advanceTimersByTimeAsync(120))
act(() => state?.loadNativeChatFiles('readme'))
await act(async () => vi.advanceTimersByTimeAsync(120))
expect(sendRequest.mock.calls.filter(([method]) => method === 'files.list')).toHaveLength(1)
await act(async () => {
resolveList(rpcSuccess(['src/apple.ts', 'docs/readme.md']))
await Promise.resolve()
await Promise.resolve()
})
expect(state?.nativeChatFilePaths).toEqual(['docs/readme.md'])
})
})
@@ -0,0 +1,146 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { RpcClient } from '../transport/rpc-client'
import { rankSuggestions } from './mobile-native-chat-autocomplete'
const FILE_SEARCH_DEBOUNCE_MS = 120
const FILE_SEARCH_RESULT_LIMIT = 16
const FILE_SEARCH_QUERY_CACHE_LIMIT = 20
function extractPaths(result: unknown): string[] {
const files = (result as { files?: Array<{ relativePath?: string }> }).files ?? []
return files
.map((file) => file.relativePath ?? '')
.filter((path): path is string => path.length > 0)
}
/** Debounces current-host path searches, bounds the mobile result/cache, and
* falls back to the legacy one-time full list when paired to an older host. */
export function useMobileNativeChatFileSearch(args: {
client: RpcClient | null
worktreeId: string
}): { nativeChatFilePaths: string[]; loadNativeChatFiles: (query: string) => void } {
const { client, worktreeId } = args
const [nativeChatFilePaths, setNativeChatFilePaths] = useState<string[]>([])
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const sequenceRef = useRef(0)
const generationRef = useRef(0)
const queryCacheRef = useRef(new Map<string, string[]>())
const legacyPathsRef = useRef<string[] | null>(null)
const legacyLoadRef = useRef<Promise<string[] | null> | null>(null)
const searchSupportedRef = useRef<boolean | null>(null)
useEffect(() => {
sequenceRef.current++
generationRef.current++
queryCacheRef.current.clear()
legacyPathsRef.current = null
legacyLoadRef.current = null
searchSupportedRef.current = null
setNativeChatFilePaths([])
return () => {
if (timerRef.current) {
clearTimeout(timerRef.current)
timerRef.current = null
}
}
}, [client, worktreeId])
const loadNativeChatFiles = useCallback(
(query: string) => {
if (!client) {
return
}
const normalizedQuery = query.trim().toLowerCase().slice(0, 256)
const cached = queryCacheRef.current.get(normalizedQuery)
if (cached) {
// Why: cancel and stale-out any in-flight debounced query so an older
// request cannot later clobber this displayed cached result.
if (timerRef.current) {
clearTimeout(timerRef.current)
timerRef.current = null
}
sequenceRef.current++
setNativeChatFilePaths(cached)
return
}
if (timerRef.current) {
clearTimeout(timerRef.current)
}
const sequence = ++sequenceRef.current
const generation = generationRef.current
setNativeChatFilePaths([])
timerRef.current = setTimeout(() => {
timerRef.current = null
const applyPaths = (paths: string[]): void => {
if (sequenceRef.current !== sequence) {
return
}
queryCacheRef.current.set(normalizedQuery, paths)
while (queryCacheRef.current.size > FILE_SEARCH_QUERY_CACHE_LIMIT) {
const oldest = queryCacheRef.current.keys().next().value as string | undefined
if (!oldest) {
break
}
queryCacheRef.current.delete(oldest)
}
setNativeChatFilePaths(paths)
}
const loadLegacyPaths = async (): Promise<void> => {
if (!legacyPathsRef.current) {
if (!legacyLoadRef.current) {
const request = client
.sendRequest('files.list', { worktree: `id:${worktreeId}` })
.then((response) => {
if (!response.ok || generationRef.current !== generation) {
return null
}
const paths = extractPaths(response.result)
legacyPathsRef.current = paths
return paths
})
.finally(() => {
if (legacyLoadRef.current === request && !legacyPathsRef.current) {
legacyLoadRef.current = null
}
})
// Why: older hosts expose only the full inventory RPC; queries that
// overlap its slow local/SSH read must share one request.
legacyLoadRef.current = request
}
const paths = await legacyLoadRef.current
if (!paths) {
return
}
}
const legacyPaths = legacyPathsRef.current
if (legacyPaths) {
applyPaths(rankSuggestions(legacyPaths, normalizedQuery, FILE_SEARCH_RESULT_LIMIT))
}
}
void (async () => {
if (searchSupportedRef.current === false) {
await loadLegacyPaths()
return
}
const response = await client.sendRequest('files.searchPaths', {
worktree: `id:${worktreeId}`,
query: normalizedQuery,
limit: FILE_SEARCH_RESULT_LIMIT
})
if (response.ok) {
searchSupportedRef.current = true
applyPaths(extractPaths(response.result))
return
}
if (response.error.code === 'method_not_found') {
searchSupportedRef.current = false
await loadLegacyPaths()
}
})().catch(() => {})
}, FILE_SEARCH_DEBOUNCE_MS)
},
[client, worktreeId]
)
return { nativeChatFilePaths, loadNativeChatFiles }
}
@@ -0,0 +1,57 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useMobileNativeChatInputLease } from './use-mobile-native-chat-input-lease'
type Lease = ReturnType<typeof useMobileNativeChatInputLease>
describe('useMobileNativeChatInputLease', () => {
let renderer: ReactTestRenderer | null = null
let lease: Lease | null = null
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
lease = null
})
function Harness({ connected }: { connected: boolean }): null {
lease = useMobileNativeChatInputLease({ activeHandle: 'terminal', connected })
return null
}
it('unlocks only after acknowledgement and clears on disconnect', async () => {
const original = console.error
const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
return
}
original(...args)
})
try {
await act(async () => {
renderer = create(createElement(Harness, { connected: true }))
})
expect(lease?.ready).toBe(false)
expect(lease?.lockReason).toBe('waiting')
act(() => lease?.markReady('terminal'))
expect(lease?.ready).toBe(true)
expect(lease?.lockReason).toBeNull()
act(() => lease?.clear())
expect(lease?.ready).toBe(false)
act(() => lease?.markReady('terminal'))
expect(lease?.ready).toBe(true)
await act(async () => renderer?.update(createElement(Harness, { connected: false })))
expect(lease?.ready).toBe(false)
expect(lease?.lockReason).toBe('disconnected')
} finally {
consoleSpy.mockRestore()
}
})
})
@@ -0,0 +1,53 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { MobileNativeChatInputLockReason } from './MobileNativeChatView'
export function useMobileNativeChatInputLease(args: {
activeHandle: string | null
connected: boolean
}): {
ready: boolean
readyRef: { readonly current: boolean }
lockReason: MobileNativeChatInputLockReason | null
markReady: (handle: string) => void
clear: (handle?: string) => void
} {
const [readyHandles, setReadyHandles] = useState<Set<string>>(new Set())
const ready = args.activeHandle != null && readyHandles.has(args.activeHandle)
// Why: absence of an acknowledgement proves only that setup is still pending;
// the protocol does not report evidence that another client owns the floor.
const lockReason: MobileNativeChatInputLockReason | null = !args.connected
? 'disconnected'
: ready
? null
: 'waiting'
const readyRef = useRef(ready)
readyRef.current = ready
useEffect(() => {
if (!args.connected) {
setReadyHandles(new Set())
}
}, [args.connected])
const markReady = useCallback((handle: string) => {
setReadyHandles((current) => new Set(current).add(handle))
}, [])
const clear = useCallback((handle?: string) => {
setReadyHandles((current) => {
if (handle === undefined) {
return new Set()
}
if (!current.has(handle)) {
return current
}
const next = new Set(current)
next.delete(handle)
return next
})
}, [])
return {
ready,
readyRef,
lockReason,
markReady,
clear
}
}
@@ -0,0 +1,39 @@
import { useMemo, useRef, useState } from 'react'
import { Gesture } from 'react-native-gesture-handler'
import type { ComposedGesture } from 'react-native-gesture-handler'
import { clampFontScale } from './mobile-native-chat-message-text'
/** Pinch-to-zoom chat font. `fontScale` is the committed size; `pinchBase`
* anchors the live gesture so successive pinches compound rather than reset. */
export function useMobileNativeChatPinchGesture(): {
fontScale: number
pinchGesture: ComposedGesture
} {
const [fontScale, setFontScale] = useState(1)
const fontScaleRef = useRef(1)
fontScaleRef.current = fontScale
const pinchBase = useRef(1)
// Why: run the gesture callbacks on the JS thread (not a reanimated worklet) so
// they can touch React refs/state and clampFontScale directly — accessing those
// from the UI-thread worklet crashes the app.
// Compose the pinch with the list's native scroll as Simultaneous so a
// two-finger pinch is recognized even while the scroll view is active —
// otherwise the scroll grabs the gesture first and the pinch never fires.
const pinchGesture = useMemo(
() =>
Gesture.Simultaneous(
Gesture.Native(),
Gesture.Pinch()
.runOnJS(true)
.onStart(() => {
pinchBase.current = fontScaleRef.current
})
.onUpdate((e) => {
setFontScale(clampFontScale(pinchBase.current * e.scale))
})
),
[]
)
return { fontScale, pinchGesture }
}
@@ -0,0 +1,47 @@
import { useMemo } from 'react'
import type { AgentStatusEntry } from '../../../src/shared/agent-status-types'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import { extractPendingAsk, parseAskFromStatus } from './mobile-native-chat-ask'
import { detectAgentPermission, parseApprovalFromStatus } from './mobile-native-chat-permission'
import { parseAgentQuestion } from './mobile-native-chat-question'
export type MobileNativeChatPrompts = {
permission: ReturnType<typeof detectAgentPermission>
question: ReturnType<typeof parseAgentQuestion>
ask: ReturnType<typeof parseAskFromStatus>
}
/** Derives the prompt cards shown above the composer. */
export function useMobileNativeChatPrompts(args: {
enabled: boolean
status: AgentStatusEntry | null | undefined
messages: readonly NativeChatMessage[]
}): MobileNativeChatPrompts {
const { enabled, status, messages } = args
const blocked = status?.state === 'waiting' || status?.state === 'blocked'
const permission =
(blocked && status
? detectAgentPermission({
state: status.state,
lastAssistantMessage: status.lastAssistantMessage,
toolName: status.toolName,
toolInput: status.toolInput
})
: null) ?? parseApprovalFromStatus(status?.interactivePrompt)
const question =
blocked && status && !permission ? parseAgentQuestion(status.lastAssistantMessage ?? '') : null
const askFromStatus = useMemo(
() => parseAskFromStatus(status?.interactivePrompt, status?.toolName),
[status?.interactivePrompt, status?.toolName]
)
const askFromMessages = useMemo(
() => (askFromStatus ? null : extractPendingAsk(messages)),
[askFromStatus, messages]
)
return {
permission,
question,
ask: enabled ? (askFromStatus ?? askFromMessages) : null
}
}
@@ -0,0 +1,107 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import { useMobileNativeChatReadability } from './use-mobile-native-chat-readability'
describe('useMobileNativeChatReadability', () => {
let renderer: ReactTestRenderer | null = null
let readable = false
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
readable = false
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
})
async function mount(connectionId: string | null): Promise<void> {
const client = {
sendRequest: vi.fn().mockResolvedValue({
ok: true,
result: { repos: [{ id: 'repo', connectionId }] }
})
} as unknown as RpcClient
function Harness(): null {
readable = useMobileNativeChatReadability(client, 'repo::/worktree')
return null
}
const original = console.error
const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
return
}
original(...args)
})
try {
await act(async () => {
renderer = create(createElement(Harness))
await Promise.resolve()
})
} finally {
consoleSpy.mockRestore()
}
}
it('admits local and runtime-owned transcript hosts', async () => {
await mount(null)
expect(readable).toBe(true)
act(() => renderer?.unmount())
renderer = null
await mount('runtime-ssh-environment')
expect(readable).toBe(true)
})
it('fails closed for Model-A SSH transcript hosts', async () => {
await mount('model-a-ssh')
expect(readable).toBe(false)
})
it('fails closed immediately while a reused route resolves its new worktree', async () => {
let resolveNext: (response: unknown) => void = () => {}
const client = {
sendRequest: vi
.fn()
.mockResolvedValueOnce({
ok: true,
result: { repos: [{ id: 'local-repo', connectionId: null }] }
})
.mockImplementationOnce(() => new Promise((resolve) => (resolveNext = resolve)))
} as unknown as RpcClient
function Harness({ worktreeId }: { worktreeId: string }): null {
readable = useMobileNativeChatReadability(client, worktreeId)
return null
}
const original = console.error
const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
return
}
original(...args)
})
try {
await act(async () => {
renderer = create(createElement(Harness, { worktreeId: 'local-repo::/one' }))
await Promise.resolve()
})
expect(readable).toBe(true)
act(() => renderer?.update(createElement(Harness, { worktreeId: 'ssh-repo::/two' })))
expect(readable).toBe(false)
await act(async () => {
resolveNext({
ok: true,
result: { repos: [{ id: 'ssh-repo', connectionId: 'model-a-ssh' }] }
})
await Promise.resolve()
})
expect(readable).toBe(false)
} finally {
consoleSpy.mockRestore()
}
})
})
@@ -0,0 +1,53 @@
import { useEffect, useState } from 'react'
import type { RpcClient } from '../transport/rpc-client'
import { isMobileNativeChatTranscriptReadable } from './mobile-native-chat-eligibility'
import { getRepoIdFromMobileWorktreeId } from './mobile-session-route-helpers'
type RepoSummary = { id: string; connectionId?: string | null }
type ReadabilityState = { client: RpcClient | null; worktreeId: string; readable: boolean }
export function useMobileNativeChatReadability(
client: RpcClient | null,
worktreeId: string
): boolean {
const [state, setState] = useState<ReadabilityState>({
client: null,
worktreeId: '',
readable: false
})
useEffect(() => {
let active = true
if (!client) {
setState({ client, worktreeId, readable: false })
return
}
void client
.sendRequest('repo.list')
.then((response) => {
if (!active) {
return
}
const repos = response.ok
? ((response.result as { repos?: RepoSummary[] }).repos ?? [])
: []
const repoId = getRepoIdFromMobileWorktreeId(worktreeId)
const repo = repos.find((candidate) => candidate.id === repoId)
setState({
client,
worktreeId,
readable: repo ? isMobileNativeChatTranscriptReadable(repo.connectionId ?? null) : false
})
})
.catch(() => {
if (active) {
setState({ client, worktreeId, readable: false })
}
})
return () => {
active = false
}
}, [client, worktreeId])
// Why: route reuse renders before its new effect resolves; never expose the
// previous repo's readability under a different client/worktree key.
return state.client === client && state.worktreeId === worktreeId ? state.readable : false
}
@@ -0,0 +1,220 @@
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 type { RpcClient } from '../transport/rpc-client'
import {
useMobileNativeChatSession,
type MobileNativeChatSession
} from './use-mobile-native-chat-session'
function message(id: string): NativeChatMessage {
return {
id,
role: 'assistant',
blocks: [{ type: 'text', text: id }],
timestamp: 1,
source: 'transcript'
}
}
describe('useMobileNativeChatSession', () => {
let renderer: ReactTestRenderer | null = null
let state: MobileNativeChatSession | null = null
let emit: (frame: unknown) => void = () => {}
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
state = null
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
})
function Harness({ client }: { client: RpcClient | null }): null {
state = useMobileNativeChatSession({
client,
agent: 'claude',
sessionId: 'session',
transcriptPath: null
})
return null
}
async function mount(client: RpcClient): Promise<void> {
const original = console.error
const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
return
}
original(...args)
})
try {
await act(async () => {
renderer = create(createElement(Harness, { client }))
})
} finally {
consoleSpy.mockRestore()
}
}
it('drops an older-page response captured before transcript replacement', async () => {
let resolveEarlier: (response: unknown) => void = () => {}
const sendRequest = vi.fn(
() => new Promise((resolve) => (resolveEarlier = resolve))
) as unknown as RpcClient['sendRequest']
const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => {
emit = onData
onData({
type: 'snapshot',
messages: Array.from({ length: 40 }, (_unused, index) => message(`old-${index}`)),
hasMore: true,
beforeOffset: 100
})
return () => {}
})
await mount({ sendRequest, subscribe } as unknown as RpcClient)
act(() => state?.loadEarlier())
await act(async () => {
emit({
type: 'replacement',
messages: [message('replacement')],
hasMore: false,
beforeOffset: 0
})
})
await act(async () => {
resolveEarlier({
ok: true,
result: { messages: [message('stale-page')], hasMore: false, beforeOffset: 0 }
})
await Promise.resolve()
})
expect(state?.messages.map((entry) => entry.id)).toEqual(['replacement'])
expect(state?.loadingEarlier).toBe(false)
})
it('drops an older-page response after the client source disappears', async () => {
let resolveEarlier: (response: unknown) => void = () => {}
const sendRequest = vi.fn(
() => new Promise((resolve) => (resolveEarlier = resolve))
) as unknown as RpcClient['sendRequest']
const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => {
onData({
type: 'snapshot',
messages: Array.from({ length: 40 }, (_unused, index) => message(`old-${index}`)),
hasMore: true,
beforeOffset: 100
})
return () => {}
})
await mount({ sendRequest, subscribe } as unknown as RpcClient)
act(() => state?.loadEarlier())
await act(async () => renderer?.update(createElement(Harness, { client: null })))
await act(async () => {
resolveEarlier({ ok: true, result: { messages: [message('stale-page')] } })
await Promise.resolve()
})
expect(state?.messages).toEqual([])
expect(state?.status).toBe('idle')
expect(state?.loadingEarlier).toBe(false)
})
it.each(['replacement', 'snapshot'] as const)(
'can page again after an authoritative %s resets a maxed-out read window',
async (frameType) => {
const sendRequest = vi.fn().mockResolvedValue({
ok: true,
result: { messages: [message('older')], hasMore: true, beforeOffset: 50 }
})
const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => {
emit = onData
onData({
type: 'snapshot',
messages: Array.from({ length: 40 }, (_unused, index) => message(`old-${index}`)),
hasMore: true,
beforeOffset: 100
})
return () => {}
})
await mount({ sendRequest, subscribe } as unknown as RpcClient)
for (let page = 0; page < 33; page += 1) {
await act(async () => {
state?.loadEarlier()
await Promise.resolve()
})
}
const requestsAtCap = sendRequest.mock.calls.length
await act(async () =>
emit({
type: frameType,
messages: [message('authoritative')],
hasMore: true,
beforeOffset: 500
})
)
await act(async () => {
state?.loadEarlier()
await Promise.resolve()
})
expect(sendRequest).toHaveBeenCalledTimes(requestsAtCap + 1)
expect(sendRequest).toHaveBeenLastCalledWith('nativeChat.readSession', {
agent: 'claude',
sessionId: 'session',
limit: 60,
beforeOffset: 500
})
}
)
it('rejects a cursor page invalidated by live trim and retries with a growing tail', async () => {
let resolveCursorPage: (response: unknown) => void = () => {}
const sendRequest = vi
.fn()
.mockImplementationOnce(() => new Promise((resolve) => (resolveCursorPage = resolve)))
.mockResolvedValueOnce({
ok: true,
result: { messages: [message('fresh-growing-tail')], hasMore: false }
})
const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => {
emit = onData
onData({
type: 'snapshot',
messages: Array.from({ length: 40 }, (_unused, index) => message(`window-${index}`)),
hasMore: true,
beforeOffset: 100
})
return () => {}
})
await mount({ sendRequest, subscribe } as unknown as RpcClient)
act(() => state?.loadEarlier())
await act(async () => emit({ type: 'appended', messages: [message('live-trim')] }))
await act(async () => {
resolveCursorPage({
ok: true,
result: { messages: [message('stale-cursor-page')], hasMore: true, beforeOffset: 50 }
})
await Promise.resolve()
})
expect(state?.messages.map((entry) => entry.id)).not.toContain('stale-cursor-page')
await act(async () => {
state?.loadEarlier()
await Promise.resolve()
})
expect(sendRequest).toHaveBeenLastCalledWith('nativeChat.readSession', {
agent: 'claude',
sessionId: 'session',
limit: 100
})
expect(state?.messages.map((entry) => entry.id)).toEqual(['fresh-growing-tail'])
})
})
@@ -0,0 +1,219 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import { buildNativeChatSubscriptionId } from '../../../src/shared/native-chat-stream-unsubscribe'
import type { RpcClient } from '../transport/rpc-client'
import { createNativeChatMerger, replaceList } from './mobile-native-chat-merge'
import {
applyMobileNativeChatStreamFrame,
type MobileNativeChatStreamFrame
} from './mobile-native-chat-stream-frame'
export type MobileNativeChatStatus = 'idle' | 'loading' | 'waiting-session' | 'ready' | 'error'
export type MobileNativeChatSession = {
messages: NativeChatMessage[]
status: MobileNativeChatStatus
error?: string
/** True when an older page may exist (the last read filled the window). */
hasMore: boolean
/** Whether an older-history page is currently loading. */
loadingEarlier: boolean
/** Grow the window to page in older history. */
loadEarlier: () => void
}
// Small first page for a fast first paint; grows by a page as the user scrolls.
const INITIAL_LIMIT = 40
const PAGE = 60
const MAX_MESSAGES = 2000
type ReadSessionResult =
| { messages: NativeChatMessage[]; hasMore?: boolean; beforeOffset?: number }
| { error: string }
/** Subscribe to an agent's native-chat transcript over the paired connection.
* Reads a small recent window for a fast first paint, tails it for live turns,
* and pages in older history on demand. Read results replace the list (they are
* an ordered tail); live appends merge by id so order stays stable. */
export function useMobileNativeChatSession(args: {
client: RpcClient | null
agent: string | null
sessionId: string | null
transcriptPath: string | null
}): MobileNativeChatSession {
const { client, agent, sessionId, transcriptPath } = args
const [messages, setMessages] = useState<NativeChatMessage[]>([])
const [status, setStatus] = useState<MobileNativeChatStatus>('idle')
const [error, setError] = useState<string | undefined>(undefined)
const [hasMore, setHasMore] = useState(false)
const [loadingEarlier, setLoadingEarlier] = useState(false)
const loadingEarlierRef = useRef(false)
const beforeOffsetRef = useRef<number | null>(null)
// Stateful id-dedup merger: caches the id→index map so each live append frame
// costs O(incoming), not O(existing+incoming) (#18). `replaceList` resets the
// base (read / loadEarlier ordered tails); `applyAppend` folds live frames in.
const mergerRef = useRef(createNativeChatMerger())
const limitRef = useRef(INITIAL_LIMIT)
// Tracks the live session so a late loadEarlier resolve can detect a swap.
const sessionIdRef = useRef<string | null>(sessionId)
sessionIdRef.current = sessionId
const streamGenerationRef = useRef(0)
// Replace the base list (read results are an ordered tail). Resets the merger
// cache so the index is rebuilt once over the new base.
const setList = useCallback((next: readonly NativeChatMessage[]) => {
replaceList(mergerRef.current, next)
setMessages(mergerRef.current.list)
}, [])
useEffect(() => {
let cancelled = false
// Why: disconnect/agent/session loss must invalidate a page request before
// the early idle/waiting return can clear the visible source.
streamGenerationRef.current += 1
limitRef.current = INITIAL_LIMIT
loadingEarlierRef.current = false
setLoadingEarlier(false)
setList([])
setError(undefined)
setHasMore(false)
beforeOffsetRef.current = null
if (!client || !agent) {
setStatus('idle')
return
}
if (!sessionId) {
setStatus('waiting-session')
return
}
setStatus('loading')
const unsubscribe = client.subscribe(
'nativeChat.subscribe',
{
agent,
sessionId,
limit: limitRef.current,
subscriptionId: buildNativeChatSubscriptionId(agent, sessionId),
...(transcriptPath ? { transcriptPath } : {})
},
(raw) => {
if (cancelled) {
return
}
const frame = raw as MobileNativeChatStreamFrame
if (frame.type === 'replacement' || frame.type === 'snapshot') {
// Why: replacement and reconnect snapshots are authoritative windows;
// stale page limits/results must not constrain the fresh generation.
streamGenerationRef.current += 1
limitRef.current = INITIAL_LIMIT
loadingEarlierRef.current = false
setLoadingEarlier(false)
}
const replaceSnapshot = frame.type === 'snapshot'
const applied = applyMobileNativeChatStreamFrame({
merger: mergerRef.current,
frame,
limit: limitRef.current,
replaceSnapshot
})
if (applied.kind === 'ignored') {
return
}
if (applied.kind === 'error') {
setStatus('error')
setError(applied.error)
return
}
setMessages(applied.messages)
if (applied.hasMore != null) {
setHasMore(applied.hasMore)
}
if (applied.beforeOffset != null) {
beforeOffsetRef.current = applied.beforeOffset
}
if (applied.cursorInvalidated) {
// Fall back to a growing-tail read so history trimmed by live appends
// cannot leave a gap between the retained window and the old cursor.
streamGenerationRef.current += 1
loadingEarlierRef.current = false
setLoadingEarlier(false)
beforeOffsetRef.current = null
}
setStatus('ready')
}
)
return () => {
cancelled = true
unsubscribe()
}
}, [client, agent, sessionId, transcriptPath, setList])
const loadEarlier = useCallback(() => {
if (!client || !agent || !sessionId || loadingEarlierRef.current || !hasMore) {
return
}
// Capture the session this page belongs to; a swap underneath us must not
// apply this read's result onto the new session (mirrors desktop's guard).
const requestSessionId = sessionId
const requestGeneration = streamGenerationRef.current
const nextLimit = Math.min(limitRef.current + PAGE, MAX_MESSAGES)
const pageLimit = nextLimit - limitRef.current
if (pageLimit <= 0) {
setHasMore(false)
return
}
const beforeOffset = beforeOffsetRef.current
loadingEarlierRef.current = true
setLoadingEarlier(true)
void (async () => {
try {
const response = await client.sendRequest('nativeChat.readSession', {
agent,
sessionId,
limit: beforeOffset === null ? nextLimit : pageLimit,
...(beforeOffset === null ? {} : { beforeOffset }),
...(transcriptPath ? { transcriptPath } : {})
})
if (!response.ok) {
return
}
const result = response.result as ReadSessionResult
if ('error' in result) {
return
}
// Drop a stale resolve from a session that swapped underneath us.
if (
sessionIdRef.current !== requestSessionId ||
streamGenerationRef.current !== requestGeneration
) {
return
}
limitRef.current = nextLimit
if (beforeOffset !== null && result.beforeOffset != null) {
beforeOffsetRef.current = result.beforeOffset
setList([...result.messages, ...mergerRef.current.list])
setHasMore(
nextLimit < MAX_MESSAGES && (result.hasMore ?? result.messages.length >= pageLimit)
)
} else {
// Older runtimes ignore the cursor and return the growing tail.
setList(result.messages)
setHasMore(result.messages.length >= nextLimit)
}
} finally {
// A late page from a prior tab must not unlock the current tab's request.
if (
sessionIdRef.current === requestSessionId &&
streamGenerationRef.current === requestGeneration
) {
loadingEarlierRef.current = false
setLoadingEarlier(false)
}
}
})()
}, [client, agent, sessionId, transcriptPath, hasMore, setList])
return { messages, status, error, hasMore, loadingEarlier, loadEarlier }
}
@@ -0,0 +1,85 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import { useMobileNativeChatStop } from './use-mobile-native-chat-stop'
describe('useMobileNativeChatStop', () => {
let renderer: ReactTestRenderer | null = null
let stop: (() => void) | null = null
const sendRequest = vi.fn()
const onSendError = vi.fn()
beforeEach(() => {
vi.useFakeTimers()
globalThis.IS_REACT_ACT_ENVIRONMENT = true
sendRequest.mockReset().mockResolvedValue({ ok: true })
onSendError.mockReset()
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
stop = null
vi.useRealTimers()
})
function Harness({
enabled,
streamIdentity
}: {
enabled: boolean
streamIdentity: string
}): null {
stop = useMobileNativeChatStop({
client: { sendRequest } as unknown as RpcClient,
enabled,
handleRef: { current: 'terminal-1' },
deviceTokenRef: { current: 'mobile-1' },
streamIdentity,
cancelPending: vi.fn(),
onSendError
})
return null
}
async function render(enabled: boolean, streamIdentity: string): Promise<void> {
await act(async () => {
const element = createElement(Harness, { enabled, streamIdentity })
if (renderer) {
renderer.update(element)
} else {
renderer = create(element)
}
})
}
it.each([
['the acknowledged input lease is lost', false, 'stream-1'],
['the active stream changes', true, 'stream-2']
])('cancels the delayed second Escape when %s', async (_case, enabled, streamIdentity) => {
await render(true, 'stream-1')
act(() => stop?.())
expect(sendRequest).toHaveBeenCalledTimes(1)
await render(enabled as boolean, streamIdentity as string)
await act(async () => vi.runAllTimersAsync())
expect(sendRequest).toHaveBeenCalledTimes(1)
})
it('handles a rejected Escape without leaking an unhandled rejection', async () => {
sendRequest.mockRejectedValue(new Error('disconnected'))
await render(true, 'stream-1')
act(() => stop?.())
await act(async () => {
await Promise.resolve()
await vi.runAllTimersAsync()
})
expect(onSendError).toHaveBeenCalledOnce()
expect(onSendError).toHaveBeenCalledWith('Stop not sent')
})
})
@@ -0,0 +1,76 @@
import { useCallback, useEffect, useRef, type MutableRefObject } from 'react'
import type { RpcClient } from '../transport/rpc-client'
export function useMobileNativeChatStop(args: {
client: RpcClient | null
enabled: boolean
handleRef: MutableRefObject<string | null>
deviceTokenRef: MutableRefObject<string | null>
streamIdentity: string
cancelPending: () => void
onSendError: (message: string) => void
}): () => void {
const { client, enabled, handleRef, deviceTokenRef, streamIdentity, cancelPending, onSendError } =
args
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const activeRouteRef = useRef({ client, enabled, streamIdentity })
activeRouteRef.current = { client, enabled, streamIdentity }
useEffect(() => {
if (!enabled && timerRef.current) {
clearTimeout(timerRef.current)
timerRef.current = null
}
return () => {
if (timerRef.current) {
clearTimeout(timerRef.current)
timerRef.current = null
}
}
}, [client, enabled, streamIdentity])
return useCallback(() => {
const handle = handleRef.current
if (!client || !handle || !enabled) {
onSendError('Stop not sent (terminal not ready)')
return
}
cancelPending()
if (timerRef.current) {
clearTimeout(timerRef.current)
}
const stopStreamIdentity = streamIdentity
let failureReported = false
const sendEscape = (): void => {
const activeRoute = activeRouteRef.current
if (
!activeRoute.enabled ||
activeRoute.client !== client ||
activeRoute.streamIdentity !== stopStreamIdentity ||
handleRef.current !== handle
) {
return
}
void client
.sendRequest('terminal.send', {
terminal: handle,
text: String.fromCharCode(27),
...(deviceTokenRef.current
? { client: { id: deviceTokenRef.current, type: 'mobile' as const } }
: {})
})
.catch(() => {
// Why: disconnect can race either fire-and-forget Escape; surface one
// failure instead of leaking an unhandled RPC rejection.
if (!failureReported) {
failureReported = true
onSendError('Stop not sent')
}
})
}
sendEscape()
// Why: two paced Escape bytes reliably stop TUIs without remote coalescing.
timerRef.current = setTimeout(() => {
timerRef.current = null
sendEscape()
}, 80)
}, [cancelPending, client, deviceTokenRef, enabled, handleRef, onSendError, streamIdentity])
}

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