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
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
@@ -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 & Ideas:** We ship fast. Missing something? [Request a new feature](https://github.com/stablyai/orca/issues).
|
||||
- **Privacy:** See the [privacy & telemetry docs](https://www.onorca.dev/docs/telemetry) for what anonymous usage data Orca collects and how to opt out.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 206 KiB |
|
After Width: | Height: | Size: 394 KiB |
|
Before Width: | Height: | Size: 434 KiB |
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)를 눌러 매일 공개되는 릴리스 소식을 확인해 주세요.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 收集哪些匿名使用数据以及如何退出。
|
||||
|
||||
@@ -35,7 +35,7 @@ authority.
|
||||
|
||||
| Capability | Preferred behavior | Compatibility behavior |
|
||||
| ----------------------- | ------------------------------------------------- | --------------------------------------------------------------------------------------- |
|
||||
| `worktree-list-z` | NUL-delimited worktree paths | Line-block parser for Git before `worktree list -z` |
|
||||
| `worktree-list-z` | NUL-delimited worktree paths with `prunable` marks | Line-block parser for Git before `worktree list -z` (2.36); the `prunable`/`locked` annotations still parse on Git 2.31–2.35, and a path-existence probe restores `prunable` detection for Git before 2.31 |
|
||||
| `rev-parse-path-format` | Absolute repo metadata paths | Resolve legacy relative output against the scanned repo |
|
||||
| `for-each-ref-exclude` | Exclude remote HEAD before the output limit | Request extra refs, then filter remote HEAD in Orca |
|
||||
| `merge-tree-write-tree` | Derive real-merge conflicts and no-op tree proofs | Omit the conflict summary and keep conservative branch cleanup behavior before Git 2.38 |
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# Kerberos / GSSAPI SSH Authentication
|
||||
|
||||
Orca's ssh2-based SSH stack cannot speak `gssapi-with-mic` — the ssh2 library
|
||||
has no GSSAPI userauth support, and adding it would mean forking ssh2's
|
||||
protocol layer plus shipping the `kerberos` native module (MIT krb5 / Heimdal /
|
||||
Windows SSPI) as a prebuilt Electron dependency on three platforms. Instead,
|
||||
hosts that need Kerberos ride the existing **system OpenSSH transport** — the
|
||||
same parallel transport already used for `ProxyCommand`/`ProxyJump` hosts —
|
||||
which delegates GSSAPI, ticket lookup (`kinit` cache, Windows domain logon),
|
||||
and credential delegation to the platform's own `ssh` binary on macOS, Linux,
|
||||
and Windows (Win32-OpenSSH uses SSPI).
|
||||
|
||||
## Transport selection
|
||||
|
||||
Two tiers, deliberately asymmetric because RHEL-family distros ship
|
||||
`GSSAPIAuthentication yes` in the global `/etc/ssh/ssh_config`, which makes
|
||||
`ssh -G` report GSSAPI enabled for *every* host:
|
||||
|
||||
1. **Proactive** — a target with `gssapiAuthentication: true` (parsed from an
|
||||
explicit `GSSAPIAuthentication yes` in the host's `~/.ssh/config` block, or
|
||||
set on the target directly) tries the system-ssh probe first. If that fails
|
||||
(e.g. no ticket), the connect falls through to the normal ssh2 key/agent
|
||||
path where passphrase/password prompts remain available — OpenSSH semantics
|
||||
allow other auth methods alongside GSSAPI.
|
||||
2. **Auth-failure fallback** — when ssh2 exhausts key/agent auth and the
|
||||
`ssh -G`-resolved config enables GSSAPI (`isGssapiSystemSshFallbackCandidate`
|
||||
in `ssh-connection-utils.ts`), the connection retries over system ssh
|
||||
*before* prompting the user for credentials. Kerberos-only hosts on
|
||||
distro-default configs connect this way; hosts where keys work never leave
|
||||
the ssh2 path.
|
||||
|
||||
Manual (non-ssh-config) targets flagged for GSSAPI get an explicit
|
||||
`-o GSSAPIAuthentication=yes` in `system-ssh-args.ts`; config-backed targets
|
||||
inherit the option from their own `Host` block since the system binary re-reads
|
||||
ssh_config.
|
||||
|
||||
Both tiers work headless (`orca serve`): the system-ssh probe needs no
|
||||
credential callbacks, and GSSAPI itself is non-interactive once a ticket
|
||||
exists.
|
||||
@@ -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.
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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' }} />
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||