Merge remote-tracking branch 'origin/main' into brennanb2025/usage-display-redesign

# Conflicts:
#	src/renderer/src/components/status-bar/StatusBar.tsx
#	src/renderer/src/components/status-bar/tooltip.tsx
This commit is contained in:
Brennan Benson
2026-07-16 15:11:01 -07:00
801 changed files with 67332 additions and 8786 deletions
+5 -1
View File
@@ -75,8 +75,12 @@ jobs:
npm install -g node-gyp@11.5.0
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
- run: pnpm install --frozen-lockfile
# Why: without --config, bare vitest ignores config/vitest.config.ts (there
# is no root config) and falls back to the 5s default timeout with no
# Windows worker cap, so the real csc.exe launcher-compile tests time out
# on hosted Windows. Use the shared config so this job matches pnpm test.
- run: >-
pnpm vitest run
pnpm vitest run --config config/vitest.config.ts
config/scripts/build-windows-cli-launcher.test.mjs
src/main/ssh/ssh-remote-cli-launcher.test.ts
config/scripts/computer-e2e-workflow.test.mjs
+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
File diff suppressed because it is too large Load Diff
@@ -4,9 +4,14 @@ import { dirname, join, resolve } from 'node:path'
import { spawnSync } from 'node:child_process'
import { describe, expect, it } from 'vitest'
const itWindows = process.platform === 'win32' ? it : it.skip
const itCrossHost = process.platform === 'win32' ? it.skip : it
const projectRoot = resolve(import.meta.dirname, '../..')
// Why: cold csc.exe startup exceeds Vitest's 5s unit budget on hosted Windows;
// keep the larger allowance scoped to the real compiler integration test.
function itWindows(name, test) {
const runner = process.platform === 'win32' ? it : it.skip
runner(name, { timeout: 15_000 }, test)
}
describe('Windows CLI launcher', () => {
itCrossHost('fails closed when the Windows launcher cannot be compiled on this host', () => {
@@ -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)
})
})
@@ -0,0 +1,257 @@
import { execFile } from 'node:child_process'
import { existsSync } from 'node:fs'
import { mkdtemp, realpath, rm } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { promisify } from 'node:util'
import { build } from 'esbuild'
const ENTRY_PATH = resolve('out/main/parcel-watcher-process-entry.js')
const POOL_SOURCE = resolve('src/main/ipc/runtime-watcher-process-pool.ts')
const FAILURE_SOURCE = resolve('src/main/ipc/parcel-watcher-process-failure.ts')
const REGISTRY_SOURCE = resolve('src/main/ipc/parcel-watcher-child-registry.ts')
const MAX_CHILD_RSS_KIB = 128 * 1024
const MAX_CHILD_CPU_PERCENT = 50
const MAX_QUARANTINE_RSS_KIB = 512 * 1024
const MAX_QUARANTINE_CPU_PERCENT = 100
const PHYSICAL_CHILD_CAP = 8
const WAIT_TIMEOUT_MS = 15_000
const execFileAsync = promisify(execFile)
const require = createRequire(import.meta.url)
async function main() {
if (!existsSync(ENTRY_PATH)) {
throw new Error(`Missing ${ENTRY_PATH}; run pnpm run build:electron-vite first`)
}
const createdRoots = []
const exactPids = new Set()
let bundleDir
let pool
let result
try {
bundleDir = await mkdtemp(join(tmpdir(), 'orca-runtime-watcher-resource-'))
const { RuntimeWatcherProcessPool, WatcherProcessFailure, reserveWatcherChild } =
await loadProbe(bundleDir)
const roots = []
for (let index = 0; index < 5; index++) {
const createdRoot = await mkdtemp(join(tmpdir(), `orca-watcher-resource-${index}-`))
createdRoots.push(createdRoot)
roots.push(await realpath(createdRoot))
}
pool = new RuntimeWatcherProcessPool()
await Promise.all(roots.map((rootPath) => pool.subscribe(rootPath, () => undefined, {}, {})))
const healthyPids = activeChildPids(pool)
assertPidCount(healthyPids, 1, 'five healthy roots')
healthyPids.forEach((pid) => exactPids.add(pid))
const healthyResources = await sampleResources(healthyPids)
assertResourceBudget(healthyResources, 'healthy pool', {
maxTotalRssKiB: MAX_CHILD_RSS_KIB,
maxTotalCpuPercent: MAX_CHILD_CPU_PERCENT
})
assertRegistryCapacity(reserveWatcherChild, 7)
const sharedSlot = [...pool.activeSlots][0]
const failure = new WatcherProcessFailure(
'resource probe synthetic shared-shard fault',
'supervisor',
'supervisor_crash_fuse'
)
for (const record of sharedSlot.supervisor.records.values()) {
record.hooks.onTerminalError?.(failure)
}
await waitFor(() => !isPidAlive(healthyPids[0]), 'healthy child exit')
await Promise.all(
roots.slice(0, 4).map((rootPath) => pool.subscribe(rootPath, () => undefined, {}, {}))
)
const quarantinePids = activeChildPids(pool)
assertPidCount(quarantinePids, 4, 'four fault-quarantine roots')
quarantinePids.forEach((pid) => exactPids.add(pid))
const quarantineResources = await sampleResources(quarantinePids)
assertResourceBudget(quarantineResources, 'quarantine pool', {
maxTotalRssKiB: MAX_QUARANTINE_RSS_KIB,
maxTotalCpuPercent: MAX_QUARANTINE_CPU_PERCENT
})
// Four live quarantine children leave four reservations before the global
// eight-physical-child ceiling rejects the next launch.
assertRegistryCapacity(reserveWatcherChild, 4)
result = {
hostPid: process.pid,
healthy: summarizeResources(healthyResources),
quarantine: summarizeResources(quarantineResources),
physicalChildCap: PHYSICAL_CHILD_CAP,
budgets: {
maxChildRssKiB: MAX_CHILD_RSS_KIB,
maxChildCpuPercent: MAX_CHILD_CPU_PERCENT,
maxQuarantineRssKiB: MAX_QUARANTINE_RSS_KIB,
maxQuarantineCpuPercent: MAX_QUARANTINE_CPU_PERCENT,
maxPhysicalRssKiB: MAX_CHILD_RSS_KIB * PHYSICAL_CHILD_CAP,
maxPhysicalCpuPercent: MAX_CHILD_CPU_PERCENT * PHYSICAL_CHILD_CAP
},
exactPidCleanup: true
}
} finally {
pool?.dispose()
await Promise.all(
[...exactPids].map((pid) => waitFor(() => !isPidAlive(pid), `watcher PID ${pid} cleanup`))
)
await Promise.all([
...createdRoots.map((rootPath) => rm(rootPath, { recursive: true, force: true })),
bundleDir ? rm(bundleDir, { recursive: true, force: true }) : Promise.resolve()
])
}
console.log(JSON.stringify(result))
}
async function loadProbe(bundleDir) {
const outfile = join(bundleDir, 'watcher-resource-probe.cjs')
await build({
stdin: {
contents: [
`export { RuntimeWatcherProcessPool } from ${JSON.stringify(POOL_SOURCE)}`,
`export { WatcherProcessFailure } from ${JSON.stringify(FAILURE_SOURCE)}`,
`export { reserveWatcherChild } from ${JSON.stringify(REGISTRY_SOURCE)}`
].join('\n'),
resolveDir: process.cwd(),
sourcefile: 'runtime-file-watcher-resource-probe-entry.ts',
loader: 'ts'
},
bundle: true,
platform: 'node',
format: 'cjs',
outfile,
external: ['@parcel/watcher', 'electron'],
logLevel: 'silent'
})
return require(outfile)
}
function activeChildPids(pool) {
return [...pool.activeSlots]
.map((slot) => slot.supervisor.child?.pid)
.filter((pid) => Number.isInteger(pid))
}
function assertPidCount(pids, expected, label) {
if (new Set(pids).size !== expected || pids.some((pid) => !isPidAlive(pid))) {
throw new Error(
`${label} used ${new Set(pids).size} live watcher children; expected ${expected}`
)
}
}
function assertRegistryCapacity(reserveWatcherChild, availableReservations) {
const releases = []
try {
for (let index = 0; index < availableReservations; index++) {
const release = reserveWatcherChild()
if (!release) {
throw new Error(`Watcher child registry rejected reservation ${index + 1}`)
}
releases.push(release)
}
const unexpectedReservation = reserveWatcherChild()
if (unexpectedReservation) {
unexpectedReservation()
throw new Error('Watcher child registry exceeded its global physical-process cap')
}
} finally {
releases.forEach((release) => release())
}
}
async function sampleResources(pids) {
await new Promise((resolveDelay) => setTimeout(resolveDelay, 250))
return Promise.all(pids.map((pid) => sampleProcess(pid)))
}
async function sampleProcess(pid) {
if (process.platform === 'win32') {
const command =
`$p=Get-Process -Id ${pid};` +
`[pscustomobject]@{rssKiB=[math]::Round($p.WorkingSet64/1KB);cpuSeconds=$p.CPU}|ConvertTo-Json -Compress`
const first = JSON.parse(
(await execFileAsync('powershell.exe', ['-NoProfile', '-Command', command])).stdout.trim()
)
await new Promise((resolveDelay) => setTimeout(resolveDelay, 250))
const second = JSON.parse(
(await execFileAsync('powershell.exe', ['-NoProfile', '-Command', command])).stdout.trim()
)
return {
pid,
rssKiB: second.rssKiB,
cpuPercent: Math.max(0, (second.cpuSeconds - first.cpuSeconds) * 400)
}
}
const { stdout } = await execFileAsync('ps', ['-o', 'rss=', '-o', '%cpu=', '-p', String(pid)], {
env: { ...process.env, LC_ALL: 'C' }
})
const [rssKiB, cpuPercent] = stdout.trim().split(/\s+/).map(Number)
return { pid, rssKiB, cpuPercent }
}
function assertResourceBudget(resources, label, { maxTotalRssKiB, maxTotalCpuPercent }) {
for (const resource of resources) {
if (!Number.isFinite(resource.rssKiB) || resource.rssKiB <= 0) {
throw new Error(`Missing RSS evidence for watcher PID ${resource.pid}`)
}
if (resource.rssKiB > MAX_CHILD_RSS_KIB) {
throw new Error(
`Watcher PID ${resource.pid} RSS ${resource.rssKiB} KiB exceeded ${MAX_CHILD_RSS_KIB} KiB`
)
}
if (!Number.isFinite(resource.cpuPercent) || resource.cpuPercent < 0) {
throw new Error(`Missing CPU evidence for watcher PID ${resource.pid}`)
}
if (resource.cpuPercent > MAX_CHILD_CPU_PERCENT) {
throw new Error(
`Watcher PID ${resource.pid} CPU ${resource.cpuPercent}% exceeded ${MAX_CHILD_CPU_PERCENT}%`
)
}
}
const summary = summarizeResources(resources)
if (summary.totalRssKiB > maxTotalRssKiB) {
throw new Error(
`${label} RSS ${summary.totalRssKiB} KiB exceeded aggregate budget ${maxTotalRssKiB} KiB`
)
}
if (summary.sampledCpuPercent > maxTotalCpuPercent) {
throw new Error(
`${label} CPU ${summary.sampledCpuPercent}% exceeded aggregate budget ${maxTotalCpuPercent}%`
)
}
}
function summarizeResources(resources) {
return {
childCount: resources.length,
pids: resources.map(({ pid }) => pid),
totalRssKiB: resources.reduce((total, { rssKiB }) => total + rssKiB, 0),
sampledCpuPercent: resources.reduce((total, { cpuPercent }) => total + cpuPercent, 0)
}
}
function isPidAlive(pid) {
try {
process.kill(pid, 0)
return true
} catch {
return false
}
}
async function waitFor(predicate, label) {
const deadline = Date.now() + WAIT_TIMEOUT_MS
while (!predicate()) {
if (Date.now() >= deadline) {
throw new Error(`Timed out waiting for ${label}`)
}
await new Promise((resolveDelay) => setTimeout(resolveDelay, 25))
}
}
await main()
@@ -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

+1 -1
View File
@@ -17,7 +17,7 @@
<p align="center">
<strong>El orquestador de IA para desarrolladores 100x.</strong><br/>
Ejecuta Claude Code, OpenClaude, Codex u OpenCode en paralelo — cada uno en su propio worktree, supervisados desde un solo lugar.
Ejecuta Codex, Claude Code, OpenCode u Pi en paralelo — cada uno en su propio worktree, supervisados desde un solo lugar.
</p>
<h3 align="center"><a href="https://onorca.dev/download"><ins>Descargar Orca</ins></a></h3>
+1 -1
View File
@@ -17,7 +17,7 @@
<p align="center">
<strong>100x ビルダーのための AI オーケストレーター。</strong><br/>
Claude Code、OpenClaude、Codex、OpenCode を並べて実行 — それぞれを専用のワークツリーで動かし、1 か所で追跡できます。
Codex、Claude Code、OpenCode、Pi を並べて実行 — それぞれを専用のワークツリーで動かし、1 か所で追跡できます。
</p>
<h3 align="center"><a href="https://onorca.dev/download"><ins>Orca をダウンロード</ins></a></h3>
+3 -3
View File
@@ -17,7 +17,7 @@
<p align="center">
<strong>100x 빌더를 위한 AI 오케스트레이터.</strong><br/>
Claude Code, OpenClaude, Codex, OpenCode를 나란히 실행하세요 — 각 에이전트는 자체 worktree에서 실행되고 한곳에서 추적됩니다.
Codex, Claude Code, OpenCode, Pi를 나란히 실행하세요. — 각 에이전트는 자체 worktree에서 실행되고 한곳에서 추적됩니다.
</p>
<h3 align="center"><a href="https://onorca.dev/download"><ins>Orca 다운로드</ins></a></h3>
@@ -164,7 +164,7 @@ diff의 어느 줄에든 코멘트를 남기고 에이전트에게 바로 보내
- **[풍부한 리포지토리 미리보기](https://www.onorca.dev/docs/editing/markdown)** — Markdown, 이미지, PDF, 리포지토리 문서를 워크스페이스에서 미리 볼 수 있습니다.
- **[Computer Use](https://www.onorca.dev/docs/cli/computer-use)** — 워크플로에 실제 상호작용이 필요할 때 에이전트가 데스크톱 앱과 화면에 보이는 UI를 직접 조작하게 하세요.
- **[알림과 읽지 않음 상태](https://www.onorca.dev/docs/notifications)** — 에이전트가 완료되거나 주의가 필요할 때 알림을 받고, 스레드를 읽지 않음으로 표시해 나중에 다시 확인하세요.
- **그리고 훨씬 더 많은 기능** — 매일 출시하기 때문에 이 목록은 항상 뒤처져 있습니다. 진짜 기능 목록은 [체인지로그](https://github.com/stablyai/orca/releases)입니다.
- **그리고 훨씬 더 많은 기능** — 새로운 기능이 매일 출시되므로 이 목록은 늘 한 발 늦습니다. 진짜 기능은 [체인지로그](https://github.com/stablyai/orca/releases)에서 확인하세요.
---
@@ -237,7 +237,7 @@ yay -S stably-orca-bin
- **Twitter / X:** 업데이트와 공지는 **[@orca_build](https://x.com/orca_build)** 를 팔로우하세요.
- **피드백과 아이디어:** 우리는 빠르게 출시합니다. 필요한 기능이 있나요? [새 기능을 요청](https://github.com/stablyai/orca/issues)하세요.
- **개인정보 보호:** Orca가 수집하는 익명 사용 데이터와 수집 거부 방법은 [개인정보 및 텔레메트리 문서](https://www.onorca.dev/docs/telemetry)를 참고하세요.
- **응원하기:** 이 리포지토리에 [Star](https://github.com/stablyai/orca)를 눌러 매일의 릴리스를 따라와 주세요.
- **응원하기:** 이 리포지토리에 [Star](https://github.com/stablyai/orca)를 눌러 매일 공개되는 릴리스 소식을 확인해 주세요.
---
+3 -4
View File
@@ -17,7 +17,7 @@
<p align="center">
<strong>面向 100x 构建者的 AI 编排器。</strong><br/>
并排运行 Claude Code、OpenClaude、Codex 或 OpenCode — 每个都在自己的 worktree 中运行,并在一个地方统一跟踪。
并排运行 Codex、Claude Code、OpenCode 或 Pi — 每个都在自己的 worktree 中运行,并在一个地方统一跟踪。
</p>
<h3 align="center"><a href="https://onorca.dev/download"><ins>下载 Orca</ins></a></h3>
@@ -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 收集哪些匿名使用数据以及如何退出。
+1 -1
View File
@@ -35,7 +35,7 @@ authority.
| Capability | Preferred behavior | Compatibility behavior |
| ----------------------- | ------------------------------------------------- | --------------------------------------------------------------------------------------- |
| `worktree-list-z` | NUL-delimited worktree paths | Line-block parser for Git before `worktree list -z` |
| `worktree-list-z` | NUL-delimited worktree paths with `prunable` marks | Line-block parser for Git before `worktree list -z` (2.36); the `prunable`/`locked` annotations still parse on Git 2.31–2.35, and a path-existence probe restores `prunable` detection for Git before 2.31 |
| `rev-parse-path-format` | Absolute repo metadata paths | Resolve legacy relative output against the scanned repo |
| `for-each-ref-exclude` | Exclude remote HEAD before the output limit | Request extra refs, then filter remote HEAD in Orca |
| `merge-tree-write-tree` | Derive real-merge conflicts and no-op tree proofs | Omit the conflict summary and keep conservative branch cleanup behavior before Git 2.38 |
+39
View File
@@ -0,0 +1,39 @@
# Kerberos / GSSAPI SSH Authentication
Orca's ssh2-based SSH stack cannot speak `gssapi-with-mic` — the ssh2 library
has no GSSAPI userauth support, and adding it would mean forking ssh2's
protocol layer plus shipping the `kerberos` native module (MIT krb5 / Heimdal /
Windows SSPI) as a prebuilt Electron dependency on three platforms. Instead,
hosts that need Kerberos ride the existing **system OpenSSH transport** — the
same parallel transport already used for `ProxyCommand`/`ProxyJump` hosts —
which delegates GSSAPI, ticket lookup (`kinit` cache, Windows domain logon),
and credential delegation to the platform's own `ssh` binary on macOS, Linux,
and Windows (Win32-OpenSSH uses SSPI).
## Transport selection
Two tiers, deliberately asymmetric because RHEL-family distros ship
`GSSAPIAuthentication yes` in the global `/etc/ssh/ssh_config`, which makes
`ssh -G` report GSSAPI enabled for *every* host:
1. **Proactive** — a target with `gssapiAuthentication: true` (parsed from an
explicit `GSSAPIAuthentication yes` in the host's `~/.ssh/config` block, or
set on the target directly) tries the system-ssh probe first. If that fails
(e.g. no ticket), the connect falls through to the normal ssh2 key/agent
path where passphrase/password prompts remain available — OpenSSH semantics
allow other auth methods alongside GSSAPI.
2. **Auth-failure fallback** — when ssh2 exhausts key/agent auth and the
`ssh -G`-resolved config enables GSSAPI (`isGssapiSystemSshFallbackCandidate`
in `ssh-connection-utils.ts`), the connection retries over system ssh
*before* prompting the user for credentials. Kerberos-only hosts on
distro-default configs connect this way; hosts where keys work never leave
the ssh2 path.
Manual (non-ssh-config) targets flagged for GSSAPI get an explicit
`-o GSSAPIAuthentication=yes` in `system-ssh-args.ts`; config-backed targets
inherit the option from their own `Host` block since the system binary re-reads
ssh_config.
Both tiers work headless (`orca serve`): the system-ssh probe needs no
credential callbacks, and GSSAPI itself is non-interactive once a ticket
exists.
+30 -2
View File
@@ -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.
@@ -74,8 +90,20 @@ could invalidate intentional long-lived `worktree-sleep` checkpoints.
- Changing terminal output, snapshot, replay, query-response, or hidden-delivery
behavior.
- Killing processes that deliberately daemonize away from the terminal process
group. This contract covers the PTY and its attached foreground process tree.
- Killing processes that deliberately daemonize away from a plain user
terminal's process group (nohup-style survivors remain user intent there).
For **agent sessions**, close/kill additionally terminates the snapshotted
descendant tree — including detached-pgid children the PTY's SIGHUP cannot
reach — via `pty-descendant-termination.ts` (bounded fresh snapshot with
same-turn coalescing, SIGTERM, grace window, then identity-safe SIGKILL).
Completed process tables are never reused as signal targets, and identity
checks use C-locale timestamps from the source scan. Later requests start a
fresh same-turn-coalesced successor inside their own deadline instead of
waiting behind older scans. A session is marked as terminating before capture
and keeps request ownership through natural exit, so reattach, duplicate kill,
and graceful-to-immediate upgrade cannot race the snapshot; descendant signals
still require the exact root session/handle to be live. Windows and SSH-hosted
PTYs keep the previous foreground-tree contract for now.
- Changing agent-provider resume commands or permission flags.
- Making a UI close wait for a remote process to exit before the tab disappears.
- Replacing worktree sleep with tab close. Sleep remains resumable by design.
+382
View File
@@ -0,0 +1,382 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
View,
Text,
TextInput,
StyleSheet,
Pressable,
ActivityIndicator,
KeyboardAvoidingView,
Platform,
ScrollView
} from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useLocalSearchParams, useRouter } from 'expo-router'
import { ChevronLeft } from 'lucide-react-native'
import { colors, radii, spacing, typography } from '../../../src/theme/mobile-theme'
import { loadHosts, updateHostNameAndEndpoint } from '../../../src/transport/host-store'
import {
displayHostEndpoint,
endpointPort,
endpointScheme,
normalizeHostEndpoint
} from '../../../src/transport/host-endpoint'
import { useForceReconnect, usePrimeHosts } from '../../../src/transport/client-context'
import type { HostProfile } from '../../../src/transport/types'
export default function EditHostScreen() {
const router = useRouter()
const insets = useSafeAreaInsets()
const { hostId } = useLocalSearchParams<{ hostId: string }>()
const primeHosts = usePrimeHosts()
const forceReconnectHost = useForceReconnect()
const [host, setHost] = useState<HostProfile | null>(null)
const [loadError, setLoadError] = useState<string | null>(null)
const [name, setName] = useState('')
const [address, setAddress] = useState('')
const [saveError, setSaveError] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
// Why: setSaving is async, so a second trigger before the re-render could
// still read stale state and re-enter handleSave; the ref closes that race.
const savingRef = useRef(false)
const load = useCallback(async () => {
if (!hostId) {
setLoadError('Missing host.')
return
}
try {
const hosts = await loadHosts()
const found = hosts.find((h) => h.id === hostId) ?? null
if (!found) {
setLoadError('This host was removed from this phone.')
setHost(null)
return
}
setHost(found)
setName(found.name)
setAddress(displayHostEndpoint(found.endpoint))
setLoadError(null)
} catch (err) {
setLoadError(err instanceof Error ? err.message : 'Failed to load host.')
setHost(null)
}
}, [hostId])
useEffect(() => {
void load()
}, [load])
const fallbackPort = host ? endpointPort(host.endpoint) : undefined
const fallbackScheme = host ? endpointScheme(host.endpoint) : 'ws'
const normalizedEndpoint = useMemo(
() => normalizeHostEndpoint(address, { fallbackPort, fallbackScheme }),
[address, fallbackPort, fallbackScheme]
)
const nameTrimmed = name.trim()
const nameChanged = host != null && nameTrimmed.length > 0 && nameTrimmed !== host.name
const endpointChanged =
host != null && normalizedEndpoint.ok && normalizedEndpoint.endpoint !== host.endpoint
const canSave =
host != null &&
nameTrimmed.length > 0 &&
normalizedEndpoint.ok &&
(nameChanged || endpointChanged) &&
!saving
async function handleSave() {
if (!host || !hostId || savingRef.current) {
return
}
const nextName = name.trim()
if (!nextName) {
setSaveError('Enter a name.')
return
}
if (!normalizedEndpoint.ok) {
setSaveError(normalizedEndpoint.error)
return
}
const willRename = nextName !== host.name
const willUpdateEndpoint = normalizedEndpoint.endpoint !== host.endpoint
if (!willRename && !willUpdateEndpoint) {
router.back()
return
}
savingRef.current = true
setSaving(true)
setSaveError(null)
try {
// Why: a single mutateStoredHosts pass so name + endpoint commit
// atomically — a mid-save failure can never persist one without the
// other, and a host removed mid-edit throws instead of no-oping.
await updateHostNameAndEndpoint(host.id, {
...(willRename ? { name: nextName } : {}),
...(willUpdateEndpoint ? { endpoint: normalizedEndpoint.endpoint } : {})
})
} catch (err) {
setSaveError(err instanceof Error ? err.message : 'Failed to save host.')
savingRef.current = false
setSaving(false)
return
}
try {
// Why: the write already committed above; a re-prime failure here
// must not be reported as a save failure — the next loadHosts() call
// elsewhere in the app picks up the fresh state regardless.
const hosts = await loadHosts()
primeHosts(hosts)
} catch {
// best-effort re-prime; persisted data is unaffected
}
savingRef.current = false
setSaving(false)
router.back()
if (willUpdateEndpoint) {
// Why: reconnect is a follow-on side effect of a save that already
// committed — its failure or a hang must not be reported as a save
// failure or block navigating back.
void forceReconnectHost(host.id).catch(() => {})
}
}
return (
<View style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
<View style={styles.topRow}>
<Pressable
style={styles.backButton}
onPress={() => router.back()}
accessibilityRole="button"
accessibilityLabel="Back"
>
<ChevronLeft size={22} color={colors.textSecondary} />
</Pressable>
<Text style={styles.heading}>Edit host</Text>
<Pressable
style={({ pressed }) => [
styles.saveButton,
(!canSave || pressed) && styles.saveButtonDisabled
]}
onPress={() => void handleSave()}
disabled={!canSave}
accessibilityRole="button"
accessibilityLabel="Save host"
>
{saving ? (
<ActivityIndicator size="small" color={colors.bgBase} />
) : (
<Text style={styles.saveButtonText}>Save</Text>
)}
</Pressable>
</View>
{loadError ? (
<View style={styles.errorState}>
<Text style={styles.errorText}>{loadError}</Text>
<Pressable style={styles.secondaryButton} onPress={() => router.back()}>
<Text style={styles.secondaryButtonText}>Go back</Text>
</Pressable>
</View>
) : !host ? (
<View style={styles.loadingState}>
<ActivityIndicator color={colors.textSecondary} />
</View>
) : (
<KeyboardAvoidingView
style={styles.flex}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<ScrollView
contentContainerStyle={[styles.form, { paddingBottom: insets.bottom + spacing.xl }]}
keyboardShouldPersistTaps="handled"
>
<Text style={styles.help}>
Change the display name or connection address. Address edits only switch where this
phone connects — they do not re-pair. Use this when the same desktop is reachable at a
different IP (for example home LAN vs Tailscale).
</Text>
<Text style={styles.label}>Name</Text>
<TextInput
style={styles.input}
accessibilityLabel="Name"
value={name}
onChangeText={(value) => {
setName(value)
setSaveError(null)
}}
placeholder="Host name"
placeholderTextColor={colors.textMuted}
autoCapitalize="words"
autoCorrect={false}
returnKeyType="next"
/>
<Text style={styles.label}>Address</Text>
<TextInput
style={styles.input}
accessibilityLabel="Address"
value={address}
onChangeText={(value) => {
setAddress(value)
setSaveError(null)
}}
placeholder="192.168.1.10:6768"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
autoComplete="off"
keyboardType="url"
returnKeyType="done"
onSubmitEditing={() => {
if (canSave) {
void handleSave()
}
}}
/>
<Text style={styles.hint}>
Accepts IP, host:port, or ws:// / wss://. Missing port defaults to the current port
(or 6768).
</Text>
{normalizedEndpoint.ok ? (
<Text style={styles.preview} numberOfLines={2}>
Connects to {normalizedEndpoint.endpoint}
</Text>
) : address.trim().length > 0 ? (
<Text style={styles.previewError}>{normalizedEndpoint.error}</Text>
) : null}
{saveError ? <Text style={styles.errorText}>{saveError}</Text> : null}
</ScrollView>
</KeyboardAvoidingView>
)}
</View>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.bgBase
},
flex: {
flex: 1
},
topRow: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingBottom: spacing.md,
gap: spacing.sm
},
backButton: {
width: 36,
height: 36,
alignItems: 'center',
justifyContent: 'center'
},
heading: {
flex: 1,
color: colors.textPrimary,
fontSize: 20,
fontWeight: '700'
},
saveButton: {
minWidth: 64,
height: 34,
paddingHorizontal: spacing.md,
borderRadius: radii.button,
backgroundColor: colors.surfaceBright,
alignItems: 'center',
justifyContent: 'center'
},
saveButtonDisabled: {
opacity: 0.4
},
saveButtonText: {
color: colors.bgBase,
fontSize: typography.bodySize,
fontWeight: '600'
},
form: {
paddingHorizontal: spacing.lg,
gap: spacing.sm
},
help: {
color: colors.textSecondary,
fontSize: typography.bodySize,
lineHeight: 20,
marginBottom: spacing.sm
},
label: {
color: colors.textSecondary,
fontSize: typography.metaSize,
fontWeight: '500',
marginTop: spacing.sm,
textTransform: 'uppercase',
letterSpacing: 0.4
},
input: {
backgroundColor: colors.bgPanel,
borderWidth: 1,
borderColor: colors.borderSubtle,
borderRadius: radii.row,
color: colors.textPrimary,
fontSize: typography.bodySize,
paddingHorizontal: spacing.md,
paddingVertical: Platform.OS === 'ios' ? 12 : 10
},
hint: {
color: colors.textMuted,
fontSize: typography.metaSize,
lineHeight: 16
},
preview: {
marginTop: spacing.sm,
color: colors.textSecondary,
fontSize: typography.metaSize,
fontFamily: Platform.OS === 'ios' ? 'Menlo' : typography.monoFamily
},
previewError: {
marginTop: spacing.sm,
color: colors.statusRed,
fontSize: typography.bodySize
},
errorText: {
color: colors.statusRed,
fontSize: typography.bodySize,
marginTop: spacing.md
},
errorState: {
flex: 1,
paddingHorizontal: spacing.lg,
paddingTop: spacing.xl,
gap: spacing.md
},
loadingState: {
flex: 1,
alignItems: 'center',
justifyContent: 'center'
},
secondaryButton: {
alignSelf: 'flex-start',
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radii.button,
backgroundColor: colors.bgRaised
},
secondaryButtonText: {
color: colors.textPrimary,
fontSize: typography.bodySize,
fontWeight: '500'
}
})
+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
+1
View File
@@ -39,6 +39,7 @@ function HostStack({ animation }: { animation: 'none' | 'default' }) {
}}
>
<Stack.Screen name="[hostId]/index" options={{ title: 'Host' }} />
<Stack.Screen name="[hostId]/edit" options={{ title: 'Edit host' }} />
<Stack.Screen name="[hostId]/accounts" options={{ title: 'Accounts' }} />
<Stack.Screen name="[hostId]/tasks" options={{ title: 'Tasks' }} />
<Stack.Screen name="[hostId]/session/[worktreeId]" options={{ title: 'Terminal' }} />
+4 -28
View File
@@ -24,7 +24,7 @@ import {
UsageBar
} from '../src/components/AccountUsage'
import AsyncStorage from '@react-native-async-storage/async-storage'
import { loadHosts, renameHost } from '../src/transport/host-store'
import { loadHosts } from '../src/transport/host-store'
import { removeHostAndCloseClient } from '../src/transport/host-removal-lifecycle'
import { pickResumeWorktree } from '../src/worktree/resume-worktree'
import type { RpcClient } from '../src/transport/rpc-client'
@@ -42,7 +42,6 @@ import { triggerMediumImpact } from '../src/platform/haptics'
import { OrcaLogo } from '../src/components/OrcaLogo'
import { MobileHostCard } from '../src/components/MobileHostCard'
import { TaskProviderLogo } from '../src/components/TaskProviderLogo'
import { TextInputModal } from '../src/components/TextInputModal'
import { ActionSheetModal, type ActionSheetAction } from '../src/components/ActionSheetModal'
import { ConfirmModal } from '../src/components/ConfirmModal'
import { setCachedWorktrees, getCachedWorktrees } from '../src/cache/worktree-cache'
@@ -306,7 +305,6 @@ export default function HomeScreen() {
const { isWideLayout, contentMaxWidth } = useResponsiveLayout()
const [hosts, setHosts] = useState<HostProfile[]>([])
const [actionTarget, setActionTarget] = useState<HostProfile | null>(null)
const [renameTarget, setRenameTarget] = useState<HostProfile | null>(null)
const [confirmRemove, setConfirmRemove] = useState<HostProfile | null>(null)
const [hostStates, setHostStates] = useState<Record<string, ConnectionState>>({})
const [hostAttempts, setHostAttempts] = useState<Record<string, number>>({})
@@ -708,19 +706,6 @@ export default function HomeScreen() {
</Pressable>
)
async function handleRename(newName: string) {
if (!renameTarget) {
return
}
try {
await renameHost(renameTarget.id, newName)
setRenameTarget(null)
setHosts(await loadHosts())
} catch {
setRenameTarget(null)
}
}
async function handleRemove() {
if (!confirmRemove) {
return
@@ -1068,11 +1053,12 @@ export default function HomeScreen() {
})
}
items.push({
label: 'Rename',
label: 'Edit host',
icon: Edit3,
closeBeforePress: true,
onPress: () => {
setRenameTarget(host)
setActionTarget(null)
router.push(`/h/${host.id}/edit`)
}
})
items.push({
@@ -1088,16 +1074,6 @@ export default function HomeScreen() {
onClose={() => setActionTarget(null)}
/>
<TextInputModal
visible={renameTarget != null}
title="Rename Host"
message="Enter a new name for this host."
defaultValue={renameTarget?.name ?? ''}
placeholder="Host name"
onSubmit={(name) => void handleRename(name)}
onCancel={() => setRenameTarget(null)}
/>
<ConfirmModal
visible={confirmRemove != null}
title="Remove Host"
+9
View File
@@ -9,6 +9,7 @@ import {
type PreProfilePairingAttempt
} from '../src/transport/pre-profile-pairing-coordinator'
import type { ConnectionLogEntry } from '../src/transport/types'
import { useCloseHost } from '../src/transport/client-context'
import { colors, spacing, radii, typography } from '../src/theme/mobile-theme'
import { ConnectionLog } from '../src/components/ConnectionLog'
import { shouldPresentNotificationOptIn } from '../src/notifications/notification-opt-in-gate'
@@ -24,6 +25,7 @@ const PAIRING_OVERALL_TIMEOUT_MS = 25_000
export default function PairConfirmScreen() {
const router = useRouter()
const closeHost = useCloseHost()
const insets = useSafeAreaInsets()
const params = useLocalSearchParams<{ code?: string }>()
const [status, setStatus] = useState<Status>('awaiting-confirm')
@@ -104,6 +106,13 @@ export default function PairConfirmScreen() {
if (!mountedRef.current || !attemptIsCurrent) {
return
}
// Why: re-pairing the same desktop now reuses its existing host id
// (STA-1840 dedup), so a client cached under that id from an earlier
// pairing would keep the stale endpoint/relay. Close it so the
// destination screen opens a fresh client with the newly-paired
// profile — the removeHost() path already refreshes on re-pair, and a
// brand-new host has no cached entry so this is a no-op.
closeHost(hostId)
const showNotificationOptIn = await shouldPresentNotificationOptIn()
if (!mountedRef.current) {
return
+9
View File
@@ -18,6 +18,7 @@ import {
type PreProfilePairingAttempt
} from '../src/transport/pre-profile-pairing-coordinator'
import type { ConnectionLogEntry, PairingOffer } from '../src/transport/types'
import { useCloseHost } from '../src/transport/client-context'
import { colors, spacing, radii, typography } from '../src/theme/mobile-theme'
import { TextInputModal } from '../src/components/TextInputModal'
import { ConnectionLog } from '../src/components/ConnectionLog'
@@ -43,6 +44,7 @@ function Step({ number, text }: { number: number; text: string }) {
export default function PairScanScreen() {
const router = useRouter()
const closeHost = useCloseHost()
const insets = useSafeAreaInsets()
const [permission, requestPermission] = useCameraPermissions()
const [status, setStatus] = useState<'scanning' | 'connecting' | 'error'>('scanning')
@@ -148,6 +150,13 @@ export default function PairScanScreen() {
if (!mountedRef.current || !attemptIsCurrent) {
return
}
// Why: re-pairing the same desktop now reuses its existing host id
// (STA-1840 dedup), so a client cached under that id from an earlier
// pairing would keep the stale endpoint/relay. Close it so the
// destination screen opens a fresh client with the newly-paired
// profile — the removeHost() path already refreshes on re-pair, and a
// brand-new host has no cached entry so this is a no-op.
closeHost(hostId)
const showNotificationOptIn = await shouldPresentNotificationOptIn()
if (!mountedRef.current) {
return
+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,106 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import EditHostScreen from '../app/h/[hostId]/edit'
const dependencies = vi.hoisted(() => ({
back: vi.fn(),
forceReconnectHost: vi.fn(),
loadHosts: vi.fn(),
primeHosts: vi.fn(),
updateHostNameAndEndpoint: vi.fn()
}))
vi.mock('react-native', () => ({
ActivityIndicator: 'ActivityIndicator',
KeyboardAvoidingView: 'KeyboardAvoidingView',
Platform: { OS: 'ios' },
Pressable: 'Pressable',
ScrollView: 'ScrollView',
StyleSheet: { create: (styles: unknown) => styles },
Text: 'Text',
TextInput: 'TextInput',
View: 'View'
}))
vi.mock('react-native-safe-area-context', () => ({
useSafeAreaInsets: () => ({ bottom: 0, left: 0, right: 0, top: 0 })
}))
vi.mock('expo-router', () => ({
useLocalSearchParams: () => ({ hostId: 'host-1' }),
useRouter: () => ({ back: dependencies.back })
}))
vi.mock('lucide-react-native', () => ({
ChevronLeft: 'ChevronLeft'
}))
vi.mock('./transport/host-store', () => ({
loadHosts: dependencies.loadHosts,
updateHostNameAndEndpoint: dependencies.updateHostNameAndEndpoint
}))
vi.mock('./transport/client-context', () => ({
useForceReconnect: () => dependencies.forceReconnectHost,
usePrimeHosts: () => dependencies.primeHosts
}))
function suppressReactTestRendererDeprecationWarning(): () => void {
const originalConsoleError = console.error
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
const firstArg = args[0]
if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) {
return
}
originalConsoleError(...args)
})
return () => consoleErrorSpy.mockRestore()
}
async function renderEditHostRoute(): Promise<ReactTestRenderer> {
let renderer: ReactTestRenderer | null = null
const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
try {
await act(async () => {
renderer = create(createElement(EditHostScreen))
await Promise.resolve()
})
} finally {
restoreConsoleError()
}
if (!renderer) {
throw new Error('Edit host route did not render')
}
return renderer
}
describe('edit host route accessibility', () => {
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
dependencies.loadHosts.mockReset().mockResolvedValue([
{
id: 'host-1',
name: 'Desk',
endpoint: 'ws://192.168.1.10:6768',
deviceToken: 'token',
publicKeyB64: 'public-key',
lastConnected: 1
}
])
})
afterEach(() => {
vi.restoreAllMocks()
})
it('exposes stable accessible names for both editable fields', async () => {
const renderer = await renderEditHostRoute()
const inputs = renderer.root.findAllByType('TextInput')
expect(inputs).toHaveLength(2)
expect(inputs.map((input) => input.props.accessibilityLabel)).toEqual(['Name', 'Address'])
act(() => renderer.unmount())
})
})
+325
View File
@@ -0,0 +1,325 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import EditHostScreen from '../app/h/[hostId]/edit'
const dependencies = vi.hoisted(() => ({
back: vi.fn(),
forceReconnectHost: vi.fn(),
loadHosts: vi.fn(),
primeHosts: vi.fn(),
updateHostNameAndEndpoint: vi.fn(),
hostId: 'host-1' as string | undefined
}))
vi.mock('react-native', () => ({
ActivityIndicator: 'ActivityIndicator',
KeyboardAvoidingView: 'KeyboardAvoidingView',
Platform: { OS: 'ios' },
Pressable: 'Pressable',
ScrollView: 'ScrollView',
StyleSheet: { create: (styles: unknown) => styles },
Text: 'Text',
TextInput: 'TextInput',
View: 'View'
}))
vi.mock('react-native-safe-area-context', () => ({
useSafeAreaInsets: () => ({ bottom: 0, left: 0, right: 0, top: 0 })
}))
vi.mock('expo-router', () => ({
useLocalSearchParams: () => ({ hostId: dependencies.hostId }),
useRouter: () => ({ back: dependencies.back })
}))
vi.mock('lucide-react-native', () => ({
ChevronLeft: 'ChevronLeft'
}))
vi.mock('./transport/host-store', () => ({
loadHosts: dependencies.loadHosts,
updateHostNameAndEndpoint: dependencies.updateHostNameAndEndpoint
}))
vi.mock('./transport/client-context', () => ({
useForceReconnect: () => dependencies.forceReconnectHost,
usePrimeHosts: () => dependencies.primeHosts
}))
const HOST_FIXTURE = {
id: 'host-1',
name: 'Desk',
endpoint: 'ws://192.168.1.10:6768',
deviceToken: 'token',
publicKeyB64: 'public-key',
lastConnected: 1
}
function suppressReactTestRendererDeprecationWarning(): () => void {
const originalConsoleError = console.error
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
const firstArg = args[0]
if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) {
return
}
originalConsoleError(...args)
})
return () => consoleErrorSpy.mockRestore()
}
async function renderEditHostRoute(): Promise<ReactTestRenderer> {
let renderer: ReactTestRenderer | null = null
const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
try {
await act(async () => {
renderer = create(createElement(EditHostScreen))
await Promise.resolve()
})
} finally {
restoreConsoleError()
}
if (!renderer) {
throw new Error('Edit host route did not render')
}
return renderer
}
function setFieldValue(
renderer: ReactTestRenderer,
accessibilityLabel: 'Name' | 'Address',
value: string
): void {
const input = renderer.root
.findAllByType('TextInput')
.find((node) => node.props.accessibilityLabel === accessibilityLabel)
if (!input) {
throw new Error(`${accessibilityLabel} input not found`)
}
act(() => {
input.props.onChangeText(value)
})
}
function findSaveButton(renderer: ReactTestRenderer) {
const button = renderer.root
.findAllByType('Pressable')
.find((node) => node.props.accessibilityLabel === 'Save host')
if (!button) {
throw new Error('Save button not found')
}
return button
}
async function pressSave(renderer: ReactTestRenderer): Promise<void> {
const button = findSaveButton(renderer)
await act(async () => {
button.props.onPress()
await Promise.resolve()
await Promise.resolve()
})
}
function findText(renderer: ReactTestRenderer, match: string): boolean {
return renderer.root.findAllByType('Text').some((node) => {
const children = node.props.children
if (typeof children === 'string') {
return children.includes(match)
}
if (Array.isArray(children)) {
return children.join('').includes(match)
}
return false
})
}
describe('edit host handleSave', () => {
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
dependencies.hostId = 'host-1'
dependencies.back.mockReset()
dependencies.forceReconnectHost.mockReset().mockResolvedValue(undefined)
dependencies.loadHosts.mockReset().mockResolvedValue([HOST_FIXTURE])
dependencies.primeHosts.mockReset()
dependencies.updateHostNameAndEndpoint.mockReset().mockResolvedValue(undefined)
})
afterEach(() => {
vi.restoreAllMocks()
})
it('rename-only save updates only the name and does not reconnect', async () => {
const renderer = await renderEditHostRoute()
setFieldValue(renderer, 'Name', 'Home Desk')
await pressSave(renderer)
expect(dependencies.updateHostNameAndEndpoint).toHaveBeenCalledWith('host-1', {
name: 'Home Desk'
})
expect(dependencies.forceReconnectHost).not.toHaveBeenCalled()
expect(dependencies.back).toHaveBeenCalledTimes(1)
act(() => renderer.unmount())
})
it('endpoint-only save updates only the endpoint and reconnects', async () => {
const renderer = await renderEditHostRoute()
setFieldValue(renderer, 'Address', '192.168.1.20:6768')
await pressSave(renderer)
expect(dependencies.updateHostNameAndEndpoint).toHaveBeenCalledWith('host-1', {
endpoint: 'ws://192.168.1.20:6768'
})
expect(dependencies.forceReconnectHost).toHaveBeenCalledWith('host-1')
expect(dependencies.back).toHaveBeenCalledTimes(1)
act(() => renderer.unmount())
})
it('saves name and endpoint together in one call, then reconnects', async () => {
const renderer = await renderEditHostRoute()
setFieldValue(renderer, 'Name', 'Home Desk')
setFieldValue(renderer, 'Address', '192.168.1.20:6768')
await pressSave(renderer)
expect(dependencies.updateHostNameAndEndpoint).toHaveBeenCalledTimes(1)
expect(dependencies.updateHostNameAndEndpoint).toHaveBeenCalledWith('host-1', {
name: 'Home Desk',
endpoint: 'ws://192.168.1.20:6768'
})
expect(dependencies.forceReconnectHost).toHaveBeenCalledWith('host-1')
expect(dependencies.back).toHaveBeenCalledTimes(1)
act(() => renderer.unmount())
})
it('navigates back without saving or reconnecting when nothing changed', async () => {
const renderer = await renderEditHostRoute()
await pressSave(renderer)
expect(dependencies.updateHostNameAndEndpoint).not.toHaveBeenCalled()
expect(dependencies.forceReconnectHost).not.toHaveBeenCalled()
expect(dependencies.back).toHaveBeenCalledTimes(1)
act(() => renderer.unmount())
})
it('shows the error and does not navigate back or reconnect when the save rejects', async () => {
dependencies.updateHostNameAndEndpoint.mockRejectedValueOnce(new Error('Host not found'))
const renderer = await renderEditHostRoute()
setFieldValue(renderer, 'Name', 'Home Desk')
await pressSave(renderer)
expect(findText(renderer, 'Host not found')).toBe(true)
expect(dependencies.forceReconnectHost).not.toHaveBeenCalled()
expect(dependencies.back).not.toHaveBeenCalled()
act(() => renderer.unmount())
})
it('still navigates back when the post-save re-prime fails', async () => {
dependencies.loadHosts
.mockResolvedValueOnce([HOST_FIXTURE])
.mockRejectedValueOnce(new Error('boom'))
const renderer = await renderEditHostRoute()
setFieldValue(renderer, 'Name', 'Home Desk')
await pressSave(renderer)
expect(dependencies.primeHosts).not.toHaveBeenCalled()
expect(dependencies.back).toHaveBeenCalledTimes(1)
act(() => renderer.unmount())
})
it('still navigates back and shows no error when the post-save reconnect rejects', async () => {
dependencies.forceReconnectHost.mockRejectedValueOnce(new Error('connect failed'))
const renderer = await renderEditHostRoute()
setFieldValue(renderer, 'Address', '192.168.1.20:6768')
await pressSave(renderer)
await act(async () => {
await Promise.resolve()
await Promise.resolve()
})
expect(dependencies.forceReconnectHost).toHaveBeenCalledWith('host-1')
expect(dependencies.back).toHaveBeenCalledTimes(1)
expect(findText(renderer, 'connect failed')).toBe(false)
act(() => renderer.unmount())
})
it('ignores a second Save trigger while a save is already in flight', async () => {
let resolveSave: () => void = () => {}
dependencies.updateHostNameAndEndpoint.mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
resolveSave = resolve
})
)
const renderer = await renderEditHostRoute()
setFieldValue(renderer, 'Name', 'Home Desk')
const button = findSaveButton(renderer)
await act(async () => {
button.props.onPress()
button.props.onPress()
await Promise.resolve()
})
expect(dependencies.updateHostNameAndEndpoint).toHaveBeenCalledTimes(1)
await act(async () => {
resolveSave()
await Promise.resolve()
await Promise.resolve()
})
act(() => renderer.unmount())
})
})
describe('edit host load() error states', () => {
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
dependencies.hostId = 'host-1'
dependencies.back.mockReset()
dependencies.forceReconnectHost.mockReset().mockResolvedValue(undefined)
dependencies.loadHosts.mockReset().mockResolvedValue([HOST_FIXTURE])
dependencies.primeHosts.mockReset()
dependencies.updateHostNameAndEndpoint.mockReset().mockResolvedValue(undefined)
})
afterEach(() => {
vi.restoreAllMocks()
})
it('shows "Missing host." when hostId is absent', async () => {
dependencies.hostId = undefined
const renderer = await renderEditHostRoute()
expect(findText(renderer, 'Missing host.')).toBe(true)
expect(renderer.root.findAllByType('TextInput')).toHaveLength(0)
act(() => renderer.unmount())
})
it('shows a not-saved message when the host is not in the loaded list', async () => {
dependencies.loadHosts.mockReset().mockResolvedValue([])
const renderer = await renderEditHostRoute()
expect(findText(renderer, 'This host was removed from this phone.')).toBe(true)
expect(renderer.root.findAllByType('TextInput')).toHaveLength(0)
act(() => renderer.unmount())
})
it('surfaces the error message when loadHosts rejects', async () => {
dependencies.loadHosts.mockReset().mockRejectedValue(new Error('storage unreadable'))
const renderer = await renderEditHostRoute()
expect(findText(renderer, 'storage unreadable')).toBe(true)
expect(renderer.root.findAllByType('TextInput')).toHaveLength(0)
act(() => renderer.unmount())
})
})
@@ -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 }
}

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