Merge remote-tracking branch 'origin/main' into brennanb2025/fix-windows-remote-terminal

# Conflicts:
#	src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts
This commit is contained in:
Brennan Benson
2026-08-06 01:30:46 -07:00
572 changed files with 34845 additions and 6574 deletions
+6
View File
@@ -20,6 +20,7 @@ on:
- 'config/scripts/computer-use-smoke.mjs'
- 'config/scripts/computer-use-smoke.test.mjs'
- 'config/scripts/daemon-boot-smoke.mjs'
- 'config/scripts/daemon-endpoint-handover-smoke.mjs'
- 'config/scripts/windows-daemon-workspace-close-repro.mjs'
- 'config/scripts/verify-computer-native.mjs'
# Why: the native-smoke job boots the built terminal daemon under plain
@@ -135,6 +136,11 @@ jobs:
# the PR when the built daemon cannot start on ubuntu-22.04 / windows.
- name: Daemon boot smoke
run: node config/scripts/daemon-boot-smoke.mjs
# Why: a daemon that lost its endpoint name used to delete the replacement's
# socket on exit, stranding a live daemon that hosts PTYs nothing can reach.
# Only two real processes racing the same endpoint reproduce it.
- name: Daemon endpoint handover smoke
run: node config/scripts/daemon-endpoint-handover-smoke.mjs
# Why: workspace removal overlaps graceful renderer teardown with the
# forced main-process sweep; exercise that exact pair against real ConPTY.
- name: Windows daemon workspace-close repro
+1 -6
View File
@@ -150,10 +150,5 @@
}
}
],
"ignorePatterns": [
"**/node_modules",
"**/dist",
"**/out",
"tests/e2e/.cross-version-checkouts"
]
"ignorePatterns": ["**/node_modules", "**/dist", "**/out", "tests/e2e/.cross-version-checkouts"]
}
@@ -28,7 +28,10 @@ describe('computer-use modifier safety', () => {
)
expect(mouseInput).toContain('event.flags = flags')
expect(clickInput.match(/flags: flags/gu)).toHaveLength(3)
// Every click event flows through the shared delivery plan and carries
// the modifier flags on the mouse event itself.
expect(clickInput).toContain('SyntheticMouseClickDelivery.steps(clickCount: count)')
expect(clickInput).toContain('event.flags = flags')
expect(clickInput).not.toContain('down: true')
})
+105 -35
View File
@@ -18,7 +18,7 @@
import { fork } from 'node:child_process'
import { connect } from 'node:net'
import { randomUUID } from 'node:crypto'
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
@@ -54,30 +54,27 @@ function makeSocketPath(userDataDir) {
return join(userDataDir, 'daemon.sock')
}
// Best-effort end-to-end PTY check over the daemon's own control-socket RPC.
// Connects a single control socket, completes the hello handshake, and calls
// `ptySpawnHealth` (the daemon spawns a throwaway PTY internally). Resolves
// true on success, false on any failure — never throws.
function runPtySpawnHealthCheck(socketPath, tokenPath, protocolVersion) {
return new Promise((resolveCheck) => {
function runDaemonRpc(socketPath, tokenPath, protocolVersion, request, timeoutMs) {
return new Promise((resolveRpc, rejectRpc) => {
let settled = false
let buffer = ''
const socket = connect(socketPath)
const finish = (ok, reason) => {
const finish = (error, response) => {
if (settled) {
return
}
settled = true
clearTimeout(timer)
socket.destroy()
if (!ok && reason) {
log(`PTY spawn health check skipped (best-effort): ${reason}`)
if (error) {
rejectRpc(error)
} else {
resolveRpc(response)
}
resolveCheck(ok)
}
const timer = setTimeout(() => finish(false, 'timed out'), PTY_HEALTH_TIMEOUT_MS)
const timer = setTimeout(() => finish(new Error(`${request.type} timed out`)), timeoutMs)
socket.on('error', (err) => finish(false, err.message))
socket.on('error', (error) => finish(error))
socket.on('connect', () => {
const token = readFileSync(tokenPath, 'utf8').trim()
socket.write(
@@ -100,19 +97,19 @@ function runPtySpawnHealthCheck(socketPath, tokenPath, protocolVersion) {
try {
msg = JSON.parse(line)
} catch {
finish(false, 'invalid response line')
finish(new Error('invalid response line'))
return
}
if (msg.type === 'hello') {
if (!msg.ok) {
finish(false, `hello rejected: ${msg.error ?? 'unknown'}`)
finish(new Error(`hello rejected: ${msg.error ?? 'unknown'}`))
return
}
socket.write(`${JSON.stringify({ id: 'health-1', type: 'ptySpawnHealth' })}\n`)
} else if (msg.id === 'health-1') {
socket.write(`${JSON.stringify(request)}\n`)
} else if (msg.id === request.id) {
finish(
msg.ok === true,
msg.ok === true ? undefined : (msg.error ?? 'ptySpawnHealth failed')
msg.ok === true ? undefined : new Error(msg.error ?? `${request.type} failed`),
msg
)
return
}
@@ -122,28 +119,61 @@ function runPtySpawnHealthCheck(socketPath, tokenPath, protocolVersion) {
})
}
// Best-effort: constrained CI runners can make node-pty spawn flaky.
async function runPtySpawnHealthCheck(socketPath, tokenPath, protocolVersion) {
try {
await runDaemonRpc(
socketPath,
tokenPath,
protocolVersion,
{ id: 'health-1', type: 'ptySpawnHealth' },
PTY_HEALTH_TIMEOUT_MS
)
return true
} catch (error) {
log(`PTY spawn health check skipped (best-effort): ${error.message}`)
return false
}
}
async function main() {
const userDataDir = mkdtempSync(join(tmpdir(), 'orca-daemon-boot-smoke-'))
const socketPath = makeSocketPath(userDataDir)
const tokenPath = join(userDataDir, 'daemon.token')
const pidPath = join(userDataDir, 'daemon.pid')
const launchNonce = randomUUID()
const protocolVersion = readProtocolVersion()
log(`forking ${entryPath} under plain Node (${process.execPath})`)
const child = fork(entryPath, ['--socket', socketPath, '--token', tokenPath], {
// Plain Node: no ELECTRON_RUN_AS_NODE. process.execPath is already node in
// CI, and this is exactly the runtime where a leaked `require("electron")`
// throws MODULE_NOT_FOUND — the failure this smoke exists to catch.
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
env: { ...process.env, ORCA_USER_DATA_PATH: userDataDir }
})
const child = fork(
entryPath,
[
'--socket',
socketPath,
'--token',
tokenPath,
'--pid-record',
pidPath,
'--launch-nonce',
launchNonce,
'--entry-path',
entryPath,
'--app-version',
'daemon-boot-smoke'
],
{
// Plain Node: no ELECTRON_RUN_AS_NODE. process.execPath is already node in
// CI, and this is exactly the runtime where a leaked `require("electron")`
// throws MODULE_NOT_FOUND — the failure this smoke exists to catch.
stdio: ['ignore', 'ignore', 'pipe', 'ipc'],
env: { ...process.env, ORCA_USER_DATA_PATH: userDataDir }
}
)
let stderr = ''
child.stderr?.on('data', (chunk) => {
stderr += chunk.toString('utf8')
})
child.stdout?.on('data', (chunk) => {
process.stdout.write(chunk)
})
const cleanup = () => {
if (child.exitCode === null && child.signalCode === null && child.pid) {
@@ -185,6 +215,25 @@ async function main() {
})
})
log('daemon signaled ready')
const pidRecord = JSON.parse(readFileSync(pidPath, 'utf8'))
if (
pidRecord.pid !== child.pid ||
pidRecord.launchNonce !== launchNonce ||
pidRecord.entryPath !== entryPath ||
pidRecord.appVersion !== 'daemon-boot-smoke'
) {
throw new Error('daemon readiness did not publish the expected PID ownership record')
}
log('PID ownership record matches the ready daemon')
if (!existsSync(socketPath)) {
throw new Error('daemon did not publish its endpoint at the canonical socket path')
}
log('endpoint published at the canonical socket path')
// Production releases startup-only handles after ready; they can pin the child on Windows.
// Diagnostics past this point therefore carry the tail captured up to readiness only.
child.stderr?.destroy()
stderr += '[boot-smoke] stderr released at readiness, mirroring production\n'
child.disconnect()
const ptyHealthy = await runPtySpawnHealthCheck(socketPath, tokenPath, protocolVersion)
if (ptyHealthy) {
@@ -193,18 +242,39 @@ async function main() {
await new Promise((resolveExit, rejectExit) => {
const timer = setTimeout(() => {
rejectExit(new Error(`daemon did not exit within ${SHUTDOWN_TIMEOUT_MS}ms of SIGTERM`))
rejectExit(new Error(`daemon did not exit within ${SHUTDOWN_TIMEOUT_MS}ms of shutdown RPC`))
}, SHUTDOWN_TIMEOUT_MS)
child.on('exit', (code, signal) => {
clearTimeout(timer)
log(`daemon exited after signal (code=${code}, signal=${signal})`)
log(`daemon exited after shutdown RPC (code=${code}, signal=${signal})`)
resolveExit()
})
// Why: SIGTERM is the graceful stop on POSIX (the daemon handles it);
// Windows has no POSIX signal delivery, so Node maps this to process
// termination. Either way the hard assertion is "it stops, no hang".
child.kill('SIGTERM')
void runDaemonRpc(
socketPath,
tokenPath,
protocolVersion,
{
id: 'shutdown-1',
type: 'shutdown',
payload: { killSessions: false }
},
SHUTDOWN_TIMEOUT_MS
).catch((error) => {
clearTimeout(timer)
rejectExit(error)
})
})
if (existsSync(pidPath)) {
throw new Error('daemon left its PID ownership record behind after shutdown')
}
if (process.platform !== 'win32' && existsSync(socketPath)) {
throw new Error('daemon left its endpoint socket behind after shutdown')
}
// The private bind name is consumed by the publish; nothing may linger in the runtime dir.
const leaked = readdirSync(userDataDir).filter((entry) => entry.startsWith('.b'))
if (leaked.length > 0) {
throw new Error(`daemon leaked private bind names: ${leaked.join(', ')}`)
}
log('PASS: daemon booted, served, and shut down under plain Node')
} finally {
@@ -0,0 +1,164 @@
/**
* Endpoint handover smoke — guards the split-brain failure with real daemon processes.
*
* The failure it reproduces: a daemon whose endpoint name is reclaimed while it is still
* alive used to delete the *replacement's* socket when it finally exited, because libuv
* unlinks the pathname a server bound to with no ownership check. The replacement stayed
* alive hosting PTYs that nothing could reach — terminals that acknowledge input and never
* run it, and that a user cannot fix by restarting the app.
*
* Unix only: Windows named pipes are not directory entries, so the mechanism cannot occur.
*
* Usage: node config/scripts/daemon-endpoint-handover-smoke.mjs
*/
import { fork } from 'node:child_process'
import { connect } from 'node:net'
import { randomUUID } from 'node:crypto'
import { existsSync, mkdtempSync, rmSync, statSync, unlinkSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
const repoRoot = resolve(import.meta.dirname, '..', '..')
const entryPath = join(repoRoot, 'out', 'main', 'daemon-entry.js')
const READY_TIMEOUT_MS = 20_000
const EXIT_TIMEOUT_MS = 15_000
const log = (msg) => console.log(`[endpoint-handover-smoke] ${msg}`)
function bootDaemon(tag, dir, socketPath) {
const tokenPath = join(dir, `${tag}.token`)
const pidPath = join(dir, `${tag}.pid`)
const child = fork(
entryPath,
[
'--socket',
socketPath,
'--token',
tokenPath,
'--pid-record',
pidPath,
'--launch-nonce',
randomUUID(),
'--entry-path',
entryPath,
'--app-version',
'endpoint-handover-smoke'
],
{
stdio: ['ignore', 'ignore', 'pipe', 'ipc'],
env: { ...process.env, ORCA_USER_DATA_PATH: dir }
}
)
let stderr = ''
child.stderr?.on('data', (chunk) => {
stderr += chunk.toString('utf8')
})
return new Promise((resolveReady, rejectReady) => {
const timer = setTimeout(
() => rejectReady(new Error(`daemon ${tag} never signaled ready.\nstderr:\n${stderr}`)),
READY_TIMEOUT_MS
)
child.on('message', (msg) => {
if (msg && typeof msg === 'object' && msg.type === 'ready') {
clearTimeout(timer)
resolveReady({ child, tokenPath, pidPath })
}
})
child.on('exit', (code) => {
clearTimeout(timer)
rejectReady(new Error(`daemon ${tag} exited with ${code}.\nstderr:\n${stderr}`))
})
})
}
function isReachable(socketPath) {
return new Promise((resolveReachable) => {
const socket = connect({ path: socketPath })
socket.on('connect', () => {
socket.destroy()
resolveReachable(true)
})
socket.on('error', () => resolveReachable(false))
})
}
function killAndWait(child) {
if (child.exitCode !== null || child.signalCode !== null) {
return Promise.resolve()
}
return new Promise((resolveExit, rejectExit) => {
const timer = setTimeout(() => rejectExit(new Error('daemon did not exit')), EXIT_TIMEOUT_MS)
child.on('exit', () => {
clearTimeout(timer)
resolveExit()
})
child.kill('SIGTERM')
})
}
async function main() {
if (process.platform === 'win32') {
log('SKIP: named pipes are not filesystem entries, so endpoint handover cannot occur')
return
}
if (!existsSync(entryPath)) {
throw new Error(`missing ${entryPath} — run \`pnpm build\` first`)
}
const dir = mkdtempSync(join(tmpdir(), 'orca-endpoint-handover-'))
const socketPath = join(dir, 'daemon.sock')
let replaced
let replacement
try {
replaced = await bootDaemon('replaced', dir, socketPath)
const replacedInode = statSync(socketPath).ino
log('daemon A published the endpoint')
// Reclaim the endpoint name the way daemon replacement does, while A is still alive.
unlinkSync(socketPath)
replacement = await bootDaemon('replacement', dir, socketPath)
const replacementInode = statSync(socketPath).ino
if (replacedInode === replacementInode) {
throw new Error('daemon B did not publish a distinct endpoint')
}
if (!(await isReachable(socketPath))) {
throw new Error('daemon B is not reachable through the canonical endpoint')
}
log('daemon B took over the endpoint and is reachable')
// A exits long after losing the endpoint. This is the step that used to break B.
await killAndWait(replaced.child)
await new Promise((r) => setTimeout(r, 300))
if (!existsSync(socketPath) || statSync(socketPath).ino !== replacementInode) {
throw new Error("daemon A's late exit deleted daemon B's endpoint")
}
if (!(await isReachable(socketPath))) {
throw new Error('daemon B became unreachable after daemon A exited')
}
if (!existsSync(replacement.pidPath)) {
throw new Error("daemon A's late exit removed daemon B's ownership record")
}
if (existsSync(replaced.pidPath)) {
throw new Error('daemon A left its own ownership record behind')
}
log('PASS: the endpoint owner and the session host stayed the same daemon')
} finally {
for (const daemon of [replaced, replacement]) {
if (daemon && daemon.child.exitCode === null && daemon.child.signalCode === null) {
try {
daemon.child.kill('SIGKILL')
} catch {
// already gone
}
}
}
rmSync(dir, { recursive: true, force: true })
}
}
main().catch((error) => {
console.error(`[endpoint-handover-smoke] FAIL: ${error.message}`)
process.exit(1)
})
@@ -275,6 +275,16 @@ describe('orchestration skill guidance', () => {
)
})
it('documents per-invocation model and effort for supervised workers', () => {
const workerLoop = getSection(readSkill(), 'Preferred Supervised Worker Loop')
expect(workerLoop).toContain('opaque provider model id with `--model`')
expect(workerLoop).toContain('`--effort` requires `--model`')
expect(workerLoop).toContain('neither option can combine with `--terminal`')
expect(workerLoop).toContain('--agent claude --model aws-bedrock-opus-5 --effort high --json')
expect(workerLoop).toContain('`launch.requested` and `launch.effective`')
})
it('never authorizes release from idle, timeout, or worker-side triggers', () => {
const skill = readSkill()
const workerLoop = getSection(skill, 'Preferred Supervised Worker Loop')
+10 -12
View File
@@ -109,9 +109,7 @@ function waitForStdoutSentinel(proc, protocol, stderr) {
settled = true
proc.stdout.off('data', onData)
rejectPromise(
new Error(
`process exited before sentinel (code=${code}, signal=${signal})\n${stderr()}`
)
new Error(`process exited before sentinel (code=${code}, signal=${signal})\n${stderr()}`)
)
}
proc.stdout.on('data', onData)
@@ -162,11 +160,7 @@ function createRelayClient(entryPath, args, env, protocol) {
})
const waitForMessage = (startIndex, predicate, label) =>
pollUntil(
() => messages.slice(startIndex).find(predicate),
label,
streams.stderr
)
pollUntil(() => messages.slice(startIndex).find(predicate), label, streams.stderr)
const request = async (method, params = {}) => {
const id = nextSequence++
@@ -317,8 +311,10 @@ async function main() {
)
await relay.request('fs.watch', { rootPath: watchRoot })
const firstWatcherPid = await waitForWatcherPid(pidFile, undefined, () =>
`${daemonStreams.stderr()}\n${relay.stderr()}`
const firstWatcherPid = await waitForWatcherPid(
pidFile,
undefined,
() => `${daemonStreams.stderr()}\n${relay.stderr()}`
)
const beforePath = join(watchRoot, 'before.txt')
startIndex = relay.messageCount()
@@ -330,8 +326,10 @@ async function main() {
const faultSignal = process.platform === 'win32' ? 'SIGTERM' : 'SIGSEGV'
startIndex = relay.messageCount()
process.kill(firstWatcherPid, faultSignal)
const replacementWatcherPid = await waitForWatcherPid(pidFile, firstWatcherPid, () =>
`${daemonStreams.stderr()}\n${relay.stderr()}`
const replacementWatcherPid = await waitForWatcherPid(
pidFile,
firstWatcherPid,
() => `${daemonStreams.stderr()}\n${relay.stderr()}`
)
await relay.waitForNotification(startIndex, 'fs.changed', (params) =>
Array.isArray(params.events)
@@ -35,7 +35,6 @@ const RESTRICTED_ENV_KEYS = [
'HOMEPATH',
'CODEX_HOME',
'ORCA_CODEX_HOME',
'ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME',
'ORCA_E2E_HOME_DIR',
'ORCA_E2E_USER_DATA_DIR',
'ORCA_USER_DATA_PATH',
@@ -69,7 +68,7 @@ async function resolveRealPath(candidate) {
}
}
export function createValidationEnv(inheritedEnv, layout, options = {}) {
export function createValidationEnv(inheritedEnv, layout) {
const env = { ...inheritedEnv }
for (const key of RESTRICTED_ENV_KEYS) {
delete env[key]
@@ -81,12 +80,7 @@ export function createValidationEnv(inheritedEnv, layout, options = {}) {
NODE_ENV: 'development',
ORCA_E2E_HOME_DIR: layout.homeDir,
ORCA_E2E_USER_DATA_DIR: layout.userDataDir,
ORCA_USER_DATA_PATH: layout.userDataDir,
// Why: flag OFF pins every codex spawn to an explicit managed CODEX_HOME,
// so native codex never resolves the OS profile — the only Windows
// configuration where strict zero-event containment is reachable. It also
// exercises the emergency kill-switch lane users fall back to.
ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME: options.systemDefaultRealHome === 'off' ? '0' : '1'
ORCA_USER_DATA_PATH: layout.userDataDir
}
}
@@ -268,8 +262,7 @@ function parseArgs(argv) {
primaryHome: os.homedir(),
configTemplate: null,
tempParent: null,
laneAwareContainment: false,
systemDefaultRealHome: 'on'
laneAwareContainment: false
}
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index]
@@ -299,14 +292,8 @@ function parseArgs(argv) {
options.skipBuild = true
} else if (arg === '--keep') {
options.keep = true
} else if (arg === '--system-default-real-home') {
const value = readValue()
if (value !== 'on' && value !== 'off') {
throw new Error('--system-default-real-home must be "on" or "off"')
}
options.systemDefaultRealHome = value
} else if (arg === '--lane-aware-containment') {
// Why: on Windows the flag-ON system-default lane cannot be env-sandboxed
// Why: on Windows the system-default real-home lane cannot be env-sandboxed
// (native codex ignores USERPROFILE), so strict zero-event containment is
// structurally unreachable there. This mode records codex's designed
// volatile churn without aborting while every other real-home write stays
@@ -314,7 +301,7 @@ function parseArgs(argv) {
options.laneAwareContainment = true
} else if (arg === '--help') {
console.log(
'Usage: node config/scripts/run-codex-real-account-validation.mjs [--scenario mixed|managed-only|codex-lb] [--config-template <path>] [--temp-parent <dir>] [--skip-build] [--dry-run] [--close-after-launch] [--keep] [--lane-aware-containment] [--system-default-real-home on|off] [--report <path>]'
'Usage: node config/scripts/run-codex-real-account-validation.mjs [--scenario mixed|managed-only|codex-lb] [--config-template <path>] [--temp-parent <dir>] [--skip-build] [--dry-run] [--close-after-launch] [--keep] [--lane-aware-containment] [--report <path>]'
)
process.exit(0)
} else {
@@ -480,7 +467,7 @@ async function main() {
const reportPath =
options.reportPath ??
path.join(os.tmpdir(), `orca-codex-real-account-${options.scenario}-${Date.now()}.json`)
const launchEnv = createValidationEnv(process.env, layout, options)
const launchEnv = createValidationEnv(process.env, layout)
let app = null
let tripwire = null
const abortController = new AbortController()
@@ -46,29 +46,9 @@ describe('Codex real-account validation harness', () => {
expect(env.CODEX_HOME).toBeUndefined()
expect(env.ORCA_CODEX_HOME).toBeUndefined()
expect(env.ZDOTDIR).toBeUndefined()
expect(env.ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME).toBe('1')
expect(env.SAFE_VALUE).toBe('preserved')
})
it('pins the real-home flag off when the system-default lane is disabled', async () => {
const primaryHome = path.join(os.tmpdir(), 'orca-primary-home-sentinel')
const { layout, env } = runValidationModule<{
layout: { tempRoot: string }
env: Record<string, string | undefined>
}>(
`
const { createValidationEnv, createValidationLayout } = await import(process.argv[1])
const layout = await createValidationLayout({ primaryHome: process.argv[2] })
const env = createValidationEnv({}, layout, { systemDefaultRealHome: 'off' })
console.log(JSON.stringify({ layout, env }))
`,
[primaryHome]
)
cleanupPaths.push(layout.tempRoot)
expect(env.ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME).toBe('0')
})
it('records only fingerprints for system-default and managed auth', async () => {
const { layout, snapshot } = runValidationModule<{
layout: { tempRoot: string }
@@ -483,7 +483,6 @@ async function main() {
HOME: isolatedHome,
USERPROFILE: isolatedHome,
ORCA_E2E_HOME_DIR: isolatedHome,
ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME: '0',
...(options.headful ? { ORCA_E2E_HEADFUL: '1' } : { ORCA_E2E_HEADLESS: '1' })
}
})
@@ -55,7 +55,6 @@ Object.assign(childEnv, {
ORCA_DEV_USER_DATA_PATH: profileDir,
HOME: isolatedHome,
USERPROFILE: isolatedHome,
ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME: '0',
...(process.platform === 'linux'
? { ELECTRON_DISABLE_SANDBOX: process.env.ELECTRON_DISABLE_SANDBOX ?? '1' }
: {})
@@ -0,0 +1,274 @@
#!/usr/bin/env node
// Benchmark: the renderer-side scans that run over a whole reattach payload.
//
// 1. hasCursorAgentReattachPayloadScreenSignal — compares the old hand-rolled char-by-char
// CSI strip against the shipped shape (256KB tail + shared CSI_SEQUENCE_PATTERN). Every
// variant is asserted to agree with the baseline before it is timed.
// 2. TerminalKittyKeyboardModeTracker.scanReplay — measured to justify leaving it alone, and
// to record that porting the daemon mouse mirror's includes() pre-filter makes it slower.
//
// Payloads are generated deterministically (LCG, no Math.random) so runs compare.
//
// Run with: node config/scripts/terminal-reattach-payload-scan-benchmark.mjs
import { performance } from 'node:perf_hooks'
import { CSI_SEQUENCE_PATTERN } from '../../src/shared/ansi-escape-sequences.ts'
import { TerminalKittyKeyboardModeTracker } from '../../src/shared/terminal-kitty-keyboard-mode-tracker.ts'
const ROUNDS = Number(process.env.ORCA_REATTACH_SCAN_BENCH_ROUNDS ?? '7')
const MIN_ITERATION_MS = 120
// --- payload generation -----------------------------------------------------
function makeRng(seed) {
let state = seed >>> 0
return () => {
state = (Math.imul(state, 1664525) + 1013904223) >>> 0
return state / 0x100000000
}
}
const WORDS = [
'src',
'renderer',
'components',
'terminal',
'pane',
'connection',
'reattach',
'payload',
'snapshot',
'daemon',
'passed',
'failed',
'warning',
'building',
'index.ts',
'1.24s',
'ok'
]
// Heavily SGR-colored scrollback, ~120 cols/line — what a serialized daemon snapshot
// of a colorized TUI/build log looks like.
function buildColoredScrollback(targetBytes, seed) {
const rng = makeRng(seed)
const lines = []
let size = 0
while (size < targetBytes) {
let line = ''
let visible = 0
while (visible < 118) {
const word = WORDS[Math.floor(rng() * WORDS.length)]
const color = 16 + Math.floor(rng() * 200)
line += `\x1b[38;5;${color}m${word}\x1b[0m `
visible += word.length + 1
}
lines.push(line)
size += line.length + 1
}
return lines.join('\r\n')
}
// Live TUIs also emit cursor moves / erases; keep some of those in the mix.
function withCursorTraffic(body, seed) {
const rng = makeRng(seed)
return body
.split('\r\n')
.map((line, i) => `\x1b[${(i % 40) + 1};1H\x1b[K${line}${rng() < 0.1 ? '\x1b[?25l' : ''}`)
.join('\r\n')
}
const CURSOR_AGENT_SCREEN = [
'\x1b[38;5;39mCursor Agent\x1b[0m',
'',
' \x1b[2mReady\x1b[0m',
'',
'\x1b[38;5;245m→ \x1b[0m'
].join('\r\n')
function buildPayloads() {
const base200k = buildColoredScrollback(200 * 1024, 1)
const base2m = buildColoredScrollback(2 * 1024 * 1024, 2)
const relayTail = buildColoredScrollback(100 * 1024, 3)
return [
{ name: '200KB snapshot, header hit (tail)', data: `${base200k}\r\n${CURSOR_AGENT_SCREEN}` },
{ name: '200KB snapshot, no header (miss)', data: base200k },
{ name: '2MB snapshot, header hit (tail)', data: `${base2m}\r\n${CURSOR_AGENT_SCREEN}` },
{ name: '2MB snapshot, no header (miss)', data: base2m },
{ name: '100KB relay tail, cursor traffic', data: withCursorTraffic(relayTail, 4) }
]
}
// --- strip variants ---------------------------------------------------------
// Baseline: verbatim copy of pty-connection.ts:448 (not exported).
function stripBaseline(data) {
let normalized = ''
let index = 0
while (index < data.length) {
if (data.charCodeAt(index) === 0x1b && data[index + 1] === '[') {
index += 2
while (index < data.length) {
const code = data.charCodeAt(index)
index += 1
if (code >= 0x40 && code <= 0x7e) {
break
}
}
continue
}
normalized += data[index]
index += 1
}
return normalized
}
const stripRegex = (data) => data.replace(CSI_SEQUENCE_PATTERN, '')
const HEADER = 'Cursor Agent'
const TAIL_CHARS = 5000
// Mirrors CURSOR_AGENT_REATTACH_SCAN_TAIL_LIMIT_CHARS in pty-connection.ts.
const SUFFIX_RAW_CHARS = 256 * 1024
const signalBaseline = (data) => hasSignal(stripBaseline(data))
const signalRegex = (data) => hasSignal(stripRegex(data))
// The shipped shape.
const signalSuffixRegex = (data) =>
hasSignal(stripRegex(data.length > SUFFIX_RAW_CHARS ? data.slice(-SUFFIX_RAW_CHARS) : data))
function hasSignal(normalized) {
const headerIndex = normalized.lastIndexOf(HEADER)
if (headerIndex === -1) {
return false
}
return normalized.slice(headerIndex + HEADER.length, headerIndex + TAIL_CHARS).includes(`${'→'} `)
}
// --- timing -----------------------------------------------------------------
function median(values) {
const sorted = [...values].sort((a, b) => a - b)
const mid = sorted.length >> 1
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2
}
function timeMsPerCall(fn, arg) {
let iterations = 1
while (true) {
const start = performance.now()
for (let i = 0; i < iterations; i++) {
globalThis.__sink = fn(arg)
}
const elapsed = performance.now() - start
if (elapsed >= MIN_ITERATION_MS || iterations >= 1 << 22) {
return elapsed / iterations
}
iterations *= Math.max(2, Math.ceil(MIN_ITERATION_MS / Math.max(elapsed, 0.01)))
}
}
function measure(fn, arg) {
timeMsPerCall(fn, arg) // warmup / JIT
const samples = []
for (let round = 0; round < ROUNDS; round++) {
samples.push(timeMsPerCall(fn, arg))
}
return median(samples)
}
const pad = (value, width) => String(value).padStart(width)
const kb = (data) => `${(data.length / 1024).toFixed(0)}KB`
// --- run --------------------------------------------------------------------
const payloads = buildPayloads()
console.log('Reattach payload scans, ms/call (median of %d rounds). Lower is better.\n', ROUNDS)
console.log('== hasCursorAgentReattachPayloadScreenSignal ==')
console.log(
`${pad('payload', 36)} ${pad('size', 8)} ${pad('char-loop', 11)} ${pad('regex', 10)} ${pad('suffix+regex', 13)} ${pad('speedup', 9)}`
)
for (const { name, data } of payloads) {
const expected = signalBaseline(data)
for (const [label, fn] of Object.entries({
regex: signalRegex,
'suffix+regex': signalSuffixRegex
})) {
if (fn(data) !== expected) {
throw new Error(`${label} disagrees with baseline on "${name}"`)
}
}
const base = measure(signalBaseline, data)
const re = measure(signalRegex, data)
const suffix = measure(signalSuffixRegex, data)
console.log(
`${pad(name, 36)} ${pad(kb(data), 8)} ${pad(base.toFixed(3), 11)} ${pad(re.toFixed(3), 10)} ${pad(suffix.toFixed(3), 13)} ${pad(`${(base / suffix).toFixed(0)}x`, 9)}`
)
}
console.log('\n== TerminalKittyKeyboardModeTracker.scanReplay ==')
console.log(
`${pad('payload', 36)} ${pad('size', 8)} ${pad('scanReplay', 11)} ${pad('+includes gate', 15)}`
)
const kittyScan = (data) => {
const tracker = new TerminalKittyKeyboardModeTracker()
tracker.scanReplay(data)
return tracker.flags
}
// Mirrors src/main/daemon/terminal-mouse-mode-mirror.ts:41-47.
const kittyGated = (data) => {
if (!data.includes('\x1b[?') && !data.includes('\x1bc') && !data.includes('\x9b')) {
return 0
}
return kittyScan(data)
}
for (const { name, data } of payloads) {
console.log(
`${pad(name, 36)} ${pad(kb(data), 8)} ${pad(measure(kittyScan, data).toFixed(3), 11)} ${pad(measure(kittyGated, data).toFixed(3), 15)}`
)
}
// The gate can only pay off where no introducer exists: live plain-output chunks, not
// snapshots (which carry ?1049h/?25l by construction).
const liveChunk = buildColoredScrollback(4 * 1024, 5).replaceAll('\x1b', '')
const introducerRows = [
['4KB live chunk, no escapes', liveChunk],
['4KB live chunk, \\x1b[?25l present', `${liveChunk}\x1b[?25l`]
]
console.log(
`\n${pad('payload', 36)} ${pad('size', 8)} ${pad('scanReplay', 11)} ${pad('+includes gate', 15)}`
)
for (const [name, data] of introducerRows) {
console.log(
`${pad(name, 36)} ${pad(kb(data), 8)} ${pad(measure(kittyScan, data).toFixed(3), 11)} ${pad(measure(kittyGated, data).toFixed(3), 15)}`
)
}
console.log(
'\nSnapshot/replay payloads always contain \\x1b[?, so the includes() gate never fires on\nthe reattach path — it only helps the live per-chunk path.'
)
// Reference point: xterm parses the same bytes right after these scans run, so its cost
// is the yardstick for whether the scans are worth cutting.
const xtermHeadless = await import('@xterm/headless')
const Terminal = xtermHeadless.Terminal ?? xtermHeadless.default.Terminal
const writeToXterm = (data) =>
new Promise((resolve) => {
const terminal = new Terminal({ cols: 120, rows: 40, scrollback: 5000, allowProposedApi: true })
const start = performance.now()
terminal.write(data, () => {
const elapsed = performance.now() - start
terminal.dispose()
resolve(elapsed)
})
})
console.log(`\n== xterm headless parse of the same payload (reference) ==`)
console.log(`${pad('payload', 36)} ${pad('size', 8)} ${pad('xterm write', 11)}`)
for (const { name, data } of payloads) {
await writeToXterm(data)
const samples = []
for (let round = 0; round < ROUNDS; round++) {
samples.push(await writeToXterm(data))
}
console.log(`${pad(name, 36)} ${pad(kb(data), 8)} ${pad(median(samples).toFixed(3), 11)}`)
}
@@ -219,7 +219,6 @@ async function runValidation(mode) {
ORCA_DEV_USER_DATA_PATH: userDataPath,
HOME: isolatedHome,
USERPROFILE: isolatedHome,
ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME: '0',
ELECTRON_ENABLE_LOGGING: '1',
ELECTRON_ENABLE_STACK_DUMPING: '1',
ELECTRON_OZONE_PLATFORM_HINT: 'wayland',
@@ -63,7 +63,6 @@ export function launchDevApp({ cdpPort, userDataDir }) {
ORCA_DEV_USER_DATA_PATH: userDataDir,
HOME: isolatedHome,
USERPROFILE: isolatedHome,
ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME: '0',
ORCA_SKIP_DEV_WEB_PREPARE: '1',
ORCA_STARTUP_DIAGNOSTICS: '1',
REMOTE_DEBUGGING_PORT: String(cdpPort),
+1
View File
@@ -63,6 +63,7 @@
"../src/main/droid/hook-service.ts",
"../src/main/gemini/hook-service.ts",
"../src/main/grok/hook-service.ts",
"../src/main/grok/windows-grok-hook-script.ts",
"../src/main/devin/hook-settings.ts",
"../src/main/devin/hook-service.ts",
"../src/main/devin/hook-config-json.ts",
+2 -2
View File
@@ -2,7 +2,7 @@
"expo": {
"name": "Orca",
"slug": "orca-mobile",
"version": "0.0.37",
"version": "0.0.41",
"orientation": "default",
"icon": "./assets/icon.png",
"userInterfaceStyle": "automatic",
@@ -75,7 +75,7 @@
"allowBackup": false,
"permissions": ["RECORD_AUDIO", "MODIFY_AUDIO_SETTINGS"],
"package": "com.stably.orca.mobile",
"versionCode": 10
"versionCode": 11
},
"plugins": [
"expo-router",
+6 -3
View File
@@ -10,7 +10,7 @@ import { OrcaLogo } from '../src/components/OrcaLogo'
import { RpcClientProvider } from '../src/transport/client-context'
import { getNotificationNavigationTarget } from '../src/notifications/notification-routing'
import { useOpenNotificationRoute } from '../src/notifications/use-open-notification-route'
import { loadHosts } from '../src/transport/host-store'
import { loadHostCatalog } from '../src/transport/host-store'
import { extractPairingCodeFromUrl } from '../src/transport/pairing'
import { recoverMobileRelayPairing } from '../src/transport/mobile-relay-pairing-recovery'
@@ -95,9 +95,12 @@ export default function RootLayout() {
}
async function getNavigationTarget(data: unknown) {
const hosts = await loadHosts().catch(() => null)
const hosts = await loadHostCatalog().catch(() => null)
return getNotificationNavigationTarget(data, {
knownHostIds: hosts ? new Set(hosts.map((host) => host.id)) : undefined
knownHostIds: hosts ? new Set(hosts.map((host) => host.id)) : undefined,
credentialStatusByHostId: hosts
? new Map(hosts.map((host) => [host.id, host.credentialStatus]))
: undefined
})
}
@@ -4986,6 +4986,7 @@ export default function SessionScreen() {
<MobileTerminalLiveInputStatus
dictation={dictation}
isAttaching={isAttaching}
liveInputText={liveInputCapture}
/>
</Pressable>
<MobileTerminalInputActions
+73 -20
View File
@@ -15,7 +15,8 @@ import {
UsageBar
} from '../src/components/AccountUsage'
import AsyncStorage from '@react-native-async-storage/async-storage'
import { loadHosts } from '../src/transport/host-store'
import { loadHostCatalog } from '../src/transport/host-store'
import { selectConnectableHostProfiles } from '../src/transport/host-catalog-selection'
import { useOpenMobileHostEdit } from '../src/transport/use-open-mobile-host-edit'
import { removeHostAndCloseClient } from '../src/transport/host-removal-lifecycle'
import { fetchHomeHostWorktreeInfo } from '../src/worktree/home-host-worktree-fetch'
@@ -26,13 +27,17 @@ import { createHostConnectRefetchGate } from '../src/transport/host-connect-refe
import { sendSingleFlightRequest } from '../src/transport/request-single-flight'
import { useCloseHost, useForceReconnect, usePrimeHosts } from '../src/transport/client-context'
import { useAllHostClients } from '../src/transport/use-all-host-clients'
import {
resolveHomeHostConnectionState,
selectHomeAutoConnectHostIds
} from '../src/transport/home-host-auto-connect'
import { classifyConnection } from '../src/transport/connection-health'
import { subscribeToDesktopNotifications } from '../src/notifications/mobile-notifications'
import {
loadMobileOnboardingSteps,
mobileOnboardingDestination
} from '../src/onboarding/mobile-onboarding-plan'
import type { ConnectionState, HostProfile } from '../src/transport/types'
import type { ConnectionState, HostCatalogEntry, HostProfile } from '../src/transport/types'
import { triggerMediumImpact } from '../src/platform/haptics'
import { OrcaLogo } from '../src/components/OrcaLogo'
import { MobileHostCard } from '../src/components/MobileHostCard'
@@ -224,9 +229,9 @@ export default function HomeScreen() {
const insets = useSafeAreaInsets()
// Why: cap/center content on wide/tablet canvases so cards don't stretch edge-to-edge on iPad.
const { isWideLayout, contentMaxWidth } = useResponsiveLayout()
const [hosts, setHosts] = useState<HostProfile[]>([])
const [hostCatalog, setHostCatalog] = useState<HostCatalogEntry[]>([])
const [actionTarget, setActionTarget] = useState<HostProfile | null>(null)
const [confirmRemove, setConfirmRemove] = useState<HostProfile | null>(null)
const [confirmRemove, setConfirmRemove] = useState<{ id: string; name: string } | null>(null)
const [hostStates, setHostStates] = useState<Record<string, ConnectionState>>({})
const [hostAttempts, setHostAttempts] = useState<Record<string, number>>({})
const [hostLastConnected, setHostLastConnected] = useState<Record<string, number | null>>({})
@@ -242,10 +247,15 @@ export default function HomeScreen() {
const onboardingOptInCheckedRef = useRef(false)
// Why: shared clients from the per-host store, not N independent WebSockets. See docs/mobile-shared-client-per-host.md.
const hosts = useMemo(() => selectConnectableHostProfiles(hostCatalog), [hostCatalog])
const hostIds = useMemo(() => hosts.map((h) => h.id), [hosts])
// Why: scoped to the paired hosts so an unpaired desktop's cached reply leaves the header total.
const stats = useMemo(() => totalHomeStats(statsByHost, hostIds), [statsByHost, hostIds])
const allClients = useAllHostClients(hostIds)
const autoConnectHostIds = useMemo(() => selectHomeAutoConnectHostIds(hosts), [hosts])
const allClients = useAllHostClients(hostIds, {
autoConnectHostIds,
closeUnusedOnRelease: true
})
const hostPaths = useMemo(
() => Object.fromEntries(allClients.map(({ hostId, path }) => [hostId, path])),
[allClients]
@@ -308,12 +318,12 @@ export default function HomeScreen() {
useFocusEffect(
useCallback(() => {
let stale = false
void loadHosts().then(async (h) => {
void loadHostCatalog().then(async (catalog) => {
if (stale) {
return
}
setHosts(h)
if (h.length === 0 || onboardingOptInCheckedRef.current) {
setHostCatalog(catalog)
if (catalog.length === 0 || onboardingOptInCheckedRef.current) {
return
}
onboardingOptInCheckedRef.current = true
@@ -351,6 +361,10 @@ export default function HomeScreen() {
() => [...hosts].sort((a, b) => b.lastConnected - a.lastConnected),
[hosts]
)
const sortedHostCatalog = useMemo(
() => [...hostCatalog].sort((a, b) => b.lastConnected - a.lastConnected),
[hostCatalog]
)
// Why: mirror per-host connection state into hostStates so existing render code (status dots) keeps working.
useEffect(() => {
@@ -389,17 +403,24 @@ export default function HomeScreen() {
}
}
// Why: reflect hosts that dropped from allClients, but only if already tracked — else the initial-acquire frame flips all to 'disconnected'.
for (const host of hosts) {
for (const host of hostCatalog) {
if (liveIds.has(host.id)) {
continue
}
if (!host.publicKeyB64 || !host.deviceToken) {
if (host.credentialStatus === 'missing') {
if (next[host.id] !== 'auth-failed') {
next[host.id] = 'auth-failed'
changed = true
}
continue
}
if (host.credentialStatus === 'temporarily-unavailable') {
if (next[host.id] !== 'disconnected') {
next[host.id] = 'disconnected'
changed = true
}
continue
}
const prevState = next[host.id]
if (prevState && prevState !== 'disconnected' && prevState !== 'auth-failed') {
next[host.id] = 'disconnected'
@@ -408,14 +429,14 @@ export default function HomeScreen() {
}
// Drop entries for hosts we no longer track at all.
for (const id of Object.keys(next)) {
if (!liveIds.has(id) && hosts.some((h) => h.id === id) === false) {
if (!liveIds.has(id) && hostCatalog.some((h) => h.id === id) === false) {
delete next[id]
changed = true
}
}
return changed ? next : prev
})
}, [allClients, hosts])
}, [allClients, hostCatalog])
// Notif/accounts subs + a snapshot read per connect for one host. Lives outside the effect body
// because react-doctor's effect-needs-cleanup false-positives on `subscribe` inside one; the
@@ -629,7 +650,7 @@ export default function HomeScreen() {
try {
await removeHostAndCloseClient(hostToRemove.id, closeHostClient)
setConfirmRemove(null)
setHosts(await loadHosts())
setHostCatalog(await loadHostCatalog())
} catch {
// Why: ConfirmModal closes on confirm; re-open for retry so the failure isn't silent.
setConfirmRemove(hostToRemove)
@@ -655,7 +676,7 @@ export default function HomeScreen() {
</Pressable>
</View>
{hosts.length === 0 ? (
{hostCatalog.length === 0 ? (
/* ─── Empty state: onboarding ─── */
<View
style={[
@@ -694,7 +715,7 @@ export default function HomeScreen() {
) : (
/* ─── Populated state ─── */
<FlatList
data={sortedHosts}
data={sortedHostCatalog}
keyExtractor={(h) => h.id}
// Why: reserve insets.bottom so the last row stays reachable above the system nav bar / home indicator.
contentContainerStyle={[
@@ -732,7 +753,11 @@ export default function HomeScreen() {
}
ItemSeparatorComponent={CardGap}
renderItem={({ item }) => {
const state = hostStates[item.id] ?? 'connecting'
const state = resolveHomeHostConnectionState(
item.id,
hostStates[item.id],
autoConnectHostIds
)
const attempts = hostAttempts[item.id] ?? 0
const lastConnectedAt = hostLastConnected[item.id] ?? null
const verdict = classifyConnection({
@@ -744,14 +769,36 @@ export default function HomeScreen() {
return (
<MobileHostCard
host={item}
credentialStatus={item.credentialStatus}
state={state}
verdict={verdict}
path={hostPaths[item.id] ?? 'lan'}
worktreeInfo={worktreeInfo[item.id]}
onPress={() => router.push(`/h/${item.id}`)}
onPress={() => {
if (item.credentialStatus === 'missing') {
router.push('/pair-scan')
} else if (item.credentialStatus === 'temporarily-unavailable') {
void loadHostCatalog()
.then(setHostCatalog)
.catch(() => Alert.alert('Could not check pairing', 'Please try again.'))
} else {
router.push(`/h/${item.id}`)
}
}}
onLongPress={() => {
triggerMediumImpact()
setActionTarget(item)
if (item.profile) {
setActionTarget(item.profile)
} else {
setConfirmRemove(item)
}
}}
onOpenActions={() => {
if (item.profile) {
setActionTarget(item.profile)
} else {
setConfirmRemove(item)
}
}}
/>
)
@@ -920,7 +967,13 @@ export default function HomeScreen() {
message={actionTarget ? endpointLabel(actionTarget.endpoint) : undefined}
actions={getHostListActionSheetActions({
host: actionTarget,
state: actionTarget ? (hostStates[actionTarget.id] ?? 'connecting') : 'disconnected',
state: actionTarget
? resolveHomeHostConnectionState(
actionTarget.id,
hostStates[actionTarget.id],
autoConnectHostIds
)
: 'disconnected',
hasEverConnected: actionTarget
? (hostLastConnected[actionTarget.id] ?? null) != null
: false,
@@ -928,7 +981,7 @@ export default function HomeScreen() {
onReconnect: (hostId) => void forceReconnectHost(hostId),
onDisconnect: closeHostClient,
onEdit: openMobileHostEdit,
onRemove: setConfirmRemove
onRemove: (host) => setConfirmRemove(host)
})}
onClose={() => setActionTarget(null)}
/>
+2 -2
View File
@@ -12,7 +12,7 @@ import { ChevronLeft, ChevronRight, Smartphone, Type } from 'lucide-react-native
import { colors, radii, spacing, typography } from '../src/theme/mobile-theme'
import { loadHosts } from '../src/transport/host-store'
import type { HostProfile } from '../src/transport/types'
import { useAllHostClients } from '../src/transport/use-all-host-clients'
import { useFocusedSettingsHostClients } from '../src/transport/settings-host-client-connections'
import type { RpcClient } from '../src/transport/rpc-client'
import { PickerModal, type PickerOption } from '../src/components/PickerModal'
import { TerminalShortcutSettings } from '../src/components/TerminalShortcutSettings'
@@ -127,7 +127,7 @@ export default function TerminalSettingsScreen() {
void loadHosts().then(setHosts)
}, [])
const hostIds = useMemo(() => hosts.map((h) => h.id), [hosts])
const hostClients = useAllHostClients(hostIds)
const { clients: hostClients } = useFocusedSettingsHostClients(hostIds)
const hostClientsById = useMemo(
() => new Map(hostClients.map((entry) => [entry.hostId, entry.client])),
[hostClients]
+3 -12
View File
@@ -9,12 +9,12 @@ import {
View
} from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useFocusEffect, useRouter } from 'expo-router'
import { useRouter } from 'expo-router'
import { ChevronLeft, ChevronRight } from 'lucide-react-native'
import { colors, radii, spacing, typography } from '../src/theme/mobile-theme'
import { loadHosts } from '../src/transport/host-store'
import type { HostProfile } from '../src/transport/types'
import { useAllHostClients } from '../src/transport/use-all-host-clients'
import { useFocusedSettingsHostClients } from '../src/transport/settings-host-client-connections'
import type { RpcClient } from '../src/transport/rpc-client'
import { BottomDrawer } from '../src/components/BottomDrawer'
import { VoiceModelList } from '../src/components/VoiceModelList'
@@ -47,7 +47,7 @@ export default function VoiceSettingsScreen(): React.JSX.Element {
void loadHosts().then(setHosts)
}, [])
const hostIds = useMemo(() => hosts.map((h) => h.id), [hosts])
const hostClients = useAllHostClients(hostIds)
const { clients: hostClients, focused: routeFocused } = useFocusedSettingsHostClients(hostIds)
// Voice dictation runs on the paired desktop, so pick the first connected host.
const client: RpcClient | null = useMemo(
() => hostClients.find((entry) => entry.state === 'connected')?.client ?? null,
@@ -59,15 +59,6 @@ export default function VoiceSettingsScreen(): React.JSX.Element {
const [error, setError] = useState<string | null>(null)
const [busyAction, setBusyAction] = useState<ModelBusyAction | null>(null)
const [modelDrawerOpen, setModelDrawerOpen] = useState(false)
const [routeFocused, setRouteFocused] = useState(false)
useFocusEffect(
useCallback(() => {
setRouteFocused(true)
return () => setRouteFocused(false)
}, [])
)
const refresh = useCallback(async (): Promise<boolean | undefined> => {
if (!client) {
return false
@@ -45,7 +45,7 @@ describe('mobile accounts route', () => {
navigateToHostStackRoute(
harness.navigation,
{ push },
{ push, replace: vi.fn() },
'host/one',
mobileAccountsRouteTarget('host/one')
)
@@ -0,0 +1,203 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { MobileHostCard } from './MobileHostCard'
vi.mock('react-native', () => ({
Pressable: 'Pressable',
StyleSheet: { create: (styles: unknown) => styles },
Text: 'Text',
View: 'View'
}))
vi.mock('lucide-react-native', () => ({
Monitor: 'Monitor',
MoreVertical: 'MoreVertical'
}))
vi.mock('./StatusDot', () => ({
StatusDot: 'StatusDot'
}))
function suppressRendererDeprecation() {
return vi.spyOn(console, 'error').mockImplementation((...args) => {
if (typeof args[0] !== 'string' || !args[0].includes('react-test-renderer is deprecated')) {
throw new Error(String(args[0]))
}
})
}
describe('MobileHostCard', () => {
let renderer: ReactTestRenderer | null = null
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
vi.restoreAllMocks()
})
it('keeps host navigation and actions as separate accessible controls', async () => {
const onPress = vi.fn()
const onLongPress = vi.fn()
const onOpenActions = vi.fn()
const consoleError = suppressRendererDeprecation()
await act(async () => {
renderer = create(
createElement(MobileHostCard, {
host: {
id: 'desk',
name: 'Desk',
endpoint: 'ws://192.168.1.2:6768',
deviceToken: 'token',
publicKeyB64: 'key',
lastConnected: 1
},
state: 'disconnected',
verdict: { kind: 'normal', label: 'Disconnected' },
path: 'lan',
onPress,
onLongPress,
onOpenActions
})
)
})
consoleError.mockRestore()
const buttons = renderer.root.findAllByType('Pressable')
expect(buttons).toHaveLength(2)
expect(buttons[0].props.accessibilityRole).toBe('button')
expect(buttons[0].props.accessibilityLabel).toBe('Open Desk, Disconnected')
expect(buttons[1].props.accessibilityRole).toBe('button')
expect(buttons[1].props.accessibilityLabel).toBe('Actions for Desk')
expect(buttons[1].props.hitSlop).toBe(8)
expect(buttons[1].props.style({ pressed: false })[0]).toMatchObject({ width: 40, height: 40 })
expect(renderer.root.findAllByType('MoreVertical')).toHaveLength(1)
expect(renderer.root.findAllByType('ChevronRight')).toHaveLength(0)
act(() => buttons[1].props.onPress())
expect(onOpenActions).toHaveBeenCalledOnce()
expect(onPress).not.toHaveBeenCalled()
act(() => buttons[0].props.onPress())
act(() => buttons[0].props.onLongPress())
expect(onPress).toHaveBeenCalledOnce()
expect(onLongPress).toHaveBeenCalledOnce()
})
it('announces the connection path without the visual separator', async () => {
const consoleError = suppressRendererDeprecation()
await act(async () => {
renderer = create(
createElement(MobileHostCard, {
host: {
id: 'desk',
name: 'Desk',
endpoint: 'ws://192.168.1.2:6768',
deviceToken: 'token',
publicKeyB64: 'key',
lastConnected: 1
},
state: 'connected',
verdict: { kind: 'normal', label: 'Connected' },
path: 'tailscale',
worktreeInfo: {
hostId: 'desk',
totalWorktrees: 3,
activeCount: 2,
lastActiveWorktree: null,
countsProvenAt: Date.now()
},
onPress: vi.fn(),
onLongPress: vi.fn(),
onOpenActions: vi.fn()
})
)
})
consoleError.mockRestore()
const navigationButton = renderer.root.findAllByType('Pressable')[0]
expect(navigationButton.props.accessibilityLabel).toBe(
'Open Desk, Connected, Direct via Tailscale, 3 worktrees, 2 active'
)
})
it('preserves the connected worktree-catalog failure state', async () => {
const consoleError = suppressRendererDeprecation()
await act(async () => {
renderer = create(
createElement(MobileHostCard, {
host: {
id: 'desk',
name: 'Desk',
endpoint: 'ws://192.168.1.2:6768',
deviceToken: 'token',
publicKeyB64: 'key',
lastConnected: 1
},
state: 'connected',
verdict: { kind: 'normal', label: 'Connected' },
path: 'relay',
worktreeInfo: {
hostId: 'desk',
totalWorktrees: 0,
activeCount: 0,
lastActiveWorktree: null,
catalogUnavailable: true
},
onPress: vi.fn(),
onLongPress: vi.fn(),
onOpenActions: vi.fn()
})
)
})
consoleError.mockRestore()
const navigationButton = renderer.root.findAllByType('Pressable')[0]
expect(navigationButton.props.accessibilityLabel).toBe(
'Open Desk, Connected, Orca Relay, Worktree list unavailable'
)
expect(
renderer.root
.findAllByType('Text')
.some((node) => node.children.includes('Worktree list unavailable'))
).toBe(true)
})
it('includes visible offline recovery guidance in the navigation label', async () => {
const consoleError = suppressRendererDeprecation()
await act(async () => {
renderer = create(
createElement(MobileHostCard, {
host: {
id: 'desk',
name: 'Desk',
endpoint: 'ws://192.168.1.2:6768',
deviceToken: 'token',
publicKeyB64: 'key',
lastConnected: 1
},
state: 'reconnecting',
verdict: {
kind: 'unreachable',
label: "Can't reach desktop",
reason: 'never-connected'
},
path: 'lan',
onPress: vi.fn(),
onLongPress: vi.fn(),
onOpenActions: vi.fn()
})
)
})
consoleError.mockRestore()
const navigationButton = renderer.root.findAllByType('Pressable')[0]
expect(navigationButton.props.accessibilityLabel).toBe(
"Open Desk, Can't reach desktop, Update desktop Orca and sign in to connect from anywhere"
)
})
})
+30 -3
View File
@@ -3,7 +3,7 @@ import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ConnectionVerdict } from '../transport/connection-health'
import type { MobileConnectionPath } from '../transport/stable-logical-rpc-client'
import type { ConnectionState, HostProfile } from '../transport/types'
import type { ConnectionState, HostCredentialStatus, HostProfile } from '../transport/types'
import {
markHomeWorktreeCatalogUnavailable,
type HostWorktreeInfo
@@ -16,7 +16,7 @@ vi.mock('react-native', () => ({
Text: 'Text',
View: 'View'
}))
vi.mock('lucide-react-native', () => ({ ChevronRight: 'ChevronRight', Monitor: 'Monitor' }))
vi.mock('lucide-react-native', () => ({ Monitor: 'Monitor', MoreVertical: 'MoreVertical' }))
vi.mock('./StatusDot', () => ({ StatusDot: 'StatusDot' }))
const host: HostProfile = {
@@ -54,6 +54,7 @@ describe('MobileHostCard', () => {
state?: ConnectionState
verdict?: ConnectionVerdict
path?: MobileConnectionPath
credentialStatus?: HostCredentialStatus
}
): Promise<string[]> {
await act(async () => {
@@ -63,9 +64,11 @@ describe('MobileHostCard', () => {
state: overrides?.state ?? 'connected',
verdict: overrides?.verdict ?? verdict,
path: overrides?.path ?? 'lan',
credentialStatus: overrides?.credentialStatus,
worktreeInfo,
onPress: () => {},
onLongPress: () => {}
onLongPress: () => {},
onOpenActions: () => {}
})
)
})
@@ -139,4 +142,28 @@ describe('MobileHostCard', () => {
expect(lines).not.toContain('0 worktrees')
expect(lines).not.toContain('Worktree list unavailable')
})
it('offers re-pairing when the credential is missing', async () => {
const lines = await renderCard(loaded, {
state: 'connected',
verdict: { kind: 'auth-failed', label: 'Pairing invalid' },
credentialStatus: 'missing'
})
expect(lines).toContain('Pairing invalid')
expect(lines).toContain('Tap to re-pair with your desktop')
expect(lines).not.toContain('12 worktrees · 2 active')
})
it('offers a retry without declaring a transient read failure invalid', async () => {
const lines = await renderCard(undefined, {
state: 'disconnected',
verdict: { kind: 'normal', label: 'Disconnected' },
credentialStatus: 'temporarily-unavailable'
})
expect(lines).toContain('Pairing temporarily unavailable')
expect(lines).toContain('Unlock your phone, then tap to retry')
expect(lines).not.toContain('Pairing invalid')
})
})
+122 -42
View File
@@ -1,16 +1,17 @@
import { ChevronRight, Monitor } from 'lucide-react-native'
import { Monitor, MoreVertical } from 'lucide-react-native'
import { Pressable, StyleSheet, Text, View } from 'react-native'
import type { ConnectionVerdict } from '../transport/connection-health'
import { verdictDisplayLabel } from '../transport/connection-health'
import { mobileConnectionPathLabel } from '../transport/mobile-connection-path-label'
import type { MobileConnectionPath } from '../transport/stable-logical-rpc-client'
import type { ConnectionState, HostProfile } from '../transport/types'
import type { ConnectionState, HostCatalogEntry, HostProfile } from '../transport/types'
import { colors, radii, spacing } from '../theme/mobile-theme'
import { homeHostWorktreeSummary, type HostWorktreeInfo } from '../worktree/home-worktree-info'
import { StatusDot } from './StatusDot'
export function MobileHostCard(props: {
host: HostProfile
host: HostProfile | HostCatalogEntry
credentialStatus?: HostCatalogEntry['credentialStatus']
state: ConnectionState
verdict: ConnectionVerdict
path: MobileConnectionPath
@@ -19,52 +20,113 @@ export function MobileHostCard(props: {
worktreeInfo?: HostWorktreeInfo
onPress: () => void
onLongPress: () => void
onOpenActions: () => void
}) {
const connected = props.state === 'connected'
const credentialUnavailable = props.credentialStatus === 'temporarily-unavailable'
const credentialMissing = props.credentialStatus === 'missing'
const connected = props.state === 'connected' && !credentialUnavailable && !credentialMissing
// Why: a relay dial can run for seconds behind "Connecting…"/"Reconnecting…"; naming the
// path mid-wait tells the user the phone is off-LAN rather than hung (F5). Only 'relay' is
// named — 'lan' doubles as the unknown-path default, so it would be a guess before connect.
const dialingPath =
['connecting', 'handshaking', 'reconnecting'].includes(props.state) && props.path === 'relay'
const isError = ['warning', 'unreachable', 'auth-failed'].includes(props.verdict.kind)
const isError =
credentialMissing || ['warning', 'unreachable', 'auth-failed'].includes(props.verdict.kind)
const statusLabel = credentialMissing
? 'Pairing invalid'
: credentialUnavailable
? 'Pairing temporarily unavailable'
: verdictDisplayLabel(props.verdict)
const statusVerdict: ConnectionVerdict = credentialMissing
? { kind: 'auth-failed', label: statusLabel }
: credentialUnavailable
? { kind: 'warning', label: statusLabel }
: props.verdict
const worktreeSummary = homeHostWorktreeSummary(props.worktreeInfo)
const connectionPathLabel =
!credentialMissing && !credentialUnavailable && (connected || dialingPath)
? mobileConnectionPathLabel(props.path)
: null
const discoveryHint =
props.verdict.kind === 'unreachable' && !props.host.relay
? 'Update desktop Orca and sign in to connect from anywhere'
: null
const credentialHint = credentialMissing
? 'Tap to re-pair with your desktop'
: credentialUnavailable
? 'Unlock your phone, then tap to retry'
: null
const accessibilityLabel = [
`Open ${props.host.name}`,
statusLabel,
connectionPathLabel?.replace(' · ', ' via '),
connected ? worktreeSummary?.replace(' · ', ', ') : null,
discoveryHint,
credentialHint
]
.filter(Boolean)
.join(', ')
return (
<Pressable
style={({ pressed }) => [styles.card, pressed && styles.cardPressed]}
onPress={props.onPress}
onLongPress={props.onLongPress}
delayLongPress={400}
>
<View style={styles.icon}>
<Monitor size={20} color={connected ? colors.textPrimary : colors.textSecondary} />
</View>
<View style={styles.main}>
<Text
style={[styles.name, !connected && { color: colors.textSecondary }]}
numberOfLines={1}
>
{props.host.name}
</Text>
<View style={styles.meta}>
<StatusDot state={props.state} verdict={props.verdict} />
<Text style={[styles.metaText, isError && { color: colors.statusRed }]} numberOfLines={1}>
{verdictDisplayLabel(props.verdict)}
{connected || dialingPath ? ` · ${mobileConnectionPathLabel(props.path)}` : ''}
</Text>
<View style={styles.card}>
<Pressable
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
style={({ pressed }) => [styles.cardMain, pressed && styles.cardPressed]}
onPress={props.onPress}
onLongPress={props.onLongPress}
delayLongPress={400}
>
<View style={styles.icon}>
<Monitor size={20} color={connected ? colors.textPrimary : colors.textSecondary} />
</View>
{connected && worktreeSummary ? (
<Text style={styles.worktreeMetaText} numberOfLines={1}>
{worktreeSummary}
<View style={styles.main}>
<Text
style={[styles.name, !connected && { color: colors.textSecondary }]}
numberOfLines={1}
>
{props.host.name}
</Text>
) : null}
{props.verdict.kind === 'unreachable' && !props.host.relay ? (
<Text style={styles.discoveryHint} numberOfLines={2}>
Update desktop Orca and sign in to connect from anywhere
</Text>
) : null}
</View>
<ChevronRight size={16} color={colors.textMuted} />
</Pressable>
<View style={styles.meta}>
<StatusDot state={props.state} verdict={statusVerdict} />
<Text
style={[
styles.metaText,
isError && { color: colors.statusRed },
credentialUnavailable && { color: colors.statusAmber }
]}
numberOfLines={1}
>
{statusLabel}
{connectionPathLabel ? ` · ${connectionPathLabel}` : ''}
</Text>
</View>
{connected && worktreeSummary ? (
<Text style={styles.worktreeMetaText} numberOfLines={1}>
{worktreeSummary}
</Text>
) : null}
{discoveryHint ? (
<Text style={styles.discoveryHint} numberOfLines={2}>
{discoveryHint}
</Text>
) : null}
{credentialHint ? (
<Text style={styles.discoveryHint} numberOfLines={2}>
{credentialHint}
</Text>
) : null}
</View>
</Pressable>
<Pressable
accessibilityRole="button"
accessibilityLabel={`Actions for ${props.host.name}`}
hitSlop={8}
style={({ pressed }) => [styles.actionButton, pressed && styles.actionButtonPressed]}
onPress={props.onOpenActions}
>
<MoreVertical size={18} color={colors.textSecondary} />
</Pressable>
</View>
)
}
@@ -72,12 +134,19 @@ const styles = StyleSheet.create({
card: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingVertical: 12,
borderRadius: radii.card,
backgroundColor: colors.bgPanel,
borderWidth: 1,
borderColor: colors.borderSubtle
borderColor: colors.borderSubtle,
overflow: 'hidden'
},
cardMain: {
flex: 1,
minWidth: 0,
flexDirection: 'row',
alignItems: 'center',
paddingLeft: spacing.md,
paddingVertical: 12
},
cardPressed: { backgroundColor: colors.bgRaised },
icon: {
@@ -104,5 +173,16 @@ const styles = StyleSheet.create({
fontSize: 11,
lineHeight: 15,
color: colors.textMuted
},
actionButton: {
width: 40,
height: 40,
marginHorizontal: spacing.xs,
borderRadius: radii.row,
alignItems: 'center',
justifyContent: 'center'
},
actionButtonPressed: {
backgroundColor: colors.bgRaised
}
})
+12
View File
@@ -34,6 +34,18 @@ describe('file-tree', () => {
])
})
it('orders numbered names naturally, matching the desktop explorer', () => {
const cache: DirectoryCache = {
'': { entries: [entry('100 - b.txt'), entry('9 - c.txt'), entry('99 - a.txt')] }
}
expect(flattenDirectoryCache(cache, new Set()).map((row) => row.id)).toEqual([
'file:9 - c.txt',
'file:99 - a.txt',
'file:100 - b.txt'
])
})
it('mirrors desktop default browse exclusions while keeping dotfiles visible', () => {
const cache: DirectoryCache = {
'': {
+2 -1
View File
@@ -1,5 +1,6 @@
// Pure tree projection for the mobile file explorer. Mobile mirrors desktop
// browse semantics by flattening cached files.readDir results as folders open.
import { compareFileNames } from '../../../src/shared/file-name-sort'
export type MobileDirEntry = {
name: string
@@ -118,7 +119,7 @@ function compareDirectoryEntries(a: MobileDirEntry, b: MobileDirEntry): number {
if (a.isDirectory !== b.isDirectory) {
return a.isDirectory ? -1 : 1
}
return a.name.localeCompare(b.name)
return compareFileNames(a.name, b.name)
}
export function shouldIncludeMobileFileExplorerEntry(entry: MobileDirEntry): boolean {
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
import {
coordinateHostStackNavigation,
hostStackHostRoute,
hostStackRouteHref,
navigateToHostStackRoute,
type HostStackNavigationState
} from './host-stack-navigation'
@@ -69,7 +70,7 @@ describe('host stack navigation', () => {
const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] })
const push = vi.fn()
navigateToHostStackRoute(harness.navigation, { push }, 'host/one', TARGET)
navigateToHostStackRoute(harness.navigation, { push, replace: vi.fn() }, 'host/one', TARGET)
expect(push).toHaveBeenCalledWith(hostStackHostRoute('host/one'))
expect(harness.navigation.dispatch).not.toHaveBeenCalled()
@@ -89,7 +90,12 @@ describe('host stack navigation', () => {
rootLayoutScopedState({ index: 0, routes: [{ name: 'index' }] })
)
navigateToHostStackRoute(harness.navigation, { push: vi.fn() }, 'host/one', TARGET)
navigateToHostStackRoute(
harness.navigation,
{ push: vi.fn(), replace: vi.fn() },
'host/one',
TARGET
)
harness.setState(rootLayoutScopedState(committedHostState('host/one')))
expect(harness.navigation.dispatch).toHaveBeenCalledWith({
@@ -103,7 +109,12 @@ describe('host stack navigation', () => {
it('survives the state emitted before the root navigator has hydrated', () => {
const harness = navigationHarness(undefined)
navigateToHostStackRoute(harness.navigation, { push: vi.fn() }, 'host/one', TARGET)
navigateToHostStackRoute(
harness.navigation,
{ push: vi.fn(), replace: vi.fn() },
'host/one',
TARGET
)
expect(() => harness.setState(undefined)).not.toThrow()
expect(harness.navigation.dispatch).not.toHaveBeenCalled()
@@ -112,10 +123,30 @@ describe('host stack navigation', () => {
expect(harness.navigation.dispatch).toHaveBeenCalledTimes(1)
})
it('replaces through Expo Router when root state omits the mounted child stack', () => {
const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] })
const replace = vi.fn()
navigateToHostStackRoute(harness.navigation, { push: vi.fn(), replace }, 'host/one', TARGET)
harness.setState({
index: 0,
routes: [{ name: 'h', params: { hostId: 'host/one' } }]
})
expect(harness.navigation.dispatch).not.toHaveBeenCalled()
expect(replace).toHaveBeenCalledWith(hostStackRouteHref(TARGET))
expect(harness.listenerCount()).toBe(0)
})
it('abandons the transition when navigation leaves the host route it was waiting on', () => {
const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] })
navigateToHostStackRoute(harness.navigation, { push: vi.fn() }, 'host/one', TARGET)
navigateToHostStackRoute(
harness.navigation,
{ push: vi.fn(), replace: vi.fn() },
'host/one',
TARGET
)
harness.setState({ index: 0, routes: [{ name: 'h' }] })
harness.setState({ index: 0, routes: [{ name: 'index' }] })
harness.setState(committedHostState('host/one'))
@@ -127,7 +158,12 @@ describe('host stack navigation', () => {
it('ignores a different host whose id merely decodes badly', () => {
const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] })
navigateToHostStackRoute(harness.navigation, { push: vi.fn() }, 'host/one', TARGET)
navigateToHostStackRoute(
harness.navigation,
{ push: vi.fn(), replace: vi.fn() },
'host/one',
TARGET
)
harness.setState(committedHostState('100%'))
expect(harness.navigation.dispatch).not.toHaveBeenCalled()
@@ -138,7 +174,7 @@ describe('host stack navigation', () => {
const controller = navigateToHostStackRoute(
harness.navigation,
{ push: vi.fn() },
{ push: vi.fn(), replace: vi.fn() },
'host/one',
TARGET
)
@@ -156,7 +192,7 @@ describe('host stack navigation', () => {
it('retargets a still-pending transition to the same host instead of pushing twice', () => {
const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] })
const push = vi.fn()
const router = { push }
const router = { push, replace: vi.fn() }
const pending = coordinateHostStackNavigation(
null,
@@ -35,8 +35,14 @@ export type HostStackRootNavigation = {
export type HostStackHostRoute = `/h/${string}`
export type HostStackRouteHref = Readonly<{
pathname: `/h/${string}`
params: Readonly<Record<string, string>>
}>
export type HostStackRouter = {
push: (route: HostStackHostRoute) => void
replace: (route: HostStackRouteHref) => void
}
export type HostStackNavigationController = Readonly<{
@@ -54,6 +60,10 @@ export function hostStackHostRoute(hostId: string): HostStackHostRoute {
return `/h/${encodeURIComponent(hostId)}`
}
export function hostStackRouteHref(target: HostStackRouteTarget): HostStackRouteHref {
return { pathname: `/h/${target.name}`, params: target.params }
}
// Why: the host is pushed as an encoded segment, so the committed route may hold
// either form — an id with `/`, `#`, or `%` must still match its own push.
function hostParamMatches(param: unknown, expectedHostId: string): boolean {
@@ -144,6 +154,10 @@ export function navigateToHostStackRoute(
}
const hostStack = hostContainer && mountedHostStack(hostContainer, hostId)
if (!hostStack) {
if (hostContainer && hostParamMatches(hostContainer.params?.hostId, hostId)) {
dispose()
router.replace(hostStackRouteHref(selectedTarget))
}
return
}
dispose()
@@ -47,7 +47,12 @@ describe('notification route coordination', () => {
const harness = navigationHarness(undefined)
const push = vi.fn()
navigateToHostStackRoute(harness.navigation, { push }, target!.hostId, target!.sessionTarget!)
navigateToHostStackRoute(
harness.navigation,
{ push, replace: vi.fn() },
target!.hostId,
target!.sessionTarget!
)
expect(push).toHaveBeenCalledWith(hostStackHostRoute('host/one'))
expect(harness.navigation.dispatch).not.toHaveBeenCalled()
@@ -1,5 +1,9 @@
import { describe, expect, it } from 'vitest'
import { buildLocalNotificationData, getNotificationNavigationTarget } from './notification-routing'
import {
buildLocalNotificationData,
getNotificationNavigationTarget,
notificationCredentialRecoveryRoute
} from './notification-routing'
describe('notification routing', () => {
it('includes the host id in locally scheduled notification data', () => {
@@ -55,4 +59,32 @@ describe('notification routing', () => {
)
).toBeNull()
})
it.each([
['missing', 're-pair'],
['temporarily-unavailable', 'retry']
] as const)('routes %s host credentials to %s recovery', (status, recovery) => {
const target = getNotificationNavigationTarget(
{ hostId: 'host-1', worktreeId: 'repo::/tmp/worktree' },
{
knownHostIds: new Set(['host-1']),
credentialStatusByHostId: new Map([['host-1', status]])
}
)
expect(target).toMatchObject({ hostId: 'host-1', credentialRecovery: recovery })
expect(notificationCredentialRecoveryRoute(target!)).toBe(
status === 'missing' ? '/pair-scan' : '/'
)
})
it('keeps ready hosts on the requested notification destination', () => {
const target = getNotificationNavigationTarget(
{ hostId: 'host-1', worktreeId: 'repo::/tmp/worktree' },
{ credentialStatusByHostId: new Map([['host-1', 'ready']]) }
)
expect(target?.sessionTarget).not.toBeNull()
expect(notificationCredentialRecoveryRoute(target!)).toBeNull()
})
})
@@ -1,5 +1,6 @@
import type { HostStackRouteTarget } from '../navigation/host-stack-navigation'
import { mobileSessionRouteTarget } from '../session/mobile-session-route'
import type { HostCredentialStatus } from '../transport/types'
export type DesktopNotificationSource = 'agent-task-complete' | 'terminal-bell' | 'test'
@@ -18,6 +19,7 @@ export type LocalNotificationData = {
export type NotificationNavigationOptions = {
knownHostIds?: ReadonlySet<string>
credentialStatusByHostId?: ReadonlyMap<string, HostCredentialStatus>
}
function readNonEmptyString(value: unknown): string | null {
@@ -46,8 +48,18 @@ export function buildLocalNotificationData(
export type NotificationNavigationTarget = Readonly<{
hostId: string
sessionTarget: HostStackRouteTarget | null
credentialRecovery?: 'retry' | 're-pair'
}>
export function notificationCredentialRecoveryRoute(
target: NotificationNavigationTarget
): '/' | '/pair-scan' | null {
if (target.credentialRecovery === 're-pair') {
return '/pair-scan'
}
return target.credentialRecovery === 'retry' ? '/' : null
}
export function getNotificationNavigationTarget(
data: unknown,
options: NotificationNavigationOptions = {}
@@ -66,8 +78,14 @@ export function getNotificationNavigationTarget(
}
const worktreeId = readNonEmptyString(record.worktreeId)
const credentialStatus = options.credentialStatusByHostId?.get(hostId)
return {
hostId,
sessionTarget: worktreeId ? mobileSessionRouteTarget({ hostId, worktreeId }) : null
sessionTarget: worktreeId ? mobileSessionRouteTarget({ hostId, worktreeId }) : null,
...(credentialStatus === 'missing'
? { credentialRecovery: 're-pair' as const }
: credentialStatus === 'temporarily-unavailable'
? { credentialRecovery: 'retry' as const }
: {})
}
}
@@ -2,7 +2,10 @@ import { useCallback } from 'react'
import { useRouter } from 'expo-router'
import { hostStackHostRoute } from '../navigation/host-stack-navigation'
import { useOpenHostStackRoute } from '../navigation/use-open-host-stack-route'
import type { NotificationNavigationTarget } from './notification-routing'
import {
notificationCredentialRecoveryRoute,
type NotificationNavigationTarget
} from './notification-routing'
export function useOpenNotificationRoute(): (target: NotificationNavigationTarget) => void {
const openHostStackRoute = useOpenHostStackRoute()
@@ -10,6 +13,11 @@ export function useOpenNotificationRoute(): (target: NotificationNavigationTarge
return useCallback(
(target) => {
const recoveryRoute = notificationCredentialRecoveryRoute(target)
if (recoveryRoute) {
router.push(recoveryRoute)
return
}
if (target.sessionTarget) {
openHostStackRoute(target.hostId, target.sessionTarget)
return
+1 -1
View File
@@ -187,7 +187,7 @@ export function MobileNativeChatAsk({ prompt, onAnswer, onCancel }: Props): Reac
disabled={!canAdvance}
>
<Text style={[styles.nextText, !canAdvance && styles.nextTextDisabled]}>
{isLast ? 'Send answer' : 'Next'}
{isLast ? 'Submit' : 'Next'}
</Text>
</Pressable>
</View>
@@ -47,6 +47,7 @@ function overlayElement(tick: Tick): ReturnType<typeof createElement> {
nativeChatStreamLive: tick.streamLive ?? false,
nativeChatStreamScopeKey: tick.identity ?? 'tab-a',
chatPending: [],
chatImagePreviewsByMessageId: {},
chatComposerText: '',
setChatComposerText: vi.fn()
} as unknown as MobileNativeChatController
@@ -82,6 +82,7 @@ export function MobileNativeChatOverlay({
onLoadEarlier={session.loadEarlier}
onSend={images.sendNativeChat}
pending={controller.chatPending}
imagePreviewsByMessageId={controller.chatImagePreviewsByMessageId}
composerText={controller.chatComposerText}
onComposerTextChange={controller.setChatComposerText}
onAttachImage={() => void images.attachImage('library')}
@@ -50,8 +50,13 @@ vi.mock('./MobileAgentWorkingIndicator', () => ({
vi.mock('./MobileNativeChatComposer', async () => {
const React = await import('react')
return {
MobileNativeChatComposer: (props: { onSend: (text: string) => Promise<boolean> }) =>
MobileNativeChatComposer: (props: {
onSend: (text: string) => Promise<boolean>
disabled?: boolean
placeholder?: string
}) =>
React.createElement('Composer', {
...props,
accessibilityLabel: 'Send message',
onPress: () => props.onSend('hi')
})
@@ -136,6 +141,10 @@ describe('MobileNativeChatView', () => {
return renderer!.root.findAll((node) => node.props.accessibilityRole === 'alert')
}
function composer(): ReactTestInstance {
return renderer!.root.find((node) => node.type === 'Composer')
}
function bannerText(): string {
const [alert, ...rest] = banners()
expect(rest).toHaveLength(0)
@@ -197,4 +206,42 @@ describe('MobileNativeChatView', () => {
expect(listIds()).toEqual(['a1', 'streaming'])
})
it('keeps a visible lock through a subscribed-end lease blip', async () => {
vi.useFakeTimers()
try {
await render({ inputLockReason: 'waiting' })
await act(async () => vi.advanceTimersByTime(600))
expect(composer().props.disabled).toBe(true)
await update({ inputLockReason: null })
expect(composer().props.disabled).toBe(true)
await act(async () => vi.advanceTimersByTime(300))
await update({ inputLockReason: 'waiting' })
await act(async () => vi.advanceTimersByTime(600))
expect(composer().props.disabled).toBe(true)
expect(composer().props.placeholder).toBe('Waiting for terminal…')
} finally {
vi.useRealTimers()
}
})
it('unlocks after the lease stays ready', async () => {
vi.useFakeTimers()
try {
await render({ inputLockReason: 'waiting' })
await act(async () => vi.advanceTimersByTime(600))
await update({ inputLockReason: null })
await act(async () => vi.advanceTimersByTime(599))
expect(composer().props.disabled).toBe(true)
await act(async () => vi.advanceTimersByTime(1))
expect(composer().props.disabled).toBe(false)
expect(composer().props.placeholder).toBe('Message, @files, /commands')
} finally {
vi.useRealTimers()
}
})
})
+20 -10
View File
@@ -33,6 +33,8 @@ import { MobileNativeChatQuestion } from './MobileNativeChatQuestion'
import { mobileChatQuestionKey, type MobileChatQuestion } from './mobile-native-chat-question'
import type { MobileNativeChatStatus } from './use-mobile-native-chat-session'
const INPUT_LOCK_SETTLE_MS = 600
/** 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'
@@ -59,6 +61,9 @@ type Props = {
/** Optimistic queued sends (owned by the route so they survive view switches). */
/** Optimistic user echoes, including any ridden-along image preview URIs. */
pending: MobileNativeChatPendingItem[]
/** Local photo URIs retained when the authoritative transcript replaces an
* optimistic image bubble. */
imagePreviewsByMessageId?: Record<string, string[]>
/** Controlled composer text (owned by the route so dictation can write to it). */
composerText: string
onComposerTextChange: (text: string) => void
@@ -123,6 +128,7 @@ export function MobileNativeChatView({
onLoadEarlier,
onSend,
pending,
imagePreviewsByMessageId,
composerText,
onComposerTextChange,
onAttachImage,
@@ -175,8 +181,14 @@ export function MobileNativeChatView({
// route-owned optimistic queued messages. Memoize on the same deps so the
// downstream autoscroll effects/`renderItem` keep referential stability.
const { data } = useMemo(
() => buildMobileNativeChatTransientData({ folded, streaming, pending }),
[folded, streaming, pending]
() =>
buildMobileNativeChatTransientData({
folded,
streaming,
pending,
imagePreviewsByMessageId
}),
[folded, streaming, pending, imagePreviewsByMessageId]
)
// Follow the tail as the conversation grows and keep the newest message above
@@ -250,20 +262,18 @@ export function MobileNativeChatView({
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.
// A dead PTY emits subscribed→end; settle both edges so its false lease cannot flash the composer enabled.
const rawLockReason = inputLockReason ?? null
const rawLockHeld = rawLockReason !== null
const [lockHeld, setLockHeld] = useState(false)
useEffect(() => {
if (rawLockReason === null) {
setLockHeld(false)
if (rawLockHeld === lockHeld) {
return
}
const timer = setTimeout(() => setLockHeld(true), 600)
const timer = setTimeout(() => setLockHeld(rawLockHeld), INPUT_LOCK_SETTLE_MS)
return () => clearTimeout(timer)
}, [rawLockReason])
const lockReason = lockHeld ? rawLockReason : null
}, [lockHeld, rawLockHeld])
const lockReason = lockHeld ? (rawLockReason ?? 'waiting') : null
return (
<View style={[styles.root, { paddingBottom: bottomPad }]}>
@@ -10,11 +10,13 @@ type DictationStatus = {
type MobileTerminalLiveInputStatusProps = {
readonly dictation: DictationStatus
readonly isAttaching: boolean
readonly liveInputText: string
}
export function MobileTerminalLiveInputStatus({
dictation,
isAttaching
isAttaching,
liveInputText
}: MobileTerminalLiveInputStatusProps) {
const title = dictation.isRecording
? 'Listening'
@@ -31,14 +33,14 @@ export function MobileTerminalLiveInputStatus({
? 'Preparing microphone'
: isAttaching
? 'Uploading image to host'
: 'Tap to show keyboard'
: liveInputText || 'Tap to show keyboard'
return (
<View style={styles.status}>
<Text style={styles.title} numberOfLines={1}>
{title}
</Text>
<Text style={styles.detail} numberOfLines={1}>
<Text style={styles.detail} numberOfLines={1} ellipsizeMode="head">
{detail}
</Text>
</View>
@@ -12,13 +12,28 @@ vi.mock('expo-file-system', () => ({
File: vi.fn()
}))
import { ImageLibraryPermissionError, pickMobileImage } from './mobile-image-source-picker'
import {
ImageLibraryPermissionError,
pickMobileImage,
pickMobileImages,
type PickedMobileImage
} from './mobile-image-source-picker'
const granted = { granted: true } as Awaited<
ReturnType<typeof import('expo-image-picker').requestMediaLibraryPermissionsAsync>
>
const denied = { granted: false } as typeof granted
async function collectImages(
images: AsyncIterable<PickedMobileImage>
): Promise<PickedMobileImage[]> {
const collected: PickedMobileImage[] = []
for await (const image of images) {
collected.push(image)
}
return collected
}
function fileFactory(
bytes: Uint8Array,
options?: { fileSize?: number; handleSize?: number | null; readError?: Error }
@@ -68,6 +83,57 @@ describe('pickMobileImage', () => {
expect(file.close).toHaveBeenCalledTimes(1)
})
it('returns every selected library photo in order', async () => {
const bytesByUri = new Map([
['file:///a.jpg', new Uint8Array([1])],
['file:///b.jpg', new Uint8Array([2])],
['file:///c.jpg', new Uint8Array([3])]
])
const createFile = vi.fn((uri: string) => {
const bytes = bytesByUri.get(uri)!
let read = false
return {
size: bytes.length,
open: () => ({
size: bytes.length,
readBytes: () => {
if (read) {
return new Uint8Array()
}
read = true
return bytes
},
close: vi.fn()
})
}
})
const launchLibrary = vi.fn().mockResolvedValue({
canceled: false,
assets: [...bytesByUri].map(([uri, bytes]) => ({ uri, fileSize: bytes.length }))
})
const result = await collectImages(
pickMobileImages('library', {
requestLibraryPermission: vi.fn().mockResolvedValue(granted),
launchLibrary,
createFile
})
)
expect(result.map((image) => image.uri)).toEqual([
'file:///a.jpg',
'file:///b.jpg',
'file:///c.jpg'
])
expect(launchLibrary).toHaveBeenCalledWith(
expect.objectContaining({
allowsMultipleSelection: true,
orderedSelection: true,
selectionLimit: 0
})
)
})
it('throws when photo library permission is denied', async () => {
await expect(
pickMobileImage('library', {
@@ -83,11 +83,12 @@ async function readUriAsBase64(
}
}
async function pickFromLibrary(
async function* pickFromLibrary(
multiple: boolean,
requestPermission: typeof ImagePicker.requestMediaLibraryPermissionsAsync = ImagePicker.requestMediaLibraryPermissionsAsync,
launch: typeof ImagePicker.launchImageLibraryAsync = ImagePicker.launchImageLibraryAsync,
createFile: MobileImageFileFactory = defaultMobileImageFileFactory
): Promise<PickedMobileImage | null> {
): AsyncGenerator<PickedMobileImage> {
const permission = await requestPermission()
// Why: `granted` covers full + limited iOS access; only a hard denial blocks us.
if (!permission.granted) {
@@ -96,51 +97,84 @@ async function pickFromLibrary(
const result = await launch({
mediaTypes: ['images'],
base64: false,
allowsMultipleSelection: false,
allowsMultipleSelection: multiple,
...(multiple ? { selectionLimit: 0, orderedSelection: true } : {}),
quality: 1
})
if (result.canceled) {
return null
return
}
const asset = result.assets[0]
const base64 = asset?.uri ? await readUriAsBase64(asset.uri, asset.fileSize, createFile) : null
if (!base64) {
return null
for (const asset of result.assets) {
if (!asset.uri) {
continue
}
const base64 = await readUriAsBase64(asset.uri, asset.fileSize, createFile)
if (base64) {
yield { base64, uri: asset.uri }
}
}
return { base64, ...(asset?.uri ? { uri: asset.uri } : {}) }
}
async function pickFromFiles(
async function* pickFromFiles(
multiple: boolean,
launch: typeof DocumentPicker.getDocumentAsync = DocumentPicker.getDocumentAsync,
createFile: MobileImageFileFactory = defaultMobileImageFileFactory
): Promise<PickedMobileImage | null> {
): AsyncGenerator<PickedMobileImage> {
const result = await launch({
type: 'image/*',
multiple: false,
multiple,
copyToCacheDirectory: true
})
if (result.canceled) {
return null
return
}
const asset = result.assets[0]
if (!asset?.uri) {
return null
for (const asset of result.assets) {
if (!asset.uri) {
continue
}
const base64 = await readUriAsBase64(asset.uri, asset.size, createFile)
if (base64) {
yield { base64, uri: asset.uri }
}
}
const base64 = await readUriAsBase64(asset.uri, asset.size, createFile)
return base64 ? { base64, uri: asset.uri } : null
}
type MobileImagePickerDeps = {
readonly requestLibraryPermission?: typeof ImagePicker.requestMediaLibraryPermissionsAsync
readonly launchLibrary?: typeof ImagePicker.launchImageLibraryAsync
readonly launchFiles?: typeof DocumentPicker.getDocumentAsync
readonly createFile?: MobileImageFileFactory
}
function pickMobileImagesWithMode(
source: MobileImageSource,
multiple: boolean,
deps?: MobileImagePickerDeps
): AsyncIterable<PickedMobileImage> {
if (source === 'library') {
return pickFromLibrary(
multiple,
deps?.requestLibraryPermission,
deps?.launchLibrary,
deps?.createFile
)
}
return pickFromFiles(multiple, deps?.launchFiles, deps?.createFile)
}
export async function pickMobileImage(
source: MobileImageSource,
deps?: {
readonly requestLibraryPermission?: typeof ImagePicker.requestMediaLibraryPermissionsAsync
readonly launchLibrary?: typeof ImagePicker.launchImageLibraryAsync
readonly launchFiles?: typeof DocumentPicker.getDocumentAsync
readonly createFile?: MobileImageFileFactory
}
deps?: MobileImagePickerDeps
): Promise<PickedMobileImage | null> {
if (source === 'library') {
return pickFromLibrary(deps?.requestLibraryPermission, deps?.launchLibrary, deps?.createFile)
for await (const image of pickMobileImagesWithMode(source, false, deps)) {
return image
}
return pickFromFiles(deps?.launchFiles, deps?.createFile)
return null
}
export function pickMobileImages(
source: MobileImageSource,
deps?: MobileImagePickerDeps
): AsyncIterable<PickedMobileImage> {
return pickMobileImagesWithMode(source, true, deps)
}
@@ -0,0 +1,64 @@
import type { Dispatch, MutableRefObject, SetStateAction } from 'react'
import type { detectAgentPermission } from './mobile-native-chat-permission'
import type { parseAgentQuestion } from './mobile-native-chat-question'
import type { AskAnswerSelection, AskPrompt, parseAskFromStatus } from './mobile-native-chat-ask'
import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send'
import type { MobileNativeChatPendingMessage } from './use-mobile-native-chat-drafts'
import type { useMobileNativeChatSession } from './use-mobile-native-chat-session'
import type { MobileNativeChatSessionOptionPickersProps } from './MobileNativeChatSessionOptionPickers'
export type MobileNativeChatController = {
/** Whether a tab's effective view is chat (per-tab override, else the default). */
isTabChatView: (tabId: string) => boolean
toggleTabChatView: (tabId: string) => void
showNativeChat: boolean
showNativeChatRef: MutableRefObject<boolean>
/** Resolved agent for the active chat tab (names the empty-state copy). */
nativeChatAgent: string | null
chatComposerText: string
setChatComposerText: Dispatch<SetStateAction<string>>
chatPending: MobileNativeChatPendingMessage[]
chatImagePreviewsByMessageId: Record<string, string[]>
nativeChatSession: ReturnType<typeof useMobileNativeChatSession>
nativeChatAgentWorking: boolean
nativeChatStreamingText?: string
/** Agent mid-turn, regardless of whether chat is the visible view. */
nativeChatStreamLive: boolean
/** Host/workspace/tab/session scope for stateful streaming suppression. */
nativeChatStreamScopeKey: string
nativeChatPermission: ReturnType<typeof detectAgentPermission>
nativeChatQuestion: ReturnType<typeof parseAgentQuestion>
/** The pending ask, already null while dismissed (dismissal lives here so it
* survives the chat-view subtree unmounting on a view toggle). */
nativeChatAsk: ReturnType<typeof parseAskFromStatus>
/** Stable key for the current ask card (keys the card component). */
nativeChatAskKey: string | null
/** Hide the current ask until a genuinely different question arrives. */
dismissNativeChatAsk: () => void
handleNativeChatAnswerAsk: (
prompt: AskPrompt,
selections: AskAnswerSelection[]
) => Promise<boolean>
handleNativeChatCancelAsk: () => Promise<boolean>
handleNativeChatRespondPermission: (text: string) => Promise<boolean>
handleNativeChatStop: () => void
nativeChatFilePaths: string[]
loadNativeChatFiles: (query: string) => void
handleNativeChatQuestionAnswer: (text: string) => Promise<boolean>
handleNativeChatSend: (text: string, images?: string[]) => Promise<boolean>
/** Outcome-preserving send: callers that pasted terminal input beforehand
* (image sends) must see 'unknown' to heal a possibly-orphaned paste. Such a
* caller passes its own `deadline` so the paste it already spent and this text
* body share one budget instead of holding the composer for two. */
handleNativeChatSendWithOutcome: (
text: string,
images?: string[],
deadline?: number
) => Promise<MobileNativeChatSendOutcome>
/** Launch-context text still parked on the agent's TUI input line, or null.
* Image sends read it to size their leading clear (one Ctrl+U per line). */
readSeededLaunchDraft: () => string | null
/** Model/session-option pickers for the composer, or null when the active
* agent has no session-option catalog. */
nativeChatSessionOptions: MobileNativeChatSessionOptionPickersProps | null
}
@@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import {
findLandedImagePreviewEchoes,
migrateImagePreviewMessageIds,
type PendingImagePreviewEcho
} from './mobile-native-chat-draft-reconcile'
function userText(id: string, text: string): NativeChatMessage {
return {
id,
role: 'user',
blocks: [{ type: 'text', text }],
timestamp: null,
source: 'transcript'
}
}
function pending(id: string, images: string[], expectedOccurrence = 1): PendingImagePreviewEcho {
return { id, text: '', images, expectedOccurrence, baselineTailMessageId: null }
}
describe('mobile native chat image preview reconciliation', () => {
it('keeps separate adjacent image-only sends independently reconcilable', () => {
const landed = findLandedImagePreviewEchoes(
[
userText('source-a', '[Image: source: /tmp/a.png]'),
userText('source-b', '[Image: source: /tmp/b.png]')
],
[pending('pending-a', ['file:///a.jpg']), pending('pending-b', ['file:///b.jpg'], 2)]
)
expect(landed).toEqual([
{ pendingId: 'pending-a', messageId: 'source-a', images: ['file:///a.jpg'] },
{ pendingId: 'pending-b', messageId: 'source-b', images: ['file:///b.jpg'] }
])
})
it('waits for a complete multi-image turn as transcript source frames stream in', () => {
const entry = pending('pending', ['file:///a.jpg', 'file:///b.jpg'])
const sourceA = userText('source-a', '[Image: source: /tmp/a.png]')
const sourceB = userText('source-b', '[Image: source: /tmp/b.png]')
expect(findLandedImagePreviewEchoes([sourceA], [entry])).toEqual([])
expect(findLandedImagePreviewEchoes([sourceA, sourceB], [entry])).toEqual([])
expect(
findLandedImagePreviewEchoes(
[sourceA, sourceB, userText('prompt', '[Image #1] [Image #2]')],
[entry]
)
).toEqual([
{
pendingId: 'pending',
messageId: 'prompt',
images: ['file:///a.jpg', 'file:///b.jpg']
}
])
})
it('moves an early standalone preview to the later folded prompt id', () => {
const sessionKey = 'host\0worktree\0tab\0session'
const previous = { [sessionKey]: { source: ['file:///a.jpg'] } }
const messages = [
userText('source', '[Image: source: /tmp/a.png]'),
userText('prompt', '[Image #1]')
]
expect(migrateImagePreviewMessageIds(previous, sessionKey, messages)).toEqual({
[sessionKey]: { prompt: ['file:///a.jpg'] }
})
})
})
@@ -1,6 +1,7 @@
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import { isImageRefBlock, type NativeChatMessage } from '../../../src/shared/native-chat-types'
import {
isImageSourceUserTurn,
normalizeImageTranscriptMessages,
stripImagePromptMarker
} from './mobile-native-chat-image-transcript-markers'
@@ -61,6 +62,149 @@ export function countImageSourceTurnsAfter(
return count
}
export type PendingImagePreviewEcho = {
id: string
text: string
images?: string[]
expectedOccurrence: number
baselineTailMessageId: string | null
}
export type LandedImagePreviewEcho = {
pendingId: string
messageId: string
images: string[]
}
const SENT_IMAGE_PREVIEW_LIMIT = 32
const SENT_IMAGE_PREVIEW_SESSION_LIMIT = 8
export function mergeLandedImagePreviewEchoes(
previous: Record<string, Record<string, string[]>>,
sessionKey: string,
landed: readonly LandedImagePreviewEcho[]
): Record<string, Record<string, string[]>> {
const entries = Object.entries(previous[sessionKey] ?? {})
for (const preview of landed) {
const existingIndex = entries.findIndex(([messageId]) => messageId === preview.messageId)
if (existingIndex >= 0) {
entries.splice(existingIndex, 1)
}
entries.push([preview.messageId, preview.images])
}
const next = { ...previous }
delete next[sessionKey]
next[sessionKey] = Object.fromEntries(entries.slice(-SENT_IMAGE_PREVIEW_LIMIT))
for (const key of Object.keys(next).slice(0, -SENT_IMAGE_PREVIEW_SESSION_LIMIT)) {
delete next[key]
}
return next
}
function imagePreviewReplacementMessageId(
messages: readonly NativeChatMessage[],
sourceIndex: number
): string | null {
const source = messages[sourceIndex]
if (!source || !isImageSourceUserTurn(source)) {
return null
}
let nextIndex = sourceIndex + 1
while (
messages[nextIndex]?.source === source.source &&
isImageSourceUserTurn(messages[nextIndex]!)
) {
nextIndex++
}
const prompt = messages[nextIndex]
const firstText = prompt?.blocks.find((block) => block.type === 'text')
return prompt?.role === 'user' &&
prompt.source === source.source &&
firstText?.type === 'text' &&
stripImagePromptMarker(firstText.text) !== firstText.text
? prompt.id
: null
}
/** Moves previews forward when a progressive source-only transcript frame later
* folds into the marker-prefixed prompt with a different authoritative id. */
export function migrateImagePreviewMessageIds(
previous: Record<string, Record<string, string[]>>,
sessionKey: string,
messages: readonly NativeChatMessage[]
): Record<string, Record<string, string[]>> {
const sessionPreviews = previous[sessionKey]
if (!sessionPreviews) {
return previous
}
const messageIndexById = new Map(messages.map((message, index) => [message.id, index]))
let nextSession: Record<string, string[]> | null = null
for (const [messageId, images] of Object.entries(sessionPreviews)) {
const sourceIndex = messageIndexById.get(messageId)
if (sourceIndex === undefined) {
continue
}
const replacementId = imagePreviewReplacementMessageId(messages, sourceIndex)
if (!replacementId) {
continue
}
nextSession ??= { ...sessionPreviews }
delete nextSession[messageId]
nextSession[replacementId] = [...(nextSession[replacementId] ?? []), ...images]
}
return nextSession ? { ...previous, [sessionKey]: nextSession } : previous
}
/** Binds local preview URIs to the authoritative transcript turn that replaced
* the optimistic bubble. Host paths and marker-only Codex turns cannot render
* the phone-local photo without this handoff. */
export function findLandedImagePreviewEchoes(
messages: readonly NativeChatMessage[],
entries: readonly PendingImagePreviewEcho[]
): LandedImagePreviewEcho[] {
const normalized = normalizeImageTranscriptMessages(messages)
const messageIndexById = new Map(normalized.map((message, index) => [message.id, index]))
const claimedMessageIds = new Set<string>()
const landed: LandedImagePreviewEcho[] = []
for (const entry of entries) {
if (!entry.images?.length) {
continue
}
const targetText = entry.text.trim()
const candidates = normalized.filter((message) => {
if (message.role !== 'user') {
return false
}
if (targetText) {
return normalizedUserText(message) === targetText
}
const imageCount = message.blocks.filter(isImageRefBlock).length
return message.blocks.length === 0 || imageCount >= entry.images!.length
})
const tailIndex = entry.baselineTailMessageId
? messageIndexById.get(entry.baselineTailMessageId)
: -1
const occurrenceIndex = Math.max(0, entry.expectedOccurrence - 1)
const candidate = targetText
? candidates[occurrenceIndex]
: candidates.filter(
(message) =>
tailIndex === undefined || (messageIndexById.get(message.id) ?? -1) > tailIndex
)[occurrenceIndex]
if (
!candidate ||
claimedMessageIds.has(candidate.id) ||
(tailIndex !== undefined && (messageIndexById.get(candidate.id) ?? -1) <= tailIndex)
) {
continue
}
claimedMessageIds.add(candidate.id)
landed.push({ pendingId: entry.id, messageId: candidate.id, images: entry.images })
}
return landed
}
export function findLandedUnconfirmedSends(
messages: readonly NativeChatMessage[],
entries: readonly UnconfirmedSend[]
@@ -1,7 +1,7 @@
import { describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcResponse, RpcSuccess } from '../transport/types'
import { uploadMobileNativeChatImage } from './mobile-native-chat-image-attachment'
import { uploadMobileNativeChatImages } from './mobile-native-chat-image-attachment'
function ok(id: string, result: unknown): RpcSuccess {
return { id, ok: true, result, _meta: { runtimeId: 'runtime-1' } }
@@ -16,6 +16,10 @@ function methodNotFound(id: string): RpcResponse {
}
}
function failed(id: string, message: string): RpcResponse {
return { id, ok: false, error: { code: 'failed', message }, _meta: { runtimeId: 'r' } }
}
function clientWithResponses(responses: RpcResponse[]): Pick<RpcClient, 'sendRequest'> & {
calls: { method: string; params: unknown }[]
} {
@@ -33,67 +37,137 @@ function clientWithResponses(responses: RpcResponse[]): Pick<RpcClient, 'sendReq
}
}
describe('uploadMobileNativeChatImage', () => {
describe('uploadMobileNativeChatImages', () => {
it('uploads the picked image and returns its host path + local preview uri, without any terminal.send', async () => {
const client = clientWithResponses([
methodNotFound('start'),
ok('save', '/tmp/orca-attach.png')
])
const result = await uploadMobileNativeChatImage('library', {
const result = await uploadMobileNativeChatImages('library', {
client,
getConnectionId: async () => 'conn-7',
pickImage: vi.fn().mockResolvedValue({ base64: 'AAAA', uri: 'file:///photo.jpg' })
pickImages: vi.fn().mockResolvedValue([{ base64: 'AAAA', uri: 'file:///photo.jpg' }])
})
expect(result).toEqual({ path: '/tmp/orca-attach.png', previewUri: 'file:///photo.jpg' })
expect(result).toEqual([{ path: '/tmp/orca-attach.png', previewUri: 'file:///photo.jpg' }])
// Native chat defers the paste to submit — nothing is sent to the terminal here.
expect(client.calls.some((call) => call.method === 'terminal.send')).toBe(false)
const saveCall = client.calls.find((c) => c.method === 'clipboard.saveImageAsTempFile')
expect(saveCall?.params).toMatchObject({ connectionId: 'conn-7' })
})
it('uploads all three selected images in picker order', async () => {
const client = clientWithResponses([
methodNotFound('start-a'),
ok('save-a', '/tmp/a.png'),
methodNotFound('start-b'),
ok('save-b', '/tmp/b.png'),
methodNotFound('start-c'),
ok('save-c', '/tmp/c.png')
])
const order: string[] = []
async function* pickImages() {
for (const image of [
{ base64: 'AAAA', uri: 'file:///a.jpg' },
{ base64: 'BBBB', uri: 'file:///b.jpg' },
{ base64: 'CCCC', uri: 'file:///c.jpg' }
]) {
order.push(`read:${image.uri}`)
yield image
}
}
const result = await uploadMobileNativeChatImages('library', {
client,
getConnectionId: async () => 'conn-7',
pickImages,
onImageUploaded: (image) => order.push(`uploaded:${image.previewUri}`)
})
expect(result).toEqual([
{ path: '/tmp/a.png', previewUri: 'file:///a.jpg' },
{ path: '/tmp/b.png', previewUri: 'file:///b.jpg' },
{ path: '/tmp/c.png', previewUri: 'file:///c.jpg' }
])
expect(order).toEqual([
'read:file:///a.jpg',
'uploaded:file:///a.jpg',
'read:file:///b.jpg',
'uploaded:file:///b.jpg',
'read:file:///c.jpg',
'uploaded:file:///c.jpg'
])
})
it('returns null when the picker is cancelled and uploads nothing', async () => {
const client = clientWithResponses([])
const result = await uploadMobileNativeChatImage('library', {
const result = await uploadMobileNativeChatImages('library', {
client,
getConnectionId: async () => null,
pickImage: vi.fn().mockResolvedValue(null)
pickImages: vi.fn().mockResolvedValue([])
})
expect(result).toBeNull()
expect(result).toEqual([])
expect(client.calls).toEqual([])
})
it('reports completed uploads before a later image fails', async () => {
const client = clientWithResponses([
methodNotFound('start-a'),
ok('save-a', '/tmp/a.png'),
methodNotFound('start-b'),
failed('save-b', 'upload failed')
])
const onImageUploaded = vi.fn()
await expect(
uploadMobileNativeChatImages('library', {
client,
getConnectionId: async () => null,
pickImages: vi.fn().mockResolvedValue([
{ base64: 'AAAA', uri: 'file:///a.jpg' },
{ base64: 'BBBB', uri: 'file:///b.jpg' }
]),
onImageUploaded
})
).rejects.toThrow('upload failed')
expect(onImageUploaded).toHaveBeenCalledOnce()
expect(onImageUploaded).toHaveBeenCalledWith({
path: '/tmp/a.png',
previewUri: 'file:///a.jpg'
})
})
it('falls back to an inline data uri for the preview when the picker omits a uri', async () => {
const client = clientWithResponses([methodNotFound('start'), ok('save', '/tmp/x.png')])
const result = await uploadMobileNativeChatImage('files', {
const result = await uploadMobileNativeChatImages('files', {
client,
getConnectionId: async () => null,
pickImage: vi.fn().mockResolvedValue({ base64: 'BBBB' })
pickImages: vi.fn().mockResolvedValue([{ base64: 'BBBB' }])
})
expect(result).toEqual({ path: '/tmp/x.png', previewUri: 'data:image/png;base64,BBBB' })
expect(result).toEqual([{ path: '/tmp/x.png', previewUri: 'data:image/png;base64,BBBB' }])
})
it('signals upload start only after a real image is picked', async () => {
const onUploadStart = vi.fn()
const cancelledClient = clientWithResponses([])
await uploadMobileNativeChatImage('library', {
await uploadMobileNativeChatImages('library', {
client: cancelledClient,
getConnectionId: async () => null,
pickImage: vi.fn().mockResolvedValue(null),
pickImages: vi.fn().mockResolvedValue([]),
onUploadStart
})
expect(onUploadStart).not.toHaveBeenCalled()
const client = clientWithResponses([methodNotFound('start'), ok('save', '/tmp/y.png')])
await uploadMobileNativeChatImage('library', {
await uploadMobileNativeChatImages('library', {
client,
getConnectionId: async () => null,
pickImage: vi.fn().mockResolvedValue({ base64: 'CCCC', uri: 'file:///y.jpg' }),
pickImages: vi.fn().mockResolvedValue([{ base64: 'CCCC', uri: 'file:///y.jpg' }]),
onUploadStart
})
expect(onUploadStart).toHaveBeenCalledTimes(1)
@@ -13,34 +13,67 @@ export type PendingNativeChatImage = {
readonly previewUri: string
}
export type UploadNativeChatImageDeps = {
export function appendPendingNativeChatImages(
current: readonly PendingNativeChatImage[],
uploaded: readonly Omit<PendingNativeChatImage, 'id'>[],
idCounter: { current: number }
): PendingNativeChatImage[] {
return [
...current,
...uploaded.map((image) => {
idCounter.current += 1
return { id: `img-${idCounter.current}`, ...image }
})
]
}
export type UploadNativeChatImagesDeps = {
readonly client: Pick<RpcClient, 'sendRequest'>
readonly getConnectionId: () => Promise<string | null>
// Injected so this module stays free of expo/react-native imports (unit-testable).
readonly pickImage: (source: MobileImageSource) => Promise<PickedMobileImage | null>
readonly pickImages: (
source: MobileImageSource
) =>
| Iterable<PickedMobileImage>
| AsyncIterable<PickedMobileImage>
| Promise<Iterable<PickedMobileImage> | AsyncIterable<PickedMobileImage>>
// Fired once the user has picked an image and the host upload is about to start —
// lets the UI show the attach spinner only for the transfer, not the picker.
readonly onUploadStart?: () => void
/** Retains each completed upload if a later image in the same selection fails. */
readonly onImageUploaded?: (image: Omit<PendingNativeChatImage, 'id'>) => void
}
/** Picks an image and uploads it to the host, returning the host path + a local
* preview URI — but does NOT paste it into the terminal. Unlike the terminal
* attach flow, native chat holds the image as a composer chip and rides it along
* on submit (desktop parity), so the chip and the agent input never diverge.
* Returns null when the user cancels the picker. */
export async function uploadMobileNativeChatImage(
* Returns an empty array when the user cancels the picker. */
export async function uploadMobileNativeChatImages(
source: MobileImageSource,
{ client, getConnectionId, pickImage, onUploadStart }: UploadNativeChatImageDeps
): Promise<Omit<PendingNativeChatImage, 'id'> | null> {
const picked = await pickImage(source)
if (!picked) {
return null
{
client,
getConnectionId,
pickImages,
onUploadStart,
onImageUploaded
}: UploadNativeChatImagesDeps
): Promise<Omit<PendingNativeChatImage, 'id'>[]> {
const picked = await pickImages(source)
const uploaded: Omit<PendingNativeChatImage, 'id'>[] = []
let connectionId: string | null = null
for await (const image of picked) {
if (uploaded.length === 0) {
onUploadStart?.()
connectionId = await getConnectionId()
}
const path = await saveMobileClipboardImageAsTempFile(client, image.base64, { connectionId })
// Prefer the picker's local URI for the thumbnail; fall back to an inline data
// URI when the source omitted one (RN <Image> renders both).
const previewUri = image.uri ?? `data:image/png;base64,${image.base64}`
const result = { path, previewUri }
uploaded.push(result)
onImageUploaded?.(result)
}
onUploadStart?.()
const connectionId = await getConnectionId()
const path = await saveMobileClipboardImageAsTempFile(client, picked.base64, { connectionId })
// Prefer the picker's local URI for the thumbnail; fall back to an inline data
// URI when the source omitted one (RN <Image> renders both).
const previewUri = picked.uri ?? `data:image/png;base64,${picked.base64}`
return { path, previewUri }
return uploaded
}
@@ -27,17 +27,22 @@ function clientWithResponses(responses: RpcResponse[]): Pick<RpcClient, 'sendReq
describe('pasteMobileNativeChatImagePaths', () => {
it('clears the input line, then pastes each path as a bracketed, non-submitting terminal.send with the mobile client tag', async () => {
const client = clientWithResponses([sendResult(true), sendResult(true), sendResult(true)])
const client = clientWithResponses([
sendResult(true),
sendResult(true),
sendResult(true),
sendResult(true)
])
const ok = await pasteMobileNativeChatImagePaths({
client,
terminal: 'term-1',
deviceToken: 'device-9',
imagePaths: ['/tmp/a.png', '/tmp/b.png']
imagePaths: ['/tmp/a.png', '/tmp/b.png', '/tmp/c.png']
})
expect(ok).toBe(true)
expect(client.calls).toHaveLength(3)
expect(client.calls).toHaveLength(4)
// Leading Ctrl+U clears any stale input so a retry can't duplicate the image.
expect(client.calls[0]).toEqual({
method: 'terminal.send',
@@ -50,6 +55,7 @@ describe('pasteMobileNativeChatImagePaths', () => {
})
expect(client.calls[1]?.params.text).toBe('\x1b[200~/tmp/a.png\x1b[201~')
expect(client.calls[2]?.params.text).toBe('\x1b[200~/tmp/b.png\x1b[201~')
expect(client.calls[3]?.params.text).toBe('\x1b[200~/tmp/c.png\x1b[201~')
})
it('stops and reports failure as soon as a paste is rejected', async () => {
@@ -0,0 +1,91 @@
export type MobileNativeChatPendingMessage = {
id: string
text: string
expectedOccurrence: number
/** Local preview URIs carried by the send for its optimistic echo. */
images?: string[]
baselineTailMessageId: string | null
}
export type MobileNativeChatSendOrigin = {
draftKey: string
pendingKey: string | null
normalizedText: string
baselineOccurrences: number
baselineTailMessageId: string | null
}
type PendingByKey = Record<string, MobileNativeChatPendingMessage[]>
export function combineMobileNativeChatPending(
session: MobileNativeChatPendingMessage[],
waiting: readonly MobileNativeChatPendingMessage[]
): MobileNativeChatPendingMessage[] {
if (waiting.length === 0) {
return session
}
const sessionIds = new Set(session.map((item) => item.id))
return [...session, ...waiting.filter((item) => !sessionIds.has(item.id))]
}
export function appendMobileNativeChatPending(
previous: PendingByKey,
key: string,
id: string,
origin: MobileNativeChatSendOrigin,
text: string,
images?: string[]
): PendingByKey {
const current = previous[key] ?? []
const earlierOutstanding = current.filter(
(pending) =>
pending.text.trim() === origin.normalizedText &&
pending.expectedOccurrence > origin.baselineOccurrences
).length
const expectedImageEchoOrdinal =
current.filter((pending) => pending.text.trim() === '' && pending.images?.length).length + 1
return {
...previous,
[key]: [
...current,
{
id,
text,
expectedOccurrence:
origin.normalizedText === ''
? expectedImageEchoOrdinal
: origin.baselineOccurrences + earlierOutstanding + 1,
baselineTailMessageId: origin.baselineTailMessageId,
...(images?.length ? { images } : {})
}
]
}
}
export function mergeWaitingSessionPending(
previous: PendingByKey,
sessionKey: string,
waiting: readonly MobileNativeChatPendingMessage[]
): PendingByKey {
const current = previous[sessionKey] ?? []
const currentIds = new Set(current.map((item) => item.id))
const moved = waiting.filter((item) => !currentIds.has(item.id))
return moved.length > 0 ? { ...previous, [sessionKey]: [...current, ...moved] } : previous
}
export function removeWaitingSessionPending(
previous: PendingByKey,
draftKey: string,
movedIds: ReadonlySet<string>
): PendingByKey {
const remaining = (previous[draftKey] ?? []).filter((item) => !movedIds.has(item.id))
if (remaining.length > 0) {
return { ...previous, [draftKey]: remaining }
}
if (!(draftKey in previous)) {
return previous
}
const next = { ...previous }
delete next[draftKey]
return next
}
@@ -116,6 +116,36 @@ describe('buildMobileNativeChatTransientData', () => {
expect(data[0]?.blocks).toEqual([{ type: 'image-ref', path: '/tmp/a.png' }])
})
it('keeps the phone-local image visible when the transcript replaces its optimistic echo', () => {
const folded = foldMobileNativeChatMessages([
user('source', '[Image: source: /tmp/a.png]'),
user('prompt', '[Image #1] look at this')
])
const result = buildMobileNativeChatTransientData({
folded,
streaming: null,
pending: [],
imagePreviewsByMessageId: { prompt: ['file:///phone-photo.jpg'] }
})
expect(result.data).toHaveLength(1)
expect(result.data[0]?.blocks).toEqual([
{ type: 'image-ref', path: '/tmp/a.png', url: 'file:///phone-photo.jpg' },
{ type: 'text', text: 'look at this' }
])
})
it('restores the local preview onto a marker-only transcript turn', () => {
const result = buildMobileNativeChatTransientData({
folded: foldMobileNativeChatMessages([user('prompt', '[Image #1]')]),
streaming: null,
pending: [],
imagePreviewsByMessageId: { prompt: ['file:///phone-photo.jpg'] }
})
expect(result.data[0]?.blocks).toEqual([{ type: 'image-ref', url: 'file:///phone-photo.jpg' }])
})
it('appends a synthetic bubble for gated streaming text, between transcript and pending', () => {
// Whether text streams at all is the gate's call
// (`mobile-native-chat-streaming-gate.test.ts`); this only places it.
@@ -3,7 +3,7 @@ import {
formatNativeChatEmptyStateCopy,
type NativeChatEmptyStateCopy
} from '../../../src/shared/native-chat-empty-state'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import { isImageRefBlock, type NativeChatMessage } from '../../../src/shared/native-chat-types'
import { foldToolMessages } from './mobile-native-chat-blocks'
import { normalizeImageTranscriptMessages } from './mobile-native-chat-image-transcript-markers'
import { stripNoiseMessages } from './mobile-native-chat-noise'
@@ -55,15 +55,37 @@ export function foldMobileNativeChatMessages(messages: NativeChatMessage[]): Nat
export function buildMobileNativeChatTransientData({
folded,
streaming,
pending
pending,
imagePreviewsByMessageId
}: {
folded: NativeChatMessage[]
/** Streaming bubble text, already gated by `deriveMobileNativeChatStreaming`. */
streaming: string | null
pending: MobileNativeChatPendingItem[]
imagePreviewsByMessageId?: Record<string, string[]>
}): { folded: NativeChatMessage[]; streaming: string | null; data: NativeChatMessage[] } {
const renderedFolded = folded.map((message) => {
const previews = imagePreviewsByMessageId?.[message.id]
if (message.role !== 'user' || !previews?.length) {
return message
}
let previewIndex = 0
const blocks = message.blocks.map((block) => {
if (!isImageRefBlock(block)) {
return block
}
const url = previews[previewIndex]
previewIndex += 1
return url ? { ...block, url } : block
})
while (previewIndex < previews.length) {
blocks.push({ type: 'image-ref', url: previews[previewIndex] })
previewIndex += 1
}
return { ...message, blocks }
})
const data: NativeChatMessage[] = [
...folded,
...renderedFolded,
...(streaming
? [
{
@@ -88,5 +110,5 @@ export function buildMobileNativeChatTransientData({
source: 'transcript' as const
}))
]
return { folded, streaming, data }
return { folded: renderedFolded, streaming, data }
}
@@ -81,7 +81,7 @@ describe('mobile session route', () => {
name: 'Fix #1'
})
navigateToHostStackRoute(harness.navigation, { push }, 'host/one', target)
navigateToHostStackRoute(harness.navigation, { push, replace: vi.fn() }, 'host/one', target)
expect(push).toHaveBeenCalledWith(hostStackHostRoute('host/one'))
expect(harness.navigation.dispatch).not.toHaveBeenCalled()
@@ -40,6 +40,7 @@ vi.mock('./use-mobile-native-chat-drafts', () => ({
composerText: '',
setComposerText: vi.fn(),
pending: [],
imagePreviewsByMessageId: {},
captureSendOrigin,
readSeededLaunchDraft: () => null,
readSeededLaunchDraftSeed: () => null,
@@ -1,99 +1,29 @@
import {
useCallback,
useLayoutEffect,
useRef,
type Dispatch,
type MutableRefObject,
type SetStateAction
} from 'react'
import { useCallback, useLayoutEffect, useRef, type MutableRefObject } from 'react'
import { encodeNativeChatTranscriptIdentity } from '../../../src/shared/native-chat-transcript-retention'
import { useMobileSessionViewMode } from './use-mobile-session-view-mode'
import type { RpcClient } from '../transport/rpc-client'
import type { ConnectionState } from '../transport/types'
import {
parseAskFromStatus,
type AskAnswerSelection,
type AskPrompt
} from './mobile-native-chat-ask'
import { type MobileNativeChatTab, resolveMobileNativeChat } from './mobile-native-chat-eligibility'
import { detectAgentPermission } from './mobile-native-chat-permission'
import { parseAgentQuestion } from './mobile-native-chat-question'
import { useMobileNativeChatPermissionSend } from './mobile-native-chat-permission-send'
import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send'
import { useMobileNativeChatAnswerSend } from './use-mobile-native-chat-answer-send'
import { useMobileNativeChatAskDismiss } from './use-mobile-native-chat-ask-dismiss'
import { useMobileNativeChatCancelAsk } from './use-mobile-native-chat-cancel-ask'
import {
useMobileNativeChatDrafts,
type MobileNativeChatPendingMessage
} from './use-mobile-native-chat-drafts'
import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts'
import { useMobileNativeChatFileSearch } from './use-mobile-native-chat-file-search'
import { useMobileNativeChatMessageSend } from './use-mobile-native-chat-message-send'
import { mobileNativeChatScopeKey } from './mobile-native-chat-scope-key'
import { useMobileNativeChatSession } from './use-mobile-native-chat-session'
import { useMobileNativeChatSessionOptions } from './use-mobile-native-chat-session-options'
import type { MobileNativeChatSessionOptionPickersProps } from './MobileNativeChatSessionOptionPickers'
import { useMobileNativeChatPrompts } from './use-mobile-native-chat-prompts'
import { useMobileNativeChatStop } from './use-mobile-native-chat-stop'
import { useNativeChatAcceptedAction } from './use-native-chat-action-outcomes'
import { useThrottledLatestValue } from './use-throttled-latest-value'
import type { MobileNativeChatController } from './mobile-native-chat-controller-contract'
export type { MobileNativeChatController } from './mobile-native-chat-controller-contract'
const NATIVE_CHAT_STREAM_THROTTLE_MS = 50
export type MobileNativeChatController = {
/** Whether a tab's effective view is chat (per-tab override, else the default). */
isTabChatView: (tabId: string) => boolean
toggleTabChatView: (tabId: string) => void
showNativeChat: boolean
showNativeChatRef: MutableRefObject<boolean>
/** Resolved agent for the active chat tab (names the empty-state copy). */
nativeChatAgent: string | null
chatComposerText: string
setChatComposerText: Dispatch<SetStateAction<string>>
chatPending: MobileNativeChatPendingMessage[]
nativeChatSession: ReturnType<typeof useMobileNativeChatSession>
nativeChatAgentWorking: boolean
nativeChatStreamingText?: string
/** Agent mid-turn, regardless of whether chat is the visible view. */
nativeChatStreamLive: boolean
/** Host/workspace/tab/session scope for stateful streaming suppression. */
nativeChatStreamScopeKey: string
nativeChatPermission: ReturnType<typeof detectAgentPermission>
nativeChatQuestion: ReturnType<typeof parseAgentQuestion>
/** The pending ask, already null while dismissed (dismissal lives here so it
* survives the chat-view subtree unmounting on a view toggle). */
nativeChatAsk: ReturnType<typeof parseAskFromStatus>
/** Stable key for the current ask card (keys the card component). */
nativeChatAskKey: string | null
/** Hide the current ask until a genuinely different question arrives. */
dismissNativeChatAsk: () => void
handleNativeChatAnswerAsk: (
prompt: AskPrompt,
selections: AskAnswerSelection[]
) => Promise<boolean>
handleNativeChatCancelAsk: () => Promise<boolean>
handleNativeChatRespondPermission: (text: string) => Promise<boolean>
handleNativeChatStop: () => void
nativeChatFilePaths: string[]
loadNativeChatFiles: (query: string) => void
handleNativeChatQuestionAnswer: (text: string) => Promise<boolean>
handleNativeChatSend: (text: string, images?: string[]) => Promise<boolean>
/** Outcome-preserving send: callers that pasted terminal input beforehand
* (image sends) must see 'unknown' to heal a possibly-orphaned paste. Such a
* caller passes its own `deadline` so the paste it already spent and this text
* body share one budget instead of holding the composer for two. */
handleNativeChatSendWithOutcome: (
text: string,
images?: string[],
deadline?: number
) => Promise<MobileNativeChatSendOutcome>
/** Launch-context text still parked on the agent's TUI input line, or null.
* Image sends read it to size their leading clear (one Ctrl+U per line). */
readSeededLaunchDraft: () => string | null
/** Model/session-option pickers for the composer, or null when the active
* agent has no session-option catalog. */
nativeChatSessionOptions: MobileNativeChatSessionOptionPickersProps | null
}
/** Owns mobile native-chat state and teardown outside the already dense session
* route. The route remains responsible only for choosing and rendering the view. */
export function useMobileNativeChatController(args: {
@@ -152,6 +82,7 @@ export function useMobileNativeChatController(args: {
const nativeChatSession = useMobileNativeChatSession({
client,
sourceIdentity: encodeNativeChatTranscriptIdentity([hostId, worktreeId]),
agent: activeChatResolution?.agent ?? null,
sessionId: activeChatSessionId,
transcriptPath: activeChatResolution?.transcriptPath ?? null
@@ -160,6 +91,7 @@ export function useMobileNativeChatController(args: {
composerText: chatComposerText,
setComposerText: setChatComposerText,
pending: chatPending,
imagePreviewsByMessageId: chatImagePreviewsByMessageId,
captureSendOrigin,
readSeededLaunchDraft,
readSeededLaunchDraftSeed,
@@ -201,7 +133,8 @@ export function useMobileNativeChatController(args: {
} = useMobileNativeChatPrompts({
enabled: activeChatResolution != null,
status: nativeChatStatus,
messages: nativeChatSession.messages
messages: nativeChatSession.messages,
transcriptLoading: nativeChatSession.transcriptLoading
})
// A never-read transcript cannot prove that a dismissed prompt cleared.
const nativeChatTranscriptSettled =
@@ -326,6 +259,7 @@ export function useMobileNativeChatController(args: {
chatComposerText,
setChatComposerText,
chatPending,
chatImagePreviewsByMessageId,
nativeChatSession,
nativeChatAgentWorking,
nativeChatStreamingText,
@@ -269,6 +269,7 @@ describe('useMobileNativeChatDrafts', () => {
)
)
expect(state?.pending).toEqual([])
expect(state?.imagePreviewsByMessageId).toEqual({ u1: ['file:///a.jpg'] })
})
it("keeps an image-only echo when an unrelated text send's echo lands", async () => {
@@ -345,6 +346,29 @@ describe('useMobileNativeChatDrafts', () => {
)
)
expect(state?.pending).toEqual([])
expect(state?.imagePreviewsByMessageId).toEqual({ u2: ['file:///a.jpg'] })
})
it('hands a marker-only image preview to the authoritative user bubble', async () => {
await mount('a')
const origin = state?.captureSendOrigin('')
act(() => {
if (origin) {
state?.acceptSend(origin, '', ['file:///a.jpg'])
}
})
await act(async () =>
renderer?.update(
createElement(Harness, {
tabId: 'a',
messages: [userTextMessage('u1', '[Image #1]')]
})
)
)
expect(state?.pending).toEqual([])
expect(state?.imagePreviewsByMessageId).toEqual({ u1: ['file:///a.jpg'] })
})
it('does not reconcile a repeated send against an older identical turn', async () => {
@@ -722,21 +746,59 @@ describe('useMobileNativeChatDrafts', () => {
}
})
it('accepts and clears the first send before a provider session id exists', async () => {
it('preserves first-send images through session assignment and transcript replacement', async () => {
await mount('a')
await act(async () => renderer?.update(createElement(Harness, { tabId: 'a', sessionId: null })))
act(() => state?.setComposerText('start the session'))
const images = ['file:///a.jpg', 'file:///b.jpg', 'file:///c.jpg']
act(() => state?.setComposerText('look'))
const origin = state?.captureSendOrigin('start the session')
const origin = state?.captureSendOrigin('look')
expect(origin).toMatchObject({ pendingKey: null })
act(() => {
if (origin) {
state?.clearDraftForSend(origin, 'start the session')
state?.acceptSend(origin, 'start the session')
state?.clearDraftForSend(origin, 'look')
state?.acceptSend(origin, 'look', images)
}
})
expect(state?.composerText).toBe('')
expect(state?.pending.map((pending) => pending.images)).toEqual([images])
await act(async () =>
renderer?.update(createElement(Harness, { tabId: 'a', sessionId: 'assigned' }))
)
expect(state?.pending.map((pending) => pending.images)).toEqual([images])
await act(async () =>
renderer?.update(
createElement(Harness, {
tabId: 'a',
sessionId: 'assigned',
messages: [
userTextMessage('source-1', '[Image: source: /tmp/a.png]'),
userTextMessage('source-2', '[Image: source: /tmp/b.png]'),
userTextMessage('source-3', '[Image: source: /tmp/c.png]')
]
})
)
)
expect(state?.pending.map((pending) => pending.images)).toEqual([images])
await act(async () =>
renderer?.update(
createElement(Harness, {
tabId: 'a',
sessionId: 'assigned',
messages: [
userTextMessage('source-1', '[Image: source: /tmp/a.png]'),
userTextMessage('source-2', '[Image: source: /tmp/b.png]'),
userTextMessage('source-3', '[Image: source: /tmp/c.png]'),
userTextMessage('prompt', '[Image #1] [Image #2] [Image #3] look')
]
})
)
)
expect(state?.pending).toEqual([])
expect(state?.imagePreviewsByMessageId).toEqual({ prompt: images })
})
})
@@ -3,35 +3,29 @@ import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import {
countImageSourceTurnsAfter,
countUserTextOccurrences,
findLandedImagePreviewEchoes,
findLandedUnconfirmedSends,
mergeLandedImagePreviewEchoes,
migrateImagePreviewMessageIds,
normalizedUserText,
type UnconfirmedSend
} from './mobile-native-chat-draft-reconcile'
import {
appendMobileNativeChatPending,
combineMobileNativeChatPending,
mergeWaitingSessionPending,
removeWaitingSessionPending,
type MobileNativeChatPendingMessage,
type MobileNativeChatSendOrigin
} from './mobile-native-chat-pending-echo'
import { mobileNativeChatScopeKey } from './mobile-native-chat-scope-key'
import { useMobileNativeChatLaunchDraftSeed } from './use-mobile-native-chat-launch-draft-seed'
import type { MobileNativeChatLaunchDraftSeed } from './use-mobile-native-chat-launch-draft-seed'
export type MobileNativeChatPendingMessage = {
id: string
text: string
expectedOccurrence: number
/** Local preview URIs of images ridden along on the send, rendered as thumbnails
* on the echo bubble so the sent photo shows before the transcript catches up. */
images?: string[]
/** Transcript tail when sent — an image-only echo (no text to match) reconciles
* against new `[Image: source: …]` echo turns after this id, so pagination,
* agent replies, and unrelated text echoes can't clear it early. */
baselineTailMessageId: string | null
}
export type MobileNativeChatSendOrigin = {
draftKey: string
pendingKey: string | null
normalizedText: string
baselineOccurrences: number
baselineTailMessageId: string | null
}
export type { MobileNativeChatPendingMessage, MobileNativeChatSendOrigin }
const NO_PENDING_MESSAGES: MobileNativeChatPendingMessage[] = []
const NO_IMAGE_PREVIEWS: Record<string, string[]> = {}
// How long an ack-lost send waits for its transcript echo before the UI surfaces
// that delivery remains unconfirmed.
@@ -57,6 +51,9 @@ export function useMobileNativeChatDrafts(args: {
composerText: string
setComposerText: Dispatch<SetStateAction<string>>
pending: MobileNativeChatPendingMessage[]
/** Phone-local previews rebound to the transcript message that replaced the
* optimistic echo, keyed by authoritative message id. */
imagePreviewsByMessageId: Record<string, string[]>
captureSendOrigin: (text: string) => MobileNativeChatSendOrigin | null
/** Launch-context text still believed to be parked on the agent's TUI input
* line, or null once it has been declined or retired. Send paths size their
@@ -91,6 +88,12 @@ export function useMobileNativeChatDrafts(args: {
const [pendingBySession, setPendingBySession] = useState<
Record<string, MobileNativeChatPendingMessage[]>
>({})
const [pendingWaitingForSession, setPendingWaitingForSession] = useState<
Record<string, MobileNativeChatPendingMessage[]>
>({})
const [imagePreviewsBySession, setImagePreviewsBySession] = useState<
Record<string, Record<string, string[]>>
>({})
const pendingCounterRef = useRef(0)
const messagesRef = useRef(messages)
messagesRef.current = messages
@@ -162,41 +165,21 @@ export function useMobileNativeChatDrafts(args: {
const acceptSend = useCallback(
(origin: MobileNativeChatSendOrigin, text: string, images?: string[]) => {
// Why: the first prompt can be sent before the provider reports a session
// id; wait for an id before keying an optimistic echo.
if (!origin.pendingKey) {
if (!origin.pendingKey && !images?.length) {
return
}
const pendingKey = origin.pendingKey
pendingCounterRef.current += 1
setPendingBySession((previous) => {
const current = previous[pendingKey] ?? NO_PENDING_MESSAGES
const earlierOutstanding = current.filter(
(pending) =>
pending.text.trim() === origin.normalizedText &&
pending.expectedOccurrence > origin.baselineOccurrences
).length
// An empty-text send reconciles by image-echo ordinal: every outstanding
// send's ridden-along images echo as `[Image: source: …]` turns after
// this send's baseline tail, ahead of this send's own echo.
const expectedImageEchoOrdinal =
current.reduce(
(sum, pending) =>
sum + (pending.images?.length ?? (pending.text.trim() === '' ? 1 : 0)),
0
) + 1
const pending: MobileNativeChatPendingMessage = {
id: `pending-${pendingCounterRef.current}`,
text,
expectedOccurrence:
origin.normalizedText === ''
? expectedImageEchoOrdinal
: origin.baselineOccurrences + earlierOutstanding + 1,
baselineTailMessageId: origin.baselineTailMessageId,
...(images && images.length > 0 ? { images } : {})
}
return { ...previous, [pendingKey]: [...current, pending] }
})
const id = `pending-${pendingCounterRef.current}`
const key = origin.pendingKey
if (key) {
setPendingBySession((previous) =>
appendMobileNativeChatPending(previous, key, id, origin, text, images)
)
} else {
setPendingWaitingForSession((previous) =>
appendMobileNativeChatPending(previous, origin.draftKey, id, origin, text, images)
)
}
},
[]
)
@@ -255,9 +238,7 @@ export function useMobileNativeChatDrafts(args: {
const landedSet = new Set(landed)
unconfirmedRef.current = unconfirmedRef.current.filter((entry) => !landedSet.has(entry))
for (const entry of landed) {
if (entry.deadline !== null) {
clearTimeout(entry.deadline)
}
clearTimeout(entry.deadline ?? undefined)
}
}, [messages, draftKey, pendingKey])
@@ -266,21 +247,49 @@ export function useMobileNativeChatDrafts(args: {
return () => {
mountedRef.current = false
for (const entry of unconfirmedRef.current) {
if (entry.deadline !== null) {
clearTimeout(entry.deadline)
}
clearTimeout(entry.deadline ?? undefined)
}
unconfirmedRef.current = []
}
}, [])
const pending = pendingKey
? (pendingBySession[pendingKey] ?? NO_PENDING_MESSAGES)
const waitingForSession = draftKey
? (pendingWaitingForSession[draftKey] ?? NO_PENDING_MESSAGES)
: NO_PENDING_MESSAGES
useEffect(() => {
if (!pendingKey || pending.length === 0) {
if (!draftKey || !pendingKey || waitingForSession.length === 0) {
return
}
const movedIds = new Set(waitingForSession.map((item) => item.id))
setPendingBySession((previous) =>
mergeWaitingSessionPending(previous, pendingKey, waitingForSession)
)
setPendingWaitingForSession((previous) =>
removeWaitingSessionPending(previous, draftKey, movedIds)
)
}, [draftKey, pendingKey, waitingForSession])
const sessionPending = pendingKey
? (pendingBySession[pendingKey] ?? NO_PENDING_MESSAGES)
: NO_PENDING_MESSAGES
const pending = combineMobileNativeChatPending(sessionPending, waitingForSession)
useEffect(() => {
if (!pendingKey) {
return
}
setImagePreviewsBySession((previous) =>
migrateImagePreviewMessageIds(previous, pendingKey, messages)
)
if (pending.length === 0) {
return
}
const landedImagePreviews = findLandedImagePreviewEchoes(messages, pending)
const landedImagePendingIds = new Set(landedImagePreviews.map((preview) => preview.pendingId))
if (landedImagePreviews.length > 0) {
setImagePreviewsBySession((previous) =>
mergeLandedImagePreviewEchoes(previous, pendingKey, landedImagePreviews)
)
}
setPendingBySession((previous) => {
const current = previous[pendingKey] ?? []
const landedCounts = new Map<string, number>()
@@ -297,12 +306,20 @@ export function useMobileNativeChatDrafts(args: {
// baseline tail — text echoes are excluded so an unrelated outstanding
// text send cannot clear it. Ordinal-vs-count stays stable when the effect
// re-runs on the shrunken list, and ignores paginated-in history.
const next = current.filter((item) =>
item.text.trim() === ''
const next = current.filter((item) => {
if (landedImagePendingIds.has(item.id)) {
return false
}
// Image echoes hand their local URIs to the authoritative message above;
// never drop them through the text-only fallback before that handoff.
if (item.images?.length) {
return true
}
return item.text.trim() === ''
? countImageSourceTurnsAfter(messages, item.baselineTailMessageId) <
item.expectedOccurrence
item.expectedOccurrence
: (landedCounts.get(item.text.trim()) ?? 0) < item.expectedOccurrence
)
})
if (next.length === current.length) {
return previous
}
@@ -319,6 +336,9 @@ export function useMobileNativeChatDrafts(args: {
composerText: draftKey ? (drafts[draftKey] ?? '') : '',
setComposerText,
pending,
imagePreviewsByMessageId: pendingKey
? (imagePreviewsBySession[pendingKey] ?? NO_IMAGE_PREVIEWS)
: NO_IMAGE_PREVIEWS,
captureSendOrigin,
readSeededLaunchDraft,
readSeededLaunchDraftSeed,
@@ -11,13 +11,13 @@ import { useMobileNativeChatImageAttachments } from './use-mobile-native-chat-im
// Fully stub the picker so the real expo/react-native chain never loads under
// the vitest transform (react-native ships Flow syntax rolldown can't parse).
vi.mock('./mobile-image-source-picker', () => ({
pickMobileImage: vi.fn(),
pickMobileImages: vi.fn(),
ImageLibraryPermissionError: class ImageLibraryPermissionError extends Error {}
}))
import { pickMobileImage } from './mobile-image-source-picker'
import { pickMobileImages } from './mobile-image-source-picker'
const pick = vi.mocked(pickMobileImage)
const pick = vi.mocked(pickMobileImages)
function ok(id: string, result: unknown): RpcSuccess {
return { id, ok: true, result, _meta: { runtimeId: 'r' } }
@@ -124,7 +124,7 @@ describe('useMobileNativeChatImageAttachments', () => {
}
it('adds an uploaded image as a chip without pasting to the terminal', async () => {
pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }])
const client = makeClient([methodNotFound('start'), ok('save', '/tmp/a.png')])
mount(
baseArgs({
@@ -145,7 +145,7 @@ describe('useMobileNativeChatImageAttachments', () => {
})
it('rides pending images along on send: pastes the path, settles, then delegates the text', async () => {
pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }])
const client = makeClient([
methodNotFound('start'),
ok('save', '/tmp/a.png'),
@@ -208,7 +208,7 @@ describe('useMobileNativeChatImageAttachments', () => {
it('leads the image paste with a clear sized to a parked multi-line launch draft', async () => {
// A single Ctrl+U kills only the last line, so the draft's earlier lines
// would survive the clear and ride along with the image as prompt body.
pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }])
const client = makeClient([
methodNotFound('start'),
ok('save', '/tmp/a.png'),
@@ -242,7 +242,7 @@ describe('useMobileNativeChatImageAttachments', () => {
it('spends one budget across the image paste and the text body that follows', async () => {
vi.useFakeTimers()
try {
pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }])
const client = makeClient([
methodNotFound('start'),
ok('save', '/tmp/a.png'),
@@ -280,7 +280,7 @@ describe('useMobileNativeChatImageAttachments', () => {
})
it('routes an attachments-only send through baseSend with empty text so the echo still shows the photo', async () => {
pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }])
const client = makeClient([
methodNotFound('start'),
ok('save', '/tmp/a.png'),
@@ -321,7 +321,7 @@ describe('useMobileNativeChatImageAttachments', () => {
})
it('keeps the chips and does not submit when the image paste is rejected', async () => {
pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }])
const client = makeClient([
methodNotFound('start'),
ok('save', '/tmp/a.png'),
@@ -345,7 +345,7 @@ describe('useMobileNativeChatImageAttachments', () => {
})
it('keeps the chips and reports failure when the paste transport throws', async () => {
pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }])
// No terminal.send responses queued: the clear write throws (dropped transport).
const client = makeClient([methodNotFound('start'), ok('save', '/tmp/a.png')])
const baseSend = vi.fn().mockResolvedValue('accepted')
@@ -365,7 +365,7 @@ describe('useMobileNativeChatImageAttachments', () => {
})
it('surfaces an error instead of a silent no-op when the input lease gate is closed', async () => {
pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }])
const client = makeClient([methodNotFound('start'), ok('save', '/tmp/a.png')])
const baseSend = vi.fn().mockResolvedValue('accepted')
const onSendError = vi.fn()
@@ -387,7 +387,7 @@ describe('useMobileNativeChatImageAttachments', () => {
})
it('scopes chips to the tab that attached them', async () => {
pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }])
const client = makeClient([methodNotFound('start'), ok('save', '/tmp/a.png')])
const baseSend = vi.fn().mockResolvedValue('accepted')
const args = baseArgs({ client: client as unknown as RpcClient, baseSend })
@@ -426,7 +426,7 @@ describe('useMobileNativeChatImageAttachments', () => {
})
mount(args)
pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }])
let firstAttach: Promise<void> | null = null
await act(async () => {
firstAttach = hook!.attachImage('library')
@@ -438,7 +438,7 @@ describe('useMobileNativeChatImageAttachments', () => {
expect(hook!.isAttaching).toBe(true)
// A concurrent cancelled pick — its finally must leave the counter alone.
pick.mockResolvedValue(null)
pick.mockResolvedValue([])
await act(async () => {
await hook!.attachImage('library')
})
@@ -454,7 +454,7 @@ describe('useMobileNativeChatImageAttachments', () => {
})
it('clears only the chips that were sent, keeping one attached mid-send', async () => {
pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }])
const client = makeClient([
methodNotFound('start'),
ok('save', '/tmp/a.png'), // first attach
@@ -488,7 +488,7 @@ describe('useMobileNativeChatImageAttachments', () => {
})
expect(releaseSettle).not.toBeNull()
pick.mockResolvedValue({ base64: 'BBBB', uri: 'file:///b.jpg' })
pick.mockResolvedValue([{ base64: 'BBBB', uri: 'file:///b.jpg' }])
await act(async () => {
await hook!.attachImage('library')
})
@@ -511,7 +511,7 @@ describe('useMobileNativeChatImageAttachments', () => {
})
it('aborts the send when the active terminal changes during the settle window', async () => {
pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }])
const client = makeClient([
methodNotFound('start'),
ok('save', '/tmp/a.png'),
@@ -560,7 +560,7 @@ describe('useMobileNativeChatImageAttachments', () => {
})
it('leads the next text-only send with Ctrl+U after a failed paste, even with the chip removed', async () => {
pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }])
const client = makeClient([
methodNotFound('start'),
ok('save', '/tmp/a.png'),
@@ -596,7 +596,7 @@ describe('useMobileNativeChatImageAttachments', () => {
})
it('heals before the next text-only send when an image submit delivery is unknown (#10228)', async () => {
pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }])
const client = makeClient([
methodNotFound('start'),
ok('save', '/tmp/a.png'),
@@ -634,7 +634,7 @@ describe('useMobileNativeChatImageAttachments', () => {
})
it('still heals after the session screen unmounts and remounts (#10228)', async () => {
pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }])
const client = makeClient([
methodNotFound('start'),
ok('save', '/tmp/a.png'),
@@ -688,7 +688,7 @@ describe('useMobileNativeChatImageAttachments', () => {
})
it('retains the stale marker when a rejected healing clear blocks text-only send', async () => {
pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }])
const client = makeClient([
methodNotFound('start'),
ok('save', '/tmp/a.png'),
@@ -730,7 +730,7 @@ describe('useMobileNativeChatImageAttachments', () => {
})
it('does not reroute text when the active terminal changes during a healing clear', async () => {
pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }])
let releaseClear: ((response: RpcResponse) => void) | null = null
const deferredClear = new Promise<RpcResponse>((resolve) => {
releaseClear = resolve
@@ -774,7 +774,7 @@ describe('useMobileNativeChatImageAttachments', () => {
})
it('defers the heal instead of burning a rejected clear while the lease is closed', async () => {
pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }])
const client = makeClient([
methodNotFound('start'),
ok('save', '/tmp/a.png'),
@@ -818,8 +818,8 @@ describe('useMobileNativeChatImageAttachments', () => {
it('heals rejected image submits independently across terminals', async () => {
pick
.mockResolvedValueOnce({ base64: 'AAAA', uri: 'file:///a.jpg' })
.mockResolvedValueOnce({ base64: 'BBBB', uri: 'file:///b.jpg' })
.mockResolvedValueOnce([{ base64: 'AAAA', uri: 'file:///a.jpg' }])
.mockResolvedValueOnce([{ base64: 'BBBB', uri: 'file:///b.jpg' }])
const client = makeClient([
methodNotFound('start-a'),
ok('save-a', '/tmp/a.png'),
@@ -896,7 +896,7 @@ describe('useMobileNativeChatImageAttachments', () => {
})
})
mount(args)
pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }])
let attach: Promise<void> | null = null
await act(async () => {
attach = hook!.attachImage('library')
@@ -5,11 +5,12 @@ import type { RpcClient } from '../transport/rpc-client'
import type { ConnectionState } from '../transport/types'
import {
ImageLibraryPermissionError,
pickMobileImage,
pickMobileImages,
type MobileImageSource
} from './mobile-image-source-picker'
import {
uploadMobileNativeChatImage,
appendPendingNativeChatImages,
uploadMobileNativeChatImages,
type PendingNativeChatImage
} from './mobile-native-chat-image-attachment'
import {
@@ -82,10 +83,6 @@ export type MobileNativeChatImageAttachments = {
readonly sendNativeChat: (text: string) => Promise<boolean>
}
function getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
const NO_ATTACHMENTS: PendingNativeChatImage[] = []
function withScopeAttachments(
@@ -146,48 +143,53 @@ export function useMobileNativeChatImageAttachments({
// pick or pre-upload error never ran `onUploadStart`, so decrementing the
// shared counter would clear a concurrent upload's in-flight flag early.
let started = false
const uploadedImages: Omit<PendingNativeChatImage, 'id'>[] = []
let uploadError: unknown = null
try {
const uploaded = await uploadMobileNativeChatImage(source, {
await uploadMobileNativeChatImages(source, {
client,
getConnectionId: getActiveWorktreeConnectionId,
pickImage: pickMobileImage,
pickImages: pickMobileImages,
onImageUploaded: (image) => uploadedImages.push(image),
onUploadStart: () => {
started = true
attachingCount.current += 1
setIsAttaching(true)
}
})
// Cancelled picker: no error, no toast.
if (!uploaded) {
return
}
idCounter.current += 1
const chip = { id: `img-${idCounter.current}`, ...uploaded }
setAttachmentsByScope((prev) => ({ ...prev, [scope]: [...(prev[scope] ?? []), chip] }))
onAttachSuccess?.()
} catch (error) {
uploadError = error
} finally {
if (started) {
attachingCount.current -= 1
if (attachingCount.current === 0) {
setIsAttaching(false)
}
}
}
if (uploadedImages.length > 0) {
setAttachmentsByScope((prev) => ({
...prev,
[scope]: appendPendingNativeChatImages(prev[scope] ?? [], uploadedImages, idCounter)
}))
onAttachSuccess?.()
}
if (uploadError !== null) {
const message = uploadError instanceof Error ? uploadError.message : String(uploadError)
onError?.()
if (connStateRef.current !== 'connected') {
showToast('Attach failed (disconnected)', 1500)
return
}
if (error instanceof ImageLibraryPermissionError) {
if (uploadError instanceof ImageLibraryPermissionError) {
showToast('Photo permission denied', 1500)
return
}
if (getErrorMessage(error) === CLIPBOARD_IMAGE_TOO_LARGE_ERROR) {
if (message === CLIPBOARD_IMAGE_TOO_LARGE_ERROR) {
showToast('Image too large to attach', 1500)
return
}
showToast('Attach failed', 1500)
} finally {
if (started) {
attachingCount.current -= 1
if (attachingCount.current <= 0) {
attachingCount.current = 0
setIsAttaching(false)
}
}
}
},
[
@@ -15,14 +15,16 @@ const ASK = JSON.stringify({
function promptsFor(
status: Partial<AgentStatusEntry> | null,
messages: NativeChatMessage[] = []
messages: NativeChatMessage[] = [],
transcriptLoading = false
): ReturnType<typeof useMobileNativeChatPrompts> {
let captured: ReturnType<typeof useMobileNativeChatPrompts> | undefined
function Probe(): null {
captured = useMobileNativeChatPrompts({
enabled: true,
status: status as AgentStatusEntry | null,
messages
messages,
transcriptLoading
})
return null
}
@@ -118,6 +120,18 @@ describe('useMobileNativeChatPrompts ask state gate', () => {
expect(promptsFor(null, askMessages).ask).not.toBeNull()
})
it('withholds retained transcript asks while the replacement read is unsettled', () => {
const prompts = promptsFor({ state: 'done' }, askMessages, true)
expect(prompts.ask).toBeNull()
expect(prompts.detectedAsk).toBeNull()
})
it('keeps a paused live status ask authoritative while the read is unsettled', () => {
const prompts = promptsFor({ state: 'waiting', interactivePrompt: ASK }, askMessages, true)
expect(prompts.ask).toMatchObject({ questions: [{ question: 'Which path?' }] })
expect(prompts.detectedAsk).not.toBeNull()
})
it('does not leak a paused-out sticky status prompt through the transcript fallback', () => {
// The post-answer window: the status still carries the prompt while flipping
// to `working`, and the transcript's tool-result row has not landed yet, so
@@ -1,7 +1,7 @@
import { useMemo } from 'react'
import type { AgentStatusEntry } from '../../../src/shared/agent-status-types'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import { extractPendingAsk, parseAskFromStatus } from './mobile-native-chat-ask'
import { parseAskFromStatus, resolveNativeChatAsk } from './mobile-native-chat-ask'
import { detectAgentPermission, parseApprovalFromStatus } from './mobile-native-chat-permission'
import { parseAgentQuestion } from './mobile-native-chat-question'
@@ -17,8 +17,11 @@ export function useMobileNativeChatPrompts(args: {
enabled: boolean
status: AgentStatusEntry | null | undefined
messages: readonly NativeChatMessage[]
/** True while `messages` is an unsettled read (including the cached list held
* across a reconnect). Required: an ask derived from it may already be answered. */
transcriptLoading: boolean
}): MobileNativeChatPrompts {
const { enabled, status, messages } = args
const { enabled, status, messages, transcriptLoading } = args
const blocked = status?.state === 'waiting' || status?.state === 'blocked'
// Both permission paths sit inside the paused gate: an approval envelope can
// outlive its answer (the host keeps it sticky), so only a waiting/blocked
@@ -38,11 +41,16 @@ export function useMobileNativeChatPrompts(args: {
() => parseAskFromStatus(status?.interactivePrompt, status?.toolName),
[status?.interactivePrompt, status?.toolName]
)
const askFromMessages = useMemo(
() => (askFromStatus ? null : extractPendingAsk(messages)),
[askFromStatus, messages]
const resolvedAsk = useMemo(
() =>
resolveNativeChatAsk({
liveAsk: askFromStatus,
messages,
transcriptSettled: !transcriptLoading
}),
[askFromStatus, transcriptLoading, messages]
)
const askFromMessages = askFromStatus ? null : resolvedAsk
const detectedAsk = askFromStatus ?? askFromMessages
return {
@@ -36,6 +36,7 @@ describe('useMobileNativeChatSession', () => {
function Harness({ client }: { client: RpcClient | null }): null {
state = useMobileNativeChatSession({
client,
sourceIdentity: 'host-a\0workspace-a',
agent: 'claude',
sessionId: 'session',
transcriptPath: null
@@ -456,14 +457,17 @@ describe('useMobileNativeChatSession transcriptLoading', () => {
function Harness({
client,
sessionId,
agent = 'claude'
agent = 'claude',
sourceIdentity = 'host-a\0workspace-a'
}: {
client: RpcClient | null
sessionId: string | null
agent?: string | null
sourceIdentity?: string
}): null {
const session = useMobileNativeChatSession({
client,
sourceIdentity,
agent,
sessionId,
transcriptPath: null
@@ -506,7 +510,8 @@ describe('useMobileNativeChatSession transcriptLoading', () => {
it('re-reads instead of resurfacing a settled read when the same identity returns', async () => {
// Leaving chat view nulls the agent, then returning restores the identity a
// settled read already matched — but its list was cleared, so trusting it
// would report 'ready' over an empty transcript.
// would report 'ready' over an empty transcript. The last settled list for
// this identity keeps rendering while the re-read is in flight.
const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => {
onData({ type: 'snapshot', messages: [message('a-1')], hasMore: false })
return () => {}
@@ -524,12 +529,17 @@ describe('useMobileNativeChatSession transcriptLoading', () => {
renderer?.update(createElement(Harness, { client, sessionId: 'session-a', agent: 'claude' }))
)
expect(renders[0]).toMatchObject({ status: 'loading', transcriptLoading: true, ids: [] })
expect(renders[0]).toMatchObject({
status: 'loading',
transcriptLoading: true,
ids: ['a-1']
})
})
it('re-reads instead of resurfacing a settled read after a reconnect', async () => {
// A reconnect swaps the client without moving the identity; the effect
// re-subscribes and clears the list, so the old outcome must not stand.
it('keeps the last settled list rendered while a swapped client re-reads', async () => {
// A manual-retry reconnect swaps the client without moving the identity; the
// old outcome must not stand ('loading', not 'ready'), but the cached
// transcript keeps rendering instead of collapsing to a full-screen spinner.
const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => {
onData({ type: 'snapshot', messages: [message('a-1')], hasMore: false })
return () => {}
@@ -538,13 +548,61 @@ describe('useMobileNativeChatSession transcriptLoading', () => {
await mountAt(client, 'session-a')
expect(renders.at(-1)).toMatchObject({ status: 'ready' })
const reconnected = { subscribe: vi.fn(() => () => {}) } as unknown as RpcClient
let emitFresh: (frame: unknown) => void = () => {}
const reconnected = {
subscribe: vi.fn((_method: string, _params: unknown, onData: (frame: unknown) => void) => {
emitFresh = onData
return () => {}
})
} as unknown as RpcClient
renders.length = 0
await act(async () =>
renderer?.update(createElement(Harness, { client: reconnected, sessionId: 'session-a' }))
)
expect(renders[0]).toMatchObject({ status: 'loading', transcriptLoading: true, ids: [] })
expect(renders[0]).toMatchObject({
status: 'loading',
transcriptLoading: true,
ids: ['a-1']
})
// Every commit of the window, not just the first: the re-subscribe lands a
// commit after it, so clearing the cache there blanks the transcript the
// user actually sees while leaving a first-frame assertion green.
expect([...new Set(renders.map((entry) => entry.ids.join(',')))]).toEqual(['a-1'])
// The fresh client's snapshot supersedes the held list.
await act(async () =>
emitFresh({ type: 'snapshot', messages: [message('a-1'), message('a-2')], hasMore: false })
)
expect(renders.at(-1)).toMatchObject({
status: 'ready',
transcriptLoading: false,
ids: ['a-1', 'a-2']
})
})
it('never holds a cached list across a host/workspace source change', async () => {
const firstClient = {
subscribe: vi.fn((_method: string, _params: unknown, onData: (frame: unknown) => void) => {
onData({ type: 'snapshot', messages: [message('source-a')], hasMore: false })
return () => {}
})
} as unknown as RpcClient
await mountAt(firstClient, 'session-a')
const secondClient = { subscribe: vi.fn(() => () => {}) } as unknown as RpcClient
renders.length = 0
await act(async () =>
renderer?.update(
createElement(Harness, {
client: secondClient,
sessionId: 'session-a',
sourceIdentity: 'host-b\0workspace-b'
})
)
)
expect(renders[0]).toMatchObject({ status: 'loading', ids: [] })
})
it('never hands out the previous session’s messages under the new session id', async () => {
@@ -1,4 +1,8 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import {
createNativeChatTranscriptRetention,
encodeNativeChatTranscriptIdentity
} from '../../../src/shared/native-chat-transcript-retention'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import { buildNativeChatSubscriptionId } from '../../../src/shared/native-chat-stream-unsubscribe'
import type { RpcClient } from '../transport/rpc-client'
@@ -28,9 +32,6 @@ export type MobileNativeChatSession = {
loadEarlier: () => void
}
// Stable empty reference so a not-yet-current read doesn't churn consumers.
const EMPTY_MESSAGES: NativeChatMessage[] = []
// Small first page for a fast first paint; grows by a page as the user scrolls.
const INITIAL_LIMIT = 40
const PAGE = 60
@@ -45,13 +46,20 @@ type ReadSessionResult =
* an ordered tail); live appends merge by id so order stays stable. */
export function useMobileNativeChatSession(args: {
client: RpcClient | null
/** Stable host/workspace source; unlike `client`, it survives manual reconnect. */
sourceIdentity: string
agent: string | null
sessionId: string | null
transcriptPath: string | null
}): MobileNativeChatSession {
const { client, agent, sessionId, transcriptPath } = args
const { client, sourceIdentity, agent, sessionId, transcriptPath } = args
const [messages, setMessages] = useState<NativeChatMessage[]>([])
const identity = `${agent ?? ''}\0${sessionId ?? ''}\0${transcriptPath ?? ''}`
const identity = encodeNativeChatTranscriptIdentity([
sourceIdentity,
agent,
sessionId,
transcriptPath
])
// Pre-read status is a pure function of the props, so derive it rather than
// letting the effect write it a commit later.
const initialStatus: MobileNativeChatStatus =
@@ -95,6 +103,13 @@ export function useMobileNativeChatSession(args: {
// Whether this subscription already delivered its base snapshot; later
// snapshots on the same subscription are reconnect replays, not fresh bases.
const snapshotSeenRef = useRef(false)
const transcriptRetentionRef = useRef(createNativeChatTranscriptRetention())
const settledReady = settled?.status === 'ready'
useEffect(() => {
if (settledReady) {
transcriptRetentionRef.current.capture(identity, messages)
}
}, [identity, messages, settledReady])
// Replace the base list (read results are an ordered tail). Resets the merger
// cache so the index is rebuilt once over the new base.
@@ -258,10 +273,17 @@ export function useMobileNativeChatSession(args: {
})()
}, [client, agent, sessionId, transcriptPath, hasMore, setList])
const visibleMessages = transcriptRetentionRef.current.visible({
identity,
messages,
settled: settledReady,
loading: status === 'loading'
})
return {
// Withheld until the settled read belongs to this identity: the effect that
// clears the previous tab's list is passive, so `messages` lags a commit.
messages: settled ? messages : EMPTY_MESSAGES,
messages: visibleMessages,
status,
transcriptLoading: status === 'loading',
error,
@@ -29,11 +29,8 @@ describe('mobile source control status helpers', () => {
it('builds sections in the mobile source control order', () => {
const sections = buildMobileSourceControlSections(entries)
expect(sections.map((section) => section.title)).toEqual([
'Changes',
'Untracked Files',
'Staged Changes'
])
expect(sections.map((section) => section.title)).toEqual(['Changes', 'Staged Changes'])
expect(sections[0]?.data.map((entry) => entry.path)).toEqual(['a.ts', 'new.ts'])
})
it('computes actionable path sets', () => {
@@ -19,7 +19,7 @@ export type MobileSourceControlSection<TEntry extends MobileGitStatusEntry = Mob
data: TEntry[]
}
const AREA_ORDER: MobileGitStagingArea[] = ['unstaged', 'untracked', 'staged']
const AREA_ORDER: MobileGitStagingArea[] = ['unstaged', 'staged']
const AREA_TITLES: Record<MobileGitStagingArea, string> = {
unstaged: 'Changes',
@@ -59,7 +59,13 @@ export function buildMobileSourceControlSections<TEntry extends MobileGitStatusE
return AREA_ORDER.map((area) => ({
area,
title: AREA_TITLES[area],
data: entries.filter((entry) => entry.area === area).sort(compareGitStatusEntries)
data: entries
.filter((entry) =>
area === 'unstaged'
? entry.area === 'unstaged' || entry.area === 'untracked'
: entry.area === area
)
.sort(compareGitStatusEntries)
})).filter((section) => section.data.length > 0)
}
@@ -0,0 +1,233 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcResponse } from '../transport/types'
import { revealMobileSourceControlSessionDiff } from './reveal-mobile-source-control-session-diff'
function success(result: unknown): RpcResponse {
return { id: 'rpc-1', ok: true, result, _meta: { runtimeId: 'runtime-1' } }
}
function clientWith(sendRequest: RpcClient['sendRequest']): Pick<RpcClient, 'sendRequest'> {
return { sendRequest }
}
function options(sendRequest: RpcClient['sendRequest']) {
return {
client: clientWith(sendRequest),
worktreeId: 'worktree-1',
relativePath: 'src/target.ts',
tabMode: 'diff' as const,
staged: true
}
}
afterEach(() => {
vi.useRealTimers()
})
describe('revealMobileSourceControlSessionDiff', () => {
it('activates the requested file when its diff tab is already open', async () => {
const sendRequest = vi
.fn<RpcClient['sendRequest']>()
.mockResolvedValueOnce(
success({
activeTabId: 'agent-tab',
tabs: [
{ id: 'agent-tab', type: 'terminal' },
{
id: 'other-diff',
type: 'file',
mode: 'diff',
diffSource: 'staged',
relativePath: 'src/other.ts'
},
{
id: 'target-diff',
type: 'file',
mode: 'diff',
diffSource: 'staged',
relativePath: 'src/target.ts'
}
]
})
)
.mockResolvedValueOnce(success({ activeTabId: 'target-diff' }))
await expect(revealMobileSourceControlSessionDiff(options(sendRequest))).resolves.toBe(
'revealed'
)
expect(sendRequest).toHaveBeenNthCalledWith(2, 'session.tabs.activate', {
worktree: 'id:worktree-1',
tabId: 'target-diff',
notifyClients: false,
navigation: 'caller',
intent: 'user'
})
})
it('selects the staged diff when both versions of a file are open', async () => {
const sendRequest = vi
.fn<RpcClient['sendRequest']>()
.mockResolvedValueOnce(
success({
tabs: [
{
id: 'unstaged-diff',
type: 'file',
mode: 'diff',
diffSource: 'unstaged',
relativePath: 'src/target.ts'
},
{
id: 'staged-diff',
type: 'file',
mode: 'diff',
diffSource: 'staged',
relativePath: 'src/target.ts'
}
]
})
)
.mockResolvedValueOnce(success({ activeTabId: 'staged-diff' }))
await expect(revealMobileSourceControlSessionDiff(options(sendRequest))).resolves.toBe(
'revealed'
)
expect(sendRequest).toHaveBeenLastCalledWith(
'session.tabs.activate',
expect.objectContaining({ tabId: 'staged-diff' })
)
})
it('waits for the requested diff source instead of activating the other one', async () => {
vi.useFakeTimers()
const sendRequest = vi
.fn<RpcClient['sendRequest']>()
.mockResolvedValueOnce(
success({
tabs: [
{
id: 'unstaged-diff',
type: 'file',
mode: 'diff',
diffSource: 'unstaged',
relativePath: 'src/target.ts'
}
]
})
)
.mockResolvedValueOnce(
success({
tabs: [
{
id: 'staged-diff',
type: 'file',
mode: 'diff',
diffSource: 'staged',
relativePath: 'src/target.ts'
}
]
})
)
.mockResolvedValueOnce(success({ activeTabId: 'staged-diff' }))
const reveal = revealMobileSourceControlSessionDiff(options(sendRequest))
await vi.advanceTimersByTimeAsync(300)
await expect(reveal).resolves.toBe('revealed')
expect(sendRequest).toHaveBeenLastCalledWith(
'session.tabs.activate',
expect.objectContaining({ tabId: 'staged-diff' })
)
})
it('activates a legacy edit tab when opening a diff falls back to files.open', async () => {
const sendRequest = vi
.fn<RpcClient['sendRequest']>()
.mockResolvedValueOnce(
success({
tabs: [
{
id: 'stale-diff',
type: 'file',
mode: 'diff',
diffSource: 'staged',
relativePath: 'src/target.ts'
},
{
id: 'target-edit',
type: 'file',
relativePath: 'src/target.ts'
}
]
})
)
.mockResolvedValueOnce(success({ activeTabId: 'target-edit' }))
await expect(
revealMobileSourceControlSessionDiff({ ...options(sendRequest), tabMode: 'edit' })
).resolves.toBe('revealed')
expect(sendRequest).toHaveBeenLastCalledWith(
'session.tabs.activate',
expect.objectContaining({ tabId: 'target-edit' })
)
})
it('retries until the opened diff appears in the session snapshot', async () => {
vi.useFakeTimers()
const sendRequest = vi
.fn<RpcClient['sendRequest']>()
.mockResolvedValueOnce(success({ tabs: [{ id: 'agent-tab', type: 'terminal' }] }))
.mockResolvedValueOnce(
success({
tabs: [
{
id: 'target-diff',
type: 'file',
mode: 'diff',
diffSource: 'staged',
relativePath: 'src/target.ts'
}
]
})
)
.mockResolvedValueOnce(success({ activeTabId: 'target-diff' }))
const reveal = revealMobileSourceControlSessionDiff(options(sendRequest))
await vi.advanceTimersByTimeAsync(300)
await expect(reveal).resolves.toBe('revealed')
expect(sendRequest).toHaveBeenCalledTimes(3)
})
it('delegates to the mounted session when the dock callback is available', async () => {
const sendRequest = vi.fn<RpcClient['sendRequest']>()
const onOpenedFileDiff = vi.fn()
await expect(
revealMobileSourceControlSessionDiff({
...options(sendRequest),
onOpenedFileDiff
})
).resolves.toBe('revealed')
expect(onOpenedFileDiff).toHaveBeenCalledWith('src/target.ts')
expect(sendRequest).not.toHaveBeenCalled()
})
it('cancels route-owned polling after the source-control screen unmounts', async () => {
let current = true
const sendRequest = vi.fn<RpcClient['sendRequest']>().mockImplementation(async () => {
current = false
return success({ tabs: [] })
})
await expect(
revealMobileSourceControlSessionDiff({
...options(sendRequest),
isCurrent: () => current
})
).resolves.toBe('cancelled')
expect(sendRequest).toHaveBeenCalledOnce()
})
})
@@ -0,0 +1,143 @@
import type { RpcClient } from '../transport/rpc-client'
import { activateMobileSessionTab } from '../session/mobile-session-tab-activation'
type ActivationClient = Pick<RpcClient, 'sendRequest'>
type SessionFileTabCandidate = {
id: string
type: string
mode?: unknown
relativePath?: unknown
diffSource?: unknown
}
type Options = {
client: ActivationClient
worktreeId: string
relativePath: string
tabMode: 'diff' | 'edit'
staged: boolean
onOpenedFileDiff?: (relativePath: string) => void
isCurrent?: () => boolean
}
export type MobileSourceControlSessionDiffRevealResult = 'revealed' | 'cancelled' | 'timeout'
const TAB_POLL_DELAYS_MS = [0, 300, 600, 900] as const
export async function revealMobileSourceControlSessionDiff(
options: Options
): Promise<MobileSourceControlSessionDiffRevealResult> {
if (options.onOpenedFileDiff) {
options.onOpenedFileDiff(options.relativePath)
return 'revealed'
}
for (const delayMs of TAB_POLL_DELAYS_MS) {
await waitForDelay(delayMs)
if (options.isCurrent?.() === false) {
return 'cancelled'
}
const tab = await findOpenedSessionFileTab(options)
if (options.isCurrent?.() === false) {
return 'cancelled'
}
if (!tab) {
continue
}
const activated = await activateSessionFileTab(options, tab.id)
if (options.isCurrent?.() === false) {
return 'cancelled'
}
if (activated) {
return 'revealed'
}
}
return 'timeout'
}
async function findOpenedSessionFileTab(options: Options): Promise<SessionFileTabCandidate | null> {
try {
const response = await options.client.sendRequest('session.tabs.list', {
worktree: `id:${options.worktreeId}`
})
if (!response.ok) {
return null
}
const snapshot = readTabSnapshot(response.result)
if (!snapshot) {
return null
}
const matches = snapshot.tabs.filter(
(tab) =>
tab.type !== 'browser' &&
tab.type !== 'terminal' &&
matchesTabMode(tab.mode, options.tabMode) &&
tab.relativePath === options.relativePath
)
if (options.tabMode === 'edit') {
return matches[0] ?? null
}
const source = options.staged ? 'staged' : 'unstaged'
return (
matches.find((tab) => tab.diffSource === source) ??
matches.find((tab) => tab.diffSource == null) ??
null
)
} catch {
return null
}
}
function matchesTabMode(mode: unknown, expected: 'diff' | 'edit'): boolean {
return expected === 'diff' ? mode === 'diff' : mode === 'edit' || mode == null
}
async function activateSessionFileTab(options: Options, tabId: string): Promise<boolean> {
try {
const response = await activateMobileSessionTab(options.client, {
worktree: `id:${options.worktreeId}`,
tabId,
notifyClients: false,
navigation: 'caller',
intent: 'user'
})
return response.ok && readActiveTabId(response.result) === tabId
} catch {
return false
}
}
function readTabSnapshot(value: unknown): { tabs: SessionFileTabCandidate[] } | null {
if (
!isRecord(value) ||
!Array.isArray(value.tabs) ||
!value.tabs.every(isSessionFileTabCandidate)
) {
return null
}
return { tabs: value.tabs }
}
function isSessionFileTabCandidate(value: unknown): value is SessionFileTabCandidate {
return isRecord(value) && typeof value.id === 'string' && typeof value.type === 'string'
}
function readActiveTabId(value: unknown): string | null {
return isRecord(value) && typeof value.activeTabId === 'string' ? value.activeTabId : null
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
async function waitForDelay(delayMs: number): Promise<void> {
if (delayMs === 0) {
return
}
await new Promise<void>((resolve) => setTimeout(resolve, delayMs))
}
@@ -18,6 +18,7 @@ import {
type MobileGitStatusEntry
} from './mobile-git-status'
import { buildMobileReviewFileRoute } from './mobile-review-route'
import { revealMobileSourceControlSessionDiff } from './reveal-mobile-source-control-session-diff'
import type {
GitDiffTextResult,
MobileBranchCompareState,
@@ -115,11 +116,13 @@ export function useMobileSourceControlOpeners(params: Params) {
relativePath: entry.path,
staged: entry.area === 'staged'
})
let openedTabMode: 'diff' | 'edit' = 'diff'
if (!response.ok && isMobileGitUnavailable(response.error?.code, response.error?.message)) {
response = await client.sendRequest('files.open', {
worktree: `id:${worktreeId}`,
relativePath: entry.path
})
openedTabMode = 'edit'
}
if (!response.ok) {
throw new Error(response.error?.message || 'Unable to open diff')
@@ -127,8 +130,22 @@ export function useMobileSourceControlOpeners(params: Params) {
if (!mountedRef.current) {
return
}
const revealResult = await revealMobileSourceControlSessionDiff({
client,
worktreeId,
relativePath: entry.path,
tabMode: openedTabMode,
staged: entry.area === 'staged',
onOpenedFileDiff,
isCurrent: () => mountedRef.current && openingPathRef.current === entry.path
})
if (revealResult === 'cancelled') {
return
}
if (revealResult === 'timeout') {
throw new Error("The file opened, but its tab isn't ready yet. Try again.")
}
triggerSelection()
onOpenedFileDiff?.(entry.path)
// Why: when launched from the session screen, opening a file dismisses
// this surface back to the session. In embedded mode there is nothing
// to pop (the panel docks beside the terminal), so close the dock
+29 -31
View File
@@ -32,8 +32,9 @@ describe('mobile task navigation', () => {
it('waits for the expected host commit before navigating to Tasks', () => {
const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] })
const push = vi.fn()
const replace = vi.fn()
navigateToMobileTasks(harness.navigation, { push }, 'host/1')
navigateToMobileTasks(harness.navigation, { push, replace }, 'host/1')
expect(harness.navigation.addListener.mock.invocationCallOrder[0]).toBeLessThan(
push.mock.invocationCallOrder[0]!
@@ -46,38 +47,25 @@ describe('mobile task navigation', () => {
routes: [{ name: 'index' }, { name: 'h', params: { hostId: 'host/1' } }]
})
expect(harness.navigation.dispatch).not.toHaveBeenCalled()
harness.setState({
index: 1,
routes: [
{ name: 'index' },
{
name: 'h',
state: {
key: '/h',
index: 0,
routes: [{ key: 'host-index', name: '[hostId]/index', params: { hostId: 'host/1' } }]
}
}
]
})
expect(harness.unsubscribeState).toHaveBeenCalledOnce()
expect(harness.unsubscribeState.mock.invocationCallOrder[0]).toBeLessThan(
harness.navigation.dispatch.mock.invocationCallOrder[0]!
replace.mock.invocationCallOrder[0]!
)
expect(harness.navigation.dispatch).toHaveBeenCalledWith({
type: 'REPLACE',
target: '/h',
source: 'host-index',
payload: { name: '[hostId]/tasks', params: { hostId: 'host/1' } }
expect(replace).toHaveBeenCalledWith({
pathname: '/h/[hostId]/tasks',
params: { hostId: 'host/1' }
})
})
it('ignores unrelated state events and preserves the provider', () => {
const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] })
navigateToMobileTasks(harness.navigation, { push: vi.fn() }, 'host-1', 'linear')
navigateToMobileTasks(
harness.navigation,
{ push: vi.fn(), replace: vi.fn() },
'host-1',
'linear'
)
harness.setState({ index: 1, routes: [{ name: 'index' }, { name: 'settings' }] })
expect(harness.navigation.dispatch).not.toHaveBeenCalled()
harness.setState({ index: 0, routes: [{ name: 'h', params: { hostId: 'host-2' } }] })
@@ -108,30 +96,37 @@ describe('mobile task navigation', () => {
it('cleanup prevents a stale navigation from replacing', () => {
const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] })
const controller = navigateToMobileTasks(harness.navigation, { push: vi.fn() }, 'host-1')
const replace = vi.fn()
const controller = navigateToMobileTasks(
harness.navigation,
{ push: vi.fn(), replace },
'host-1'
)
controller.cancel()
harness.setState({ index: 0, routes: [{ name: 'h', params: { hostId: 'host-1' } }] })
expect(harness.unsubscribeState).toHaveBeenCalledOnce()
expect(harness.navigation.dispatch).not.toHaveBeenCalled()
expect(replace).not.toHaveBeenCalled()
})
it('reuses a pending host push and applies the latest provider', () => {
const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] })
const push = vi.fn()
const router = { push, replace: vi.fn() }
const first = coordinateMobileTasksNavigation(
null,
harness.navigation,
{ push },
router,
'host-1',
'github'
)
const second = coordinateMobileTasksNavigation(
first,
harness.navigation,
{ push },
router,
'host-1',
'linear'
)
@@ -161,11 +156,12 @@ describe('mobile task navigation', () => {
)
})
it('keeps waiting through host setup and cleans up when navigation leaves', () => {
it('keeps waiting through host setup without params and cleans up when navigation leaves', () => {
const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] })
const replace = vi.fn()
navigateToMobileTasks(harness.navigation, { push: vi.fn() }, 'host-1')
harness.setState({ index: 0, routes: [{ name: 'h', params: { hostId: 'host-1' } }] })
navigateToMobileTasks(harness.navigation, { push: vi.fn(), replace }, 'host-1')
harness.setState({ index: 0, routes: [{ name: 'h' }] })
expect(harness.unsubscribeState).not.toHaveBeenCalled()
harness.setState({ index: 0, routes: [{ name: 'index' }] })
harness.setState({
@@ -184,6 +180,7 @@ describe('mobile task navigation', () => {
expect(harness.unsubscribeState).toHaveBeenCalledOnce()
expect(harness.navigation.dispatch).not.toHaveBeenCalled()
expect(replace).not.toHaveBeenCalled()
})
it('unsubscribes when mounting the host throws synchronously', () => {
@@ -196,7 +193,8 @@ describe('mobile task navigation', () => {
{
push: () => {
throw error
}
},
replace: vi.fn()
},
'host-1'
)
@@ -39,6 +39,7 @@ describe('terminal live input affordance', () => {
expect(block).toContain('pressed && styles.liveInputFocusTargetPressed')
expect(block).toContain('!canSend && styles.liveInputFocusTargetDisabled')
expect(block).toContain('showSoftInputOnFocus')
expect(block).toContain('liveInputText={liveInputCapture}')
expect(sessionRouteSource).toContain('useTerminalLiveInputFocus({')
expect(sessionRouteSource).toContain('return resetLiveInputFocus')
expect(liveInputFocusSource).toContain('focusTerminalLiveInputTarget(inputRef.current')
@@ -52,6 +53,8 @@ describe('terminal live input affordance', () => {
it('makes the live keyboard target visible instead of status-only chrome', () => {
expect(liveInputStatusSource).toContain("'Tap to show keyboard'")
expect(liveInputStatusSource).toContain("liveInputText || 'Tap to show keyboard'")
expect(liveInputStatusSource).toContain('ellipsizeMode="head"')
expect(commandInputStylesSource).toContain('backgroundColor: colors.bgRaised')
expect(commandInputStylesSource).toContain('borderWidth: 1')
expect(commandInputStylesSource).toContain('liveInputFocusTargetPressed')
@@ -1,15 +1,16 @@
import { describe, expect, it } from 'vitest'
import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order'
import {
cancelTerminalLivePendingFlush,
createTerminalLivePendingFlushState,
queueTerminalLiveMirrorSend,
waitForTerminalLivePendingFlush,
type TerminalLivePendingFlushState
waitForTerminalLivePendingFlush
} from './terminal-live-pending-flush-state'
describe('terminal live pending flush state', () => {
it('Given no in-flight flush When waiting for the barrier Then allows control input', async () => {
// Given
const state: TerminalLivePendingFlushState = { current: null }
const state = createTerminalLivePendingFlushState()
// When / Then
await expect(waitForTerminalLivePendingFlush(state)).resolves.toBe(true)
@@ -22,7 +23,8 @@ describe('terminal live pending flush state', () => {
const flushPromise = new Promise<boolean>((resolve) => {
resolveFlush = resolve
})
const state: TerminalLivePendingFlushState = { current: flushPromise }
const state = createTerminalLivePendingFlushState()
state.current = flushPromise
// When
const controlSend = sendTerminalLiveControlAfterPendingFlush(
@@ -48,7 +50,8 @@ describe('terminal live pending flush state', () => {
const flushPromise = new Promise<boolean>((resolve) => {
resolveFlush = resolve
})
const state: TerminalLivePendingFlushState = { current: flushPromise }
const state = createTerminalLivePendingFlushState()
state.current = flushPromise
// When
const controlSend = sendTerminalLiveControlAfterPendingFlush(
@@ -67,17 +70,45 @@ describe('terminal live pending flush state', () => {
})
describe('terminal live mirror send queue', () => {
it('Given high RTT When more input queues Then pending bytes share one follow-up send', async () => {
// Given
const state = createTerminalLivePendingFlushState()
const payloads: string[] = []
let resolveFirstSend: (value: boolean) => void = () => {}
const sender = async (_handle: string, payload: string): Promise<boolean> => {
payloads.push(payload)
if (payloads.length === 1) {
return new Promise<boolean>((resolve) => {
resolveFirstSend = resolve
})
}
return true
}
// When
const first = queueTerminalLiveMirrorSend(state, 'terminal-1', 'a', sender)
const second = queueTerminalLiveMirrorSend(state, 'terminal-1', 'b', sender)
const third = queueTerminalLiveMirrorSend(state, 'terminal-1', 'c', sender)
await Promise.resolve()
// Then
expect(payloads).toEqual(['a'])
resolveFirstSend(true)
await expect(Promise.all([first, second, third])).resolves.toEqual([true, true, true])
expect(payloads).toEqual(['a', 'bc'])
})
it('Given a failed previous send When a mirror send queues Then it still runs in order', async () => {
// Given
const state: TerminalLivePendingFlushState = { current: null }
const state = createTerminalLivePendingFlushState()
const order: string[] = []
const first = queueTerminalLiveMirrorSend(state, async () => {
const first = queueTerminalLiveMirrorSend(state, 'terminal-1', 'first', async () => {
order.push('first')
return false
})
// When
const second = queueTerminalLiveMirrorSend(state, async () => {
const second = queueTerminalLiveMirrorSend(state, 'terminal-1', 'second', async () => {
order.push('second')
return true
})
@@ -90,13 +121,13 @@ describe('terminal live mirror send queue', () => {
it('Given a throwing send When a mirror send queues Then the promise resolves false and the chain continues', async () => {
// Given
const state: TerminalLivePendingFlushState = { current: null }
const first = queueTerminalLiveMirrorSend(state, async () => {
const state = createTerminalLivePendingFlushState()
const first = queueTerminalLiveMirrorSend(state, 'terminal-1', 'first', async () => {
throw new Error('boom')
})
// When
const second = queueTerminalLiveMirrorSend(state, async () => true)
const second = queueTerminalLiveMirrorSend(state, 'terminal-1', 'second', async () => true)
// Then
await expect(first).resolves.toBe(false)
@@ -105,13 +136,33 @@ describe('terminal live mirror send queue', () => {
it('Given a settled mirror send When it was the newest Then the state resets to null', async () => {
// Given
const state: TerminalLivePendingFlushState = { current: null }
const state = createTerminalLivePendingFlushState()
// When
await queueTerminalLiveMirrorSend(state, async () => true)
await queueTerminalLiveMirrorSend(state, 'terminal-1', 'payload', async () => true)
await Promise.resolve()
// Then
expect(state.current).toBeNull()
})
it('Given queued input When the queue is cancelled Then unsent input is dropped', async () => {
// Given
const state = createTerminalLivePendingFlushState()
let resolveSend: (value: boolean) => void = () => {}
const sender = async (): Promise<boolean> =>
new Promise((resolve) => {
resolveSend = resolve
})
const active = queueTerminalLiveMirrorSend(state, 'terminal-1', 'a', sender)
const pending = queueTerminalLiveMirrorSend(state, 'terminal-1', 'b', sender)
// When
cancelTerminalLivePendingFlush(state)
// Then
await expect(Promise.all([active, pending])).resolves.toEqual([false, false])
expect(state.current).toBeNull()
resolveSend(true)
})
})
@@ -1,5 +1,30 @@
type TerminalLiveMirrorSender = (handle: string, payload: string) => Promise<boolean>
type TerminalLivePendingRequest = {
readonly resolve: (sent: boolean) => void
}
type TerminalLivePendingBatch = {
readonly handle: string
payload: string
readonly requests: TerminalLivePendingRequest[]
readonly sender: TerminalLiveMirrorSender
}
export type TerminalLivePendingFlushState = {
current: Promise<boolean> | null
activeRequests: TerminalLivePendingRequest[]
generation: number
pendingBatches: TerminalLivePendingBatch[]
}
export function createTerminalLivePendingFlushState(): TerminalLivePendingFlushState {
return {
current: null,
activeRequests: [],
generation: 0,
pendingBatches: []
}
}
export function waitForTerminalLivePendingFlush(
@@ -8,26 +33,76 @@ export function waitForTerminalLivePendingFlush(
return state.current ?? Promise.resolve(true)
}
// Why: mirror payloads are erase/append deltas against the PTY echo. A skipped
// delta desyncs every later diff, so this chain runs each send even when the
// previous one failed. state.current should never reject; the catch keeps a
// future raw assignment from skipping a delta.
export function cancelTerminalLivePendingFlush(state: TerminalLivePendingFlushState): void {
state.generation += 1
const requests = [
...state.activeRequests,
...state.pendingBatches.flatMap((batch) => batch.requests)
]
state.activeRequests = []
state.pendingBatches = []
state.current = null
requests.forEach(({ resolve }) => resolve(false))
}
async function drainTerminalLiveMirrorSends(
state: TerminalLivePendingFlushState,
generation: number
): Promise<boolean> {
let allSent = true
while (state.generation === generation) {
const batch = state.pendingBatches.shift()
if (!batch) {
state.current = null
return allSent
}
state.activeRequests = batch.requests
const sent = await batch.sender(batch.handle, batch.payload).catch(() => false)
if (state.generation !== generation) {
return false
}
state.activeRequests = []
batch.requests.forEach(({ resolve }) => resolve(sent))
allSent &&= sent
}
return false
}
// Mirror deltas are ordered PTY bytes; batching pending bytes avoids one RTT per keystroke.
export function queueTerminalLiveMirrorSend(
state: TerminalLivePendingFlushState,
sendMirrorPayload: () => Promise<boolean>
handle: string,
payload: string,
sender: TerminalLiveMirrorSender
): Promise<boolean> {
const previousSend = state.current
const sendPromise = (async () => {
if (previousSend) {
await previousSend.catch(() => false)
}
return sendMirrorPayload()
})().catch(() => false)
state.current = sendPromise
void sendPromise.then(() => {
if (state.current === sendPromise) {
state.current = null
}
let resolveRequest: (sent: boolean) => void = () => {}
const request = new Promise<boolean>((resolve) => {
resolveRequest = resolve
})
return sendPromise
const pendingTail = state.pendingBatches.at(-1)
if (pendingTail?.handle === handle && pendingTail.sender === sender) {
pendingTail.payload += payload
pendingTail.requests.push({ resolve: resolveRequest })
} else {
state.pendingBatches.push({
handle,
payload,
requests: [{ resolve: resolveRequest }],
sender
})
}
if (!state.current) {
const generation = state.generation
const drain = drainTerminalLiveMirrorSends(state, generation).catch(() => false)
state.current = drain
void drain.then(() => {
if (state.current === drain) {
state.current = null
}
})
}
return request
}
@@ -7,6 +7,8 @@ import {
TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS
} from './terminal-live-hangul-mirror'
import {
cancelTerminalLivePendingFlush,
createTerminalLivePendingFlushState,
queueTerminalLiveMirrorSend,
waitForTerminalLivePendingFlush
} from './terminal-live-pending-flush-state'
@@ -39,7 +41,7 @@ export function useTerminalLivePendingInputFlush<TTabType extends string>({
setLiveInputCapture
}: TerminalLivePendingInputFlushOptions<TTabType>): TerminalLivePendingInputFlush {
const heldCommitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const pendingLiveInputFlushRef = useRef<Promise<boolean> | null>(null)
const pendingLiveInputFlushRef = useRef(createTerminalLivePendingFlushState())
const heldLiveInputTextRef = useRef('')
const sentLiveInputTextRef = useRef('')
const pendingLiveInputHandleRef = useRef<string | null>(null)
@@ -56,6 +58,7 @@ export function useTerminalLivePendingInputFlush<TTabType extends string>({
const resetMirrorState = useCallback(() => {
clearHeldCommitTimer()
cancelTerminalLivePendingFlush(pendingLiveInputFlushRef.current)
heldLiveInputTextRef.current = ''
sentLiveInputTextRef.current = ''
pendingLiveInputHandleRef.current = null
@@ -68,9 +71,15 @@ export function useTerminalLivePendingInputFlush<TTabType extends string>({
}, [liveInputRef, resetMirrorState, setLiveInputCapture])
const waitForPendingLiveInputFlush = useCallback(async (): Promise<boolean> => {
return waitForTerminalLivePendingFlush(pendingLiveInputFlushRef)
return waitForTerminalLivePendingFlush(pendingLiveInputFlushRef.current)
}, [])
const sendQueuedMirrorPayload = useCallback(
(handle: string, payload: string): Promise<boolean> =>
sendLiveTerminalInputRef.current(handle, payload),
[sendLiveTerminalInputRef]
)
const runMirrorStep = useCallback(
async (handle: string, fieldText: string, commitHeld: boolean): Promise<boolean> => {
if (
@@ -107,8 +116,11 @@ export function useTerminalLivePendingInputFlush<TTabType extends string>({
if (payload.length === 0) {
return waitForPendingLiveInputFlush()
}
return queueTerminalLiveMirrorSend(pendingLiveInputFlushRef, () =>
sendLiveTerminalInputRef.current(handle, payload)
return queueTerminalLiveMirrorSend(
pendingLiveInputFlushRef.current,
handle,
payload,
sendQueuedMirrorPayload
)
},
[
@@ -117,7 +129,7 @@ export function useTerminalLivePendingInputFlush<TTabType extends string>({
clearHeldCommitTimer,
liveInputTerminalHandlesRef,
resetMirrorState,
sendLiveTerminalInputRef,
sendQueuedMirrorPayload,
waitForPendingLiveInputFlush
]
)
@@ -164,7 +176,7 @@ export function useTerminalLivePendingInputFlush<TTabType extends string>({
heldLiveInputTextRef.current = ''
sentLiveInputTextRef.current = ''
pendingLiveInputHandleRef.current = null
pendingLiveInputFlushRef.current = null
cancelTerminalLivePendingFlush(pendingLiveInputFlushRef.current)
}
}, [])
+178
View File
@@ -21,6 +21,8 @@ vi.mock('./connection-revival-triggers', () => ({
}))
import { RpcClientProvider, useCloseHost, useForceReconnect, useHostClient } from './client-context'
import { useAllHostClients } from './use-all-host-clients'
import { selectHomeAutoConnectHostIds } from './home-host-auto-connect'
type FakeClient = RpcClient & {
emitState: (state: ConnectionState) => void
@@ -386,3 +388,179 @@ describe('useHostClient', () => {
expect(connectMock).not.toHaveBeenCalled()
})
})
describe('useAllHostClients', () => {
it('only opens the requested startup subset', async () => {
const host2 = { ...HOST, id: 'host-2', name: 'Host 2' }
connectMock.mockReturnValue(makeFakeClient('connected'))
loadHostsMock.mockResolvedValue([HOST, host2])
let renderer: ReactTestRenderer | null = null
function Probe(): null {
useAllHostClients([HOST.id, host2.id], { autoConnectHostIds: [host2.id] })
return null
}
const restore = suppressReactTestRendererDeprecationWarning()
try {
await act(async () => {
renderer = create(createElement(RpcClientProvider, null, createElement(Probe)))
await Promise.resolve()
})
expect(connectMock).toHaveBeenCalledOnce()
expect(connectMock).toHaveBeenCalledWith(host2, expect.any(Function))
} finally {
restore()
act(() => renderer?.unmount())
}
})
it('keeps startup connection fanout constant for a large saved-host list', async () => {
const hosts = Array.from({ length: 1_000 }, (_, index) => ({
...HOST,
id: `host-${index}`,
name: `Host ${index}`,
lastConnected: index
}))
const hostIds = hosts.map((host) => host.id)
const autoConnectHostIds = selectHomeAutoConnectHostIds(hosts)
connectMock.mockReturnValue(makeFakeClient('connected'))
loadHostsMock.mockResolvedValue(hosts)
let renderer: ReactTestRenderer | null = null
function Probe(): null {
useAllHostClients(hostIds, { autoConnectHostIds })
return null
}
const restore = suppressReactTestRendererDeprecationWarning()
try {
await act(async () => {
renderer = create(createElement(RpcClientProvider, null, createElement(Probe)))
await Promise.resolve()
})
expect(connectMock).toHaveBeenCalledTimes(3)
expect(connectMock.mock.calls.map(([host]) => host.id)).toEqual([
'host-999',
'host-998',
'host-997'
])
} finally {
restore()
act(() => renderer?.unmount())
}
})
it('closes a demoted Home client when the recent-host set rotates', async () => {
const hosts = [
{ ...HOST, id: 'host-a', lastConnected: 4 },
{ ...HOST, id: 'host-b', lastConnected: 3 },
{ ...HOST, id: 'host-c', lastConnected: 2 },
{ ...HOST, id: 'host-d', lastConnected: 1 }
]
const clients = new Map<string, FakeClient>()
connectMock.mockImplementation((profile: typeof HOST) => {
const client = makeFakeClient('connected')
clients.set(profile.id, client)
return client
})
loadHostsMock.mockResolvedValue(hosts)
let activeHostIds: string[] = []
let renderer: ReactTestRenderer | null = null
function Probe({ profiles }: { profiles: typeof hosts }): null {
const hostIds = profiles.map((host) => host.id)
activeHostIds = useAllHostClients(hostIds, {
autoConnectHostIds: selectHomeAutoConnectHostIds(profiles),
closeUnusedOnRelease: true
}).map(({ hostId }) => hostId)
return null
}
const restore = suppressReactTestRendererDeprecationWarning()
try {
await act(async () => {
renderer = create(
createElement(RpcClientProvider, null, createElement(Probe, { profiles: hosts }))
)
await Promise.resolve()
})
expect(activeHostIds.sort()).toEqual(['host-a', 'host-b', 'host-c'])
const rotatedHosts = hosts.map((host) =>
host.id === 'host-d' ? { ...host, lastConnected: 5 } : host
)
await act(async () => {
renderer?.update(
createElement(RpcClientProvider, null, createElement(Probe, { profiles: rotatedHosts }))
)
await Promise.resolve()
})
expect(connectMock).toHaveBeenCalledTimes(4)
expect(activeHostIds.sort()).toEqual(['host-a', 'host-b', 'host-d'])
expect(clients.get('host-a')?.closeMock).not.toHaveBeenCalled()
expect(clients.get('host-b')?.closeMock).not.toHaveBeenCalled()
expect(clients.get('host-c')?.closeMock).toHaveBeenCalledOnce()
expect(clients.get('host-d')?.closeMock).not.toHaveBeenCalled()
} finally {
restore()
act(() => renderer?.unmount())
}
})
it('retains connect-all behavior when no startup subset is provided', async () => {
const host2 = { ...HOST, id: 'host-2', name: 'Host 2' }
connectMock.mockReturnValue(makeFakeClient('connected'))
loadHostsMock.mockResolvedValue([HOST, host2])
let renderer: ReactTestRenderer | null = null
function Probe(): null {
useAllHostClients([HOST.id, host2.id])
return null
}
const restore = suppressReactTestRendererDeprecationWarning()
try {
await act(async () => {
renderer = create(createElement(RpcClientProvider, null, createElement(Probe)))
await Promise.resolve()
})
expect(connectMock).toHaveBeenCalledTimes(2)
} finally {
restore()
act(() => renderer?.unmount())
}
})
it('allows an excluded host to connect manually', async () => {
connectMock.mockReturnValue(makeFakeClient('connected'))
loadHostsMock.mockResolvedValue([HOST])
let reconnect: ((hostId: string) => Promise<void>) | null = null
let renderer: ReactTestRenderer | null = null
function Probe(): null {
useAllHostClients([HOST.id], { autoConnectHostIds: [] })
reconnect = useForceReconnect()
return null
}
const restore = suppressReactTestRendererDeprecationWarning()
try {
await act(async () => {
renderer = create(createElement(RpcClientProvider, null, createElement(Probe)))
})
expect(connectMock).not.toHaveBeenCalled()
if (!reconnect) {
throw new Error('reconnect harness did not initialize')
}
await act(async () => {
await reconnect?.(HOST.id)
})
expect(connectMock).toHaveBeenCalledOnce()
} finally {
restore()
act(() => renderer?.unmount())
}
})
})
+106 -84
View File
@@ -14,9 +14,20 @@ import type { RpcClient } from './rpc-client'
import { connectionLogStore } from './connection-log-buffer'
import { subscribeConnectionRevivalTriggers } from './connection-revival-triggers'
import { HostClientOpenRegistry } from './host-client-open-registry'
import { decrementPendingAcquisition } from './host-client-acquisition-count'
import {
clientActivePath,
listHostClients,
notifyAllHostListeners,
notifyHostStateListeners,
primeHostProfiles,
subscribeAllHostListener,
subscribeHostStateListener,
type CloseEntryOptions
} from './host-client-context-state'
import { loadHosts } from './host-store'
import { openHostLogicalClient } from './host-logical-client'
import type { MobileConnectionPath, StableLogicalRpcClient } from './stable-logical-rpc-client'
import type { MobileConnectionPath } from './stable-logical-rpc-client'
import type { ConnectionState, HostProfile } from './types'
type StoreEntry = {
@@ -29,6 +40,8 @@ type StoreEntry = {
export type RpcClientContextValue = {
acquire: (hostId: string, host?: HostProfile) => RpcClient | null
release: (hostId: string) => void
releaseAndCloseIfUnused: (hostId: string) => void
closeIfUnused: (hostId: string) => void
forceReconnect: (hostId: string) => Promise<void>
closeHost: (hostId: string) => void
getState: (hostId: string) => ConnectionState
@@ -55,30 +68,27 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
// Pending opens keyed by hostId so two acquire() callers in the same render don't race the host lookup.
const pendingOpensRef = useRef(new HostClientOpenRegistry())
const pendingAcquisitionsRef = useRef<Map<string, number>>(new Map())
// Why: cache of already-loaded HostProfiles so openEntry can skip a second loadHosts()/Keychain pass on cold start.
const primedHostsRef = useRef<Map<string, HostProfile>>(new Map())
function notifyHostState(hostId: string, state: ConnectionState) {
const set = stateListenersRef.current.get(hostId)
if (!set) {
return
}
for (const listener of set) {
listener(state)
}
}
const notifyHostState = (hostId: string, state: ConnectionState) =>
notifyHostStateListeners(stateListenersRef.current, hostId, state)
const notifyAllHosts = () => notifyAllHostListeners(allHostsListenersRef.current)
function notifyAllHosts() {
for (const listener of allHostsListenersRef.current) {
listener()
}
}
const closeEntry = useCallback((hostId: string) => {
pendingOpensRef.current.cancel(hostId)
primedHostsRef.current.delete(hostId)
const closeEntry = useCallback((hostId: string, options: CloseEntryOptions) => {
const entry = storeRef.current.get(hostId)
const acquisitionCount = entry?.refCount ?? pendingAcquisitionsRef.current.get(hostId) ?? 0
pendingOpensRef.current.cancel(hostId)
if (options.preserveAcquisitions && acquisitionCount > 0) {
pendingAcquisitionsRef.current.set(hostId, acquisitionCount)
} else {
pendingAcquisitionsRef.current.delete(hostId)
}
if (options.forgetPrimedHost) {
primedHostsRef.current.delete(hostId)
}
entry?.unsubState()
storeRef.current.delete(hostId)
entry?.client.close()
@@ -86,6 +96,13 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
notifyAllHosts()
}, [])
const closeHost = useCallback(
(hostId: string) => {
closeEntry(hostId, { forgetPrimedHost: true, preserveAcquisitions: true })
},
[closeEntry]
)
const openEntry = useCallback(async (hostId: string): Promise<StoreEntry | null> => {
const existing = pendingOpensRef.current.getActivePromise(hostId)
if (existing) {
@@ -151,9 +168,10 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
const entry: StoreEntry = {
client,
state: client.getState(),
refCount: 0,
refCount: pendingAcquisitionsRef.current.get(hostId) ?? 0,
unsubState
}
pendingAcquisitionsRef.current.delete(hostId)
storeRef.current.set(hostId, entry)
notifyHostState(hostId, entry.state)
notifyAllHosts()
@@ -175,50 +193,83 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
existing.refCount += 1
return existing.client
}
pendingAcquisitionsRef.current.set(
hostId,
(pendingAcquisitionsRef.current.get(hostId) ?? 0) + 1
)
// Trigger async open; returns null this tick — consumers re-call acquire() from an effect that re-runs on state changes.
void openEntry(hostId).then((entry) => {
if (!entry) {
return
}
entry.refCount += 1
})
void openEntry(hostId)
return null
},
[openEntry]
)
const primeHosts = useCallback((hosts: HostProfile[]) => {
for (const host of hosts) {
primedHostsRef.current.set(host.id, host)
}
}, [])
const primeHosts = useCallback(
(hosts: HostProfile[]) => primeHostProfiles(primedHostsRef.current, hosts),
[]
)
// Why: no idle-close on refcount→0 — transient nav gaps flashed false 'disconnected', so keep sockets alive while foregrounded.
const release = useCallback((hostId: string) => {
const entry = storeRef.current.get(hostId)
if (!entry) {
if (entry) {
entry.refCount = Math.max(0, entry.refCount - 1)
return
}
entry.refCount = Math.max(0, entry.refCount - 1)
decrementPendingAcquisition(pendingAcquisitionsRef.current, hostId)
}, [])
const releaseAndCloseIfUnused = useCallback(
(hostId: string) => {
const entry = storeRef.current.get(hostId)
if (entry) {
entry.refCount = Math.max(0, entry.refCount - 1)
if (entry.refCount === 0) {
closeEntry(hostId, { forgetPrimedHost: false, preserveAcquisitions: false })
}
return
}
if (decrementPendingAcquisition(pendingAcquisitionsRef.current, hostId) === 0) {
closeEntry(hostId, { forgetPrimedHost: false, preserveAcquisitions: false })
}
},
[closeEntry]
)
const closeIfUnused = useCallback(
(hostId: string) => {
const entry = storeRef.current.get(hostId)
const pendingCount = pendingAcquisitionsRef.current.get(hostId)
const hasPendingOpen = pendingOpensRef.current.getActivePromise(hostId) !== null
if (!entry && pendingCount === undefined && !hasPendingOpen) {
return
}
if ((entry?.refCount ?? pendingCount ?? 0) === 0) {
closeEntry(hostId, { forgetPrimedHost: false, preserveAcquisitions: false })
}
},
[closeEntry]
)
const forceReconnect = useCallback(
async (hostId: string) => {
const entry = storeRef.current.get(hostId)
// Why: preserve refcount across the swap; via Disconnect→Reconnect the entry is already gone, so fall back to listener count.
const listenerCount = stateListenersRef.current.get(hostId)?.size ?? 0
const savedRefCount = entry?.refCount ?? Math.max(1, listenerCount)
// Why: ownership survives explicit close/re-pair while observers never become synthetic owners.
const savedRefCount = entry?.refCount ?? pendingAcquisitionsRef.current.get(hostId) ?? 0
if (entry) {
entry.unsubState()
entry.client.close()
storeRef.current.delete(hostId)
}
if (savedRefCount > 0) {
pendingAcquisitionsRef.current.set(
hostId,
Math.max(savedRefCount, pendingAcquisitionsRef.current.get(hostId) ?? 0)
)
}
// Why: Retry must read amber for the whole reopen, not grey-then-amber.
notifyHostState(hostId, 'connecting')
const fresh = await openEntry(hostId)
if (fresh) {
fresh.refCount = savedRefCount
}
await openEntry(hostId)
},
[openEntry]
)
@@ -252,41 +303,17 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
}, [])
const subscribeHostState = useCallback(
(hostId: string, listener: (state: ConnectionState) => void) => {
let set = stateListenersRef.current.get(hostId)
if (!set) {
set = new Set()
stateListenersRef.current.set(hostId, set)
}
set.add(listener)
return () => {
const s = stateListenersRef.current.get(hostId)
if (!s) {
return
}
s.delete(listener)
if (s.size === 0) {
stateListenersRef.current.delete(hostId)
}
}
},
(hostId: string, listener: (state: ConnectionState) => void) =>
subscribeHostStateListener(stateListenersRef.current, hostId, listener),
[]
)
const getAllClients = useCallback((): Array<{ hostId: string; client: RpcClient }> => {
const out: Array<{ hostId: string; client: RpcClient }> = []
for (const [hostId, entry] of storeRef.current) {
out.push({ hostId, client: entry.client })
}
return out
}, [])
const getAllClients = useCallback(() => listHostClients(storeRef.current), [])
const subscribeAllHosts = useCallback((listener: () => void) => {
allHostsListenersRef.current.add(listener)
return () => {
allHostsListenersRef.current.delete(listener)
}
}, [])
const subscribeAllHosts = useCallback(
(listener: () => void) => subscribeAllHostListener(allHostsListenersRef.current, listener),
[]
)
// Close all clients on provider unmount. Empty deps: [closeEntry] would let Fast Refresh tear down all live sockets.
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -294,8 +321,9 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
const store = storeRef.current
return () => {
pendingOpensRef.current.cancelAll()
pendingAcquisitionsRef.current.clear()
for (const [hostId] of store) {
closeEntry(hostId)
closeEntry(hostId, { forgetPrimedHost: true, preserveAcquisitions: false })
}
}
}, [])
@@ -313,8 +341,10 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
() => ({
acquire,
release,
releaseAndCloseIfUnused,
closeIfUnused,
forceReconnect,
closeHost: closeEntry,
closeHost,
getState,
getKnownState,
getReconnectAttempt,
@@ -328,8 +358,10 @@ export function RpcClientProvider({ children }: { children: ReactNode }) {
[
acquire,
release,
releaseAndCloseIfUnused,
closeIfUnused,
forceReconnect,
closeEntry,
closeHost,
getState,
getKnownState,
getReconnectAttempt,
@@ -437,13 +469,3 @@ export function usePrimeHosts(): (hosts: HostProfile[]) => void {
const ctx = useRpcClientContext()
return ctx.primeHosts
}
function clientActivePath(client: RpcClient | undefined): MobileConnectionPath {
const logical = client as Partial<StableLogicalRpcClient> | undefined
if (typeof logical?.getActivePath !== 'function') {
return 'lan'
}
// Why: mid-migration the active path still names the session being replaced; the
// pending one is what the user is actually waiting on (F5).
return logical.getPendingPath?.() ?? logical.getActivePath()
}
@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest'
import type { HostProfile } from './types'
import {
HOME_AUTO_CONNECT_LIMIT,
resolveHomeHostConnectionState,
selectHomeAutoConnectHostIds
} from './home-host-auto-connect'
function host(id: string, lastConnected: number, credentials = true): HostProfile {
return {
id,
name: id,
endpoint: `ws://${id}`,
deviceToken: credentials ? `token-${id}` : '',
publicKeyB64: credentials ? `key-${id}` : '',
lastConnected
}
}
describe('home host auto-connect', () => {
it('limits startup connections to the most recently used credentialed hosts', () => {
const hosts = [
host('old', 1),
host('newest', 5),
host('second', 4),
host('third', 3),
host('fourth', 2),
host('missing-credentials', 6, false)
]
expect(selectHomeAutoConnectHostIds(hosts)).toEqual(['newest', 'second', 'third'])
expect(selectHomeAutoConnectHostIds(hosts)).toHaveLength(HOME_AUTO_CONNECT_LIMIT)
})
it('does not mutate the host card order', () => {
const hosts = [host('old', 1), host('new', 2)]
selectHomeAutoConnectHostIds(hosts)
expect(hosts.map((item) => item.id)).toEqual(['old', 'new'])
})
it('only presents hosts in the startup subset as connecting before clients open', () => {
const autoConnectHostIds = ['recent']
expect(resolveHomeHostConnectionState('recent', undefined, autoConnectHostIds)).toBe(
'connecting'
)
expect(resolveHomeHostConnectionState('stale', undefined, autoConnectHostIds)).toBe(
'disconnected'
)
expect(resolveHomeHostConnectionState('stale', 'connected', autoConnectHostIds)).toBe(
'connected'
)
})
})
@@ -0,0 +1,24 @@
import type { ConnectionState, HostProfile } from './types'
export const HOME_AUTO_CONNECT_LIMIT = 3
export function selectHomeAutoConnectHostIds(
hosts: readonly HostProfile[],
limit = HOME_AUTO_CONNECT_LIMIT
): string[] {
return [...hosts]
.filter((host) => host.deviceToken.length > 0 && host.publicKeyB64.length > 0)
.sort(
(left, right) => right.lastConnected - left.lastConnected || left.id.localeCompare(right.id)
)
.slice(0, Math.max(0, limit))
.map((host) => host.id)
}
export function resolveHomeHostConnectionState(
hostId: string,
state: ConnectionState | undefined,
autoConnectHostIds: readonly string[]
): ConnectionState {
return state ?? (autoConnectHostIds.includes(hostId) ? 'connecting' : 'disconnected')
}
@@ -0,0 +1,52 @@
import type { MobileRelayHostOverlay } from './mobile-relay-host-overlay'
import type { HostListSnapshot } from './host-list-load-sharing'
import type { HostCatalogEntry, StoredHostProfile } from './types'
export async function joinHostCatalogCredentials(args: {
storedHosts: readonly StoredHostProfile[]
overlays: ReadonlyMap<string, MobileRelayHostOverlay>
tokenCache: Map<string, string>
readToken: (hostId: string) => Promise<string | null>
getRevision: () => number
}): Promise<HostListSnapshot> {
const catalog: HostCatalogEntry[] = []
const profiles: HostListSnapshot['profiles'] = []
for (const stored of args.storedHosts) {
let token = args.tokenCache.get(stored.id)
let credentialStatus: HostCatalogEntry['credentialStatus'] = 'ready'
if (!token) {
const readRevision = args.getRevision()
let fetched: string | null
try {
fetched = await args.readToken(stored.id)
} catch {
credentialStatus = 'temporarily-unavailable'
fetched = null
}
if (!fetched && credentialStatus === 'ready') {
credentialStatus = 'missing'
}
token = fetched ?? undefined
if (token && readRevision === args.getRevision()) {
args.tokenCache.set(stored.id, token)
}
}
const overlay = args.overlays.get(stored.id)
const base = {
...stored,
...(overlay
? {
endpoints: overlay.endpoints,
relayHostId: overlay.relayHostId,
relay: overlay.relay
}
: {})
}
const profile = token ? { ...base, deviceToken: token } : null
catalog.push({ ...base, credentialStatus, profile })
if (profile) {
profiles.push(profile)
}
}
return { catalog, profiles }
}
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest'
import { selectConnectableHostProfiles } from './host-catalog-selection'
import type { HostCatalogEntry, HostProfile } from './types'
const profile: HostProfile = {
id: 'host-ready',
name: 'Ready',
endpoint: 'ws://127.0.0.1:1',
deviceToken: 'token',
publicKeyB64: 'key-ready',
lastConnected: 0
}
function unavailable(
id: string,
credentialStatus: 'temporarily-unavailable' | 'missing'
): HostCatalogEntry {
return {
id,
name: id,
endpoint: `ws://127.0.0.1/${id}`,
publicKeyB64: `key-${id}`,
lastConnected: 0,
credentialStatus,
profile: null
}
}
describe('selectConnectableHostProfiles', () => {
it('excludes unavailable catalog entries from connection priming', () => {
expect(
selectConnectableHostProfiles([
{ ...profile, credentialStatus: 'ready', profile },
unavailable('host-locked', 'temporarily-unavailable'),
unavailable('host-missing', 'missing')
])
).toEqual([profile])
})
})
@@ -0,0 +1,7 @@
import type { HostCatalogEntry, HostProfile } from './types'
export function selectConnectableHostProfiles(catalog: readonly HostCatalogEntry[]): HostProfile[] {
return catalog.flatMap((entry) =>
entry.credentialStatus === 'ready' && entry.profile ? [entry.profile] : []
)
}
@@ -0,0 +1,9 @@
export function decrementPendingAcquisition(pending: Map<string, number>, hostId: string): number {
const next = Math.max(0, (pending.get(hostId) ?? 0) - 1)
if (next === 0) {
pending.delete(hostId)
} else {
pending.set(hostId, next)
}
return next
}
@@ -0,0 +1,72 @@
import type { RpcClient } from './rpc-client'
import type { MobileConnectionPath, StableLogicalRpcClient } from './stable-logical-rpc-client'
import type { ConnectionState, HostProfile } from './types'
export type CloseEntryOptions = {
forgetPrimedHost: boolean
preserveAcquisitions: boolean
}
export function notifyHostStateListeners(
listeners: Map<string, Set<(state: ConnectionState) => void>>,
hostId: string,
state: ConnectionState
): void {
for (const listener of listeners.get(hostId) ?? []) {
listener(state)
}
}
export function notifyAllHostListeners(listeners: Set<() => void>): void {
for (const listener of listeners) {
listener()
}
}
export function subscribeHostStateListener(
listeners: Map<string, Set<(state: ConnectionState) => void>>,
hostId: string,
listener: (state: ConnectionState) => void
): () => void {
let hostListeners = listeners.get(hostId)
if (!hostListeners) {
hostListeners = new Set()
listeners.set(hostId, hostListeners)
}
hostListeners.add(listener)
return () => {
hostListeners.delete(listener)
if (hostListeners.size === 0) {
listeners.delete(hostId)
}
}
}
export function subscribeAllHostListener(
listeners: Set<() => void>,
listener: () => void
): () => void {
listeners.add(listener)
return () => listeners.delete(listener)
}
export function listHostClients(
entries: ReadonlyMap<string, { client: RpcClient }>
): { hostId: string; client: RpcClient }[] {
return [...entries].map(([hostId, entry]) => ({ hostId, client: entry.client }))
}
export function primeHostProfiles(cache: Map<string, HostProfile>, hosts: HostProfile[]): void {
for (const host of hosts) {
cache.set(host.id, host)
}
}
export function clientActivePath(client: RpcClient | undefined): MobileConnectionPath {
const logical = client as Partial<StableLogicalRpcClient> | undefined
if (typeof logical?.getActivePath !== 'function') {
return 'lan'
}
// Why: during migration the pending path is what the user is waiting on.
return logical.getPendingPath?.() ?? logical.getActivePath()
}
@@ -1,8 +1,10 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
cancelPendingHostCredentialCleanup,
loadPendingHostCredentialCleanup,
loadPendingHostCredentialCleanupIds,
recordHostCredentialCleanupIntent,
resetHostCredentialCleanupForTests,
retryPendingHostCredentialCleanups,
scheduleHostCredentialCleanup,
@@ -66,6 +68,12 @@ describe('host credential cleanup', () => {
expect(deleteCredential).toHaveBeenCalledOnce()
})
it('records cleanup intent without starting native deletion', async () => {
await recordHostCredentialCleanupIntent('host-1')
await expect(loadPendingHostCredentialCleanupIds()).resolves.toEqual(['host-1'])
})
it('persists cleanup intent before a rejected delete for manual retry', async () => {
const deleteCredential = vi.fn().mockRejectedValue(new Error('keychain unavailable'))
const listener = vi.fn()
@@ -78,6 +86,18 @@ describe('host credential cleanup', () => {
unsubscribe()
})
it('cancels stale cleanup intent after replacement publication', async () => {
const deleteCredential = vi.fn().mockRejectedValue(new Error('superseded'))
await scheduleHostCredentialCleanup('host-1', deleteCredential, 20)
await vi.waitFor(() => expect(deleteCredential).toHaveBeenCalledOnce())
await expect(loadPendingHostCredentialCleanupIds()).resolves.toEqual(['host-1'])
await cancelPendingHostCredentialCleanup('host-1')
await expect(loadPendingHostCredentialCleanupIds()).resolves.toEqual([])
})
it('records intent before a stalled delete times out', async () => {
vi.useFakeTimers()
const deleteCredential = vi.fn(() => new Promise<void>(() => undefined))
@@ -155,13 +155,17 @@ function startOrJoinDelete(hostId: string, deleteCredential: DeleteHostCredentia
async function recordCleanupIntent(hostId: string): Promise<boolean> {
try {
await addPendingId(hostId)
await recordHostCredentialCleanupIntent(hostId)
return true
} catch {
return false
}
}
export async function recordHostCredentialCleanupIntent(hostId: string): Promise<void> {
await addPendingId(hostId)
}
async function confirmNativeCleanup(
hostId: string,
deleteCredential: DeleteHostCredential,
@@ -209,6 +213,11 @@ export function subscribePendingHostCredentialCleanup(listener: () => void): ()
return () => pendingListeners.delete(listener)
}
export async function cancelPendingHostCredentialCleanup(hostId: string): Promise<void> {
clearUnrecordedPending(hostId)
await removePendingId(hostId)
}
/**
* Record cleanup intent, then fire-and-forget the native keychain delete so
* removeHost never blocks on SecureStore. If the durable intent write fails,
@@ -0,0 +1,21 @@
let nextRevision = 1
const revisionByHostId = new Map<string, number>()
export function getHostCredentialWriteRevision(hostId: string): number {
return revisionByHostId.get(hostId) ?? 0
}
export function markHostCredentialWrite(hostId: string): void {
revisionByHostId.set(hostId, nextRevision)
nextRevision += 1
}
export function clearHostCredentialWriteRevision(hostId: string): void {
revisionByHostId.delete(hostId)
}
/** Test-only: clear session credential generations between cases. */
export function resetHostCredentialWriteRevisionsForTests(): void {
nextRevision = 1
revisionByHostId.clear()
}
@@ -0,0 +1,43 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import { Platform } from 'react-native'
import {
deletePairingKeychainItem,
readPairingKeychainItem,
writePairingKeychainItem
} from './pairing-keychain'
// Why: SecureStore keys must match [A-Za-z0-9._-] (colons rejected), so use dots as the separator.
const TOKEN_KEY_PREFIX = 'orca.host-token.'
const WEB_TOKEN_KEY_PREFIX = 'orca:web-host-token:'
function tokenKey(hostId: string): string {
return `${TOKEN_KEY_PREFIX}${hostId}`
}
function webTokenKey(hostId: string): string {
return `${WEB_TOKEN_KEY_PREFIX}${hostId}`
}
export async function readHostDeviceToken(hostId: string): Promise<string | null> {
// Why: Expo SecureStore has no working web backend; fall back to AsyncStorage only on web so native still uses the keychain.
if (Platform.OS === 'web') {
return AsyncStorage.getItem(webTokenKey(hostId))
}
return readPairingKeychainItem(tokenKey(hostId))
}
export async function writeHostDeviceToken(hostId: string, token: string): Promise<void> {
if (Platform.OS === 'web') {
await AsyncStorage.setItem(webTokenKey(hostId), token)
return
}
await writePairingKeychainItem(tokenKey(hostId), token)
}
export async function deleteHostDeviceToken(hostId: string): Promise<void> {
if (Platform.OS === 'web') {
await AsyncStorage.removeItem(webTokenKey(hostId))
return
}
await deletePairingKeychainItem(tokenKey(hostId))
}
@@ -52,7 +52,7 @@ describe('mobile host edit navigation', () => {
const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] })
const push = vi.fn()
navigateToMobileHostEdit(harness.navigation, { push }, 'host/1')
navigateToMobileHostEdit(harness.navigation, { push, replace: vi.fn() }, 'host/1')
expect(push).toHaveBeenCalledWith(mobileHostEditHostRoute('host/1'))
expect(harness.navigation.dispatch).not.toHaveBeenCalled()
@@ -75,7 +75,7 @@ describe('mobile host edit navigation', () => {
it('does not replace an unrelated host route', () => {
const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] })
navigateToMobileHostEdit(harness.navigation, { push: vi.fn() }, 'host-1')
navigateToMobileHostEdit(harness.navigation, { push: vi.fn(), replace: vi.fn() }, 'host-1')
harness.setState(committedHostState('host-2'))
expect(harness.navigation.dispatch).not.toHaveBeenCalled()
@@ -83,7 +83,7 @@ describe('mobile host edit navigation', () => {
it('disposes itself when navigation leaves the host flow without cancel()', () => {
const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] })
navigateToMobileHostEdit(harness.navigation, { push: vi.fn() }, 'host-1')
navigateToMobileHostEdit(harness.navigation, { push: vi.fn(), replace: vi.fn() }, 'host-1')
// Enter the host flow for a different host, then leave it entirely.
harness.setState(committedHostState('host-2'))
@@ -97,7 +97,11 @@ describe('mobile host edit navigation', () => {
it('cancels a pending replacement when navigation leaves the host flow', () => {
const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] })
const controller = navigateToMobileHostEdit(harness.navigation, { push: vi.fn() }, 'host-1')
const controller = navigateToMobileHostEdit(
harness.navigation,
{ push: vi.fn(), replace: vi.fn() },
'host-1'
)
harness.setState(committedHostState('host-2'))
controller.cancel()
+10 -3
View File
@@ -1,15 +1,22 @@
import type { HostProfile } from './types'
import type { HostCatalogEntry, HostProfile } from './types'
export type HostListSnapshot = {
catalog: HostCatalogEntry[]
profiles: HostProfile[]
}
// Why: concurrent callers share a slow Keychain pass; durable writes invalidate
// it so later loads cannot receive stale snapshots (#8791).
let inflight: Promise<HostProfile[]> | null = null
let inflight: Promise<HostListSnapshot> | null = null
let revision = 0
export function getHostListLoadRevision(): number {
return revision
}
export function shareHostListLoad(load: () => Promise<HostProfile[]>): Promise<HostProfile[]> {
export function shareHostListLoad(
load: () => Promise<HostListSnapshot>
): Promise<HostListSnapshot> {
if (inflight) {
return inflight
}
@@ -0,0 +1,51 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import { StoredHostProfileSchema, type HostProfile, type StoredHostProfile } from './types'
const STORAGE_KEY = 'orca:hosts'
export async function loadStoredHostProfiles(): Promise<StoredHostProfile[] | null> {
return parseStoredHostProfiles(await AsyncStorage.getItem(STORAGE_KEY))
}
export async function readStoredHostProfilesForMutation(): Promise<StoredHostProfile[]> {
try {
const parsed = await loadStoredHostProfiles()
if (parsed) {
return parsed
}
} catch {
// Normalize storage and payload failures for fail-closed mutations.
}
throw new Error('host list storage unreadable')
}
export function writeStoredHostProfiles(hosts: readonly StoredHostProfile[]): Promise<void> {
return AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(hosts))
}
export function toStoredHostProfile(host: HostProfile): StoredHostProfile {
const { id, name, endpoint, publicKeyB64, lastConnected } = host
return { id, name, endpoint, publicKeyB64, lastConnected }
}
function parseStoredHostProfiles(raw: string | null): StoredHostProfile[] | null {
if (!raw) {
return []
}
try {
const parsed = JSON.parse(raw) as unknown
if (!Array.isArray(parsed)) {
return null
}
return parsed.flatMap((item) => {
// Why: pre-v0.0.3 records embedded secrets; users re-pair instead of migrating them.
if (item && typeof item === 'object' && 'deviceToken' in item) {
return []
}
const result = StoredHostProfileSchema.safeParse(item)
return result.success ? [result.data] : []
})
} catch {
return null
}
}
+328 -3
View File
@@ -14,6 +14,8 @@ const secureStoreMock = vi.hoisted(() => ({
}))
const scheduleCleanupMock = vi.hoisted(() => vi.fn())
const cancelCleanupMock = vi.hoisted(() => vi.fn())
const recordCleanupIntentMock = vi.hoisted(() => vi.fn())
const platformMock = vi.hoisted(() => ({ OS: 'ios' }))
vi.mock('@react-native-async-storage/async-storage', () => ({
@@ -30,11 +32,14 @@ vi.mock('react-native', () => ({
}))
vi.mock('./host-credential-cleanup', () => ({
cancelPendingHostCredentialCleanup: (...args: unknown[]) => cancelCleanupMock(...args),
recordHostCredentialCleanupIntent: (...args: unknown[]) => recordCleanupIntentMock(...args),
scheduleHostCredentialCleanup: (...args: unknown[]) => scheduleCleanupMock(...args),
retryPendingHostCredentialCleanups: vi.fn()
}))
import {
loadHostCatalog,
loadHosts,
MobileRelayUpgradeHostRemovedError,
removeHost,
@@ -46,6 +51,7 @@ import {
updateLastConnected
} from './host-store'
import { resetMobileRelayHostOverlayStoreForTests } from './mobile-relay-host-overlay-store'
import { writeMobileRelayCredentialBundle } from './mobile-relay-credential-bundle'
const HOSTS_STORAGE_KEY = 'orca:hosts'
const OVERLAY_STORAGE_KEY = 'orca:mobile-relay:host-overlays:v2'
@@ -63,6 +69,23 @@ const HOST_TWO = {
publicKeyB64: 'key-2',
lastConnected: 0
}
const HOST_ONE_RELAY_BUNDLE = {
v: 1 as const,
hostId: HOST_ONE.id,
deviceToken: 'replacement-token',
current: {
token: 'A'.repeat(43),
hash: 'B'.repeat(43),
version: 1,
expiresAt: 10_000
}
}
function scheduledCleanup(hostId: string): (id: string) => Promise<void> {
const cleanup = scheduleCleanupMock.mock.calls.find(([id]) => id === hostId)?.[1]
expect(cleanup).toBeTypeOf('function')
return cleanup as (id: string) => Promise<void>
}
describe('host-store list mutations', () => {
let storedHostsRaw: string
@@ -75,6 +98,10 @@ describe('host-store list mutations', () => {
resetMobileRelayHostOverlayStoreForTests()
scheduleCleanupMock.mockReset()
scheduleCleanupMock.mockResolvedValue(undefined)
cancelCleanupMock.mockReset()
cancelCleanupMock.mockResolvedValue(undefined)
recordCleanupIntentMock.mockReset()
recordCleanupIntentMock.mockResolvedValue(undefined)
storedHostsRaw = JSON.stringify([HOST_ONE, HOST_TWO])
storedOverlayRaw = null
asyncStorageMock.getItem.mockImplementation(async (key: string) => {
@@ -93,6 +120,7 @@ describe('host-store list mutations', () => {
storedOverlayRaw = raw
}
})
secureStoreMock.setItemAsync.mockResolvedValue(undefined)
secureStoreMock.getItemAsync.mockImplementation(async (key: string) =>
key.endsWith(HOST_ONE.id) || key.endsWith(HOST_TWO.id) ? `token-${key.at(-1)}` : null
)
@@ -114,6 +142,55 @@ describe('host-store list mutations', () => {
expect(asyncStorageMock.getItem).toHaveBeenCalledOnce()
})
it('keeps a host visible when its credential read temporarily fails', async () => {
secureStoreMock.getItemAsync.mockImplementation(async (key: string) => {
if (key.endsWith(HOST_ONE.id)) {
throw new Error('keychain locked')
}
return key.endsWith(HOST_TWO.id) ? 'token-2' : null
})
const catalog = await loadHostCatalog()
const profiles = await loadHosts()
expect(catalog.map(({ id }) => id)).toEqual([HOST_ONE.id, HOST_TWO.id])
expect(catalog.find(({ id }) => id === HOST_ONE.id)).toMatchObject({
credentialStatus: 'temporarily-unavailable',
profile: null
})
expect(profiles.map(({ id }) => id)).toEqual([HOST_TWO.id])
})
it('keeps orphaned metadata visible when its credential is missing', async () => {
secureStoreMock.getItemAsync.mockImplementation(async (key: string) =>
key.endsWith(HOST_TWO.id) ? 'token-2' : null
)
const catalog = await loadHostCatalog()
expect(catalog.find(({ id }) => id === HOST_ONE.id)).toMatchObject({
credentialStatus: 'missing',
profile: null
})
})
it('keeps existing metadata when pairing a distinct host', async () => {
await saveHost({
id: 'host-new',
name: 'Host 3',
endpoint: 'ws://127.0.0.1:3',
publicKeyB64: 'key-new',
deviceToken: 'token-new',
lastConnected: 0
})
expect(JSON.parse(storedHostsRaw).map(({ id }: { id: string }) => id)).toEqual([
HOST_ONE.id,
HOST_TWO.id,
'host-new'
])
})
it('fails closed when durable host identity storage is unreadable', async () => {
asyncStorageMock.getItem.mockRejectedValueOnce(new Error('storage unavailable'))
@@ -134,9 +211,173 @@ describe('host-store list mutations', () => {
})
expect(JSON.parse(storedHostsRaw)).toEqual([{ ...HOST_ONE, endpoint: 'ws://127.0.0.1:3' }])
expect(recordCleanupIntentMock.mock.invocationCallOrder[0]).toBeLessThan(
asyncStorageMock.setItem.mock.invocationCallOrder[0]!
)
expect(scheduleCleanupMock).toHaveBeenCalledWith('host-duplicate', expect.any(Function))
})
it('does not remove same-key metadata when the replacement credential write fails', async () => {
const duplicate = {
...HOST_TWO,
id: 'host-duplicate',
publicKeyB64: HOST_ONE.publicKeyB64
}
storedHostsRaw = JSON.stringify([HOST_ONE, duplicate])
secureStoreMock.setItemAsync.mockRejectedValue(new Error('keychain write failed'))
await expect(
saveHost({ ...HOST_ONE, endpoint: 'ws://127.0.0.1:3', deviceToken: 'replacement-token' })
).rejects.toThrow('keychain write failed')
expect(JSON.parse(storedHostsRaw)).toEqual([HOST_ONE, duplicate])
expect(cancelCleanupMock).toHaveBeenCalledWith(duplicate.id)
expect(scheduleCleanupMock).not.toHaveBeenCalled()
})
it('serves a committed replacement token when same-key metadata persistence fails', async () => {
const duplicate = {
...HOST_TWO,
id: 'host-duplicate',
publicKeyB64: HOST_ONE.publicKeyB64
}
storedHostsRaw = JSON.stringify([HOST_ONE, duplicate])
await loadHosts()
asyncStorageMock.setItem.mockRejectedValueOnce(new Error('metadata write failed'))
await expect(
saveHost({ ...HOST_ONE, endpoint: 'ws://127.0.0.1:3', deviceToken: 'replacement-token' })
).rejects.toThrow('metadata write failed')
expect(JSON.parse(storedHostsRaw)).toEqual([HOST_ONE, duplicate])
await expect(
loadHosts().then((hosts) => hosts.find(({ id }) => id === HOST_ONE.id)?.deviceToken)
).resolves.toBe('replacement-token')
})
it('records cleanup when new same-key metadata fails after its credential commits', async () => {
const replacement = {
...HOST_TWO,
id: 'host-replacement',
publicKeyB64: HOST_ONE.publicKeyB64,
deviceToken: 'replacement-token'
}
asyncStorageMock.setItem.mockRejectedValueOnce(new Error('metadata write failed'))
await expect(saveHost(replacement)).rejects.toThrow('metadata write failed')
expect(JSON.parse(storedHostsRaw)).toEqual([HOST_ONE, HOST_TWO])
expect(recordCleanupIntentMock).toHaveBeenCalledWith(replacement.id)
expect(recordCleanupIntentMock.mock.invocationCallOrder[0]).toBeLessThan(
secureStoreMock.setItemAsync.mock.invocationCallOrder[0]!
)
expect(scheduleCleanupMock).toHaveBeenCalledWith(replacement.id, expect.any(Function))
})
it('does not clean a duplicate id that a later same-key save made authoritative', async () => {
const duplicate = {
...HOST_TWO,
id: 'host-duplicate',
publicKeyB64: HOST_ONE.publicKeyB64
}
storedHostsRaw = JSON.stringify([HOST_ONE, duplicate])
await saveHost({ ...HOST_ONE, deviceToken: 'token-a' })
const staleCleanup = scheduledCleanup(duplicate.id)
await saveHost({ ...duplicate, deviceToken: 'token-b' })
await staleCleanup(duplicate.id)
expect(JSON.parse(storedHostsRaw)).toEqual([duplicate])
expect(secureStoreMock.deleteItemAsync).not.toHaveBeenCalled()
})
it('does not let stale removal cleanup delete a replacement relay bundle before publication', async () => {
await removeHost(HOST_ONE.id)
const staleCleanup = scheduledCleanup(HOST_ONE.id)
await writeMobileRelayCredentialBundle(HOST_ONE_RELAY_BUNDLE)
await expect(staleCleanup(HOST_ONE.id)).rejects.toThrow('credential write superseded cleanup')
expect(secureStoreMock.deleteItemAsync).not.toHaveBeenCalled()
})
it('keeps stale cleanup pending when a replacement relay write fails', async () => {
await removeHost(HOST_ONE.id)
const staleCleanup = scheduledCleanup(HOST_ONE.id)
secureStoreMock.setItemAsync.mockRejectedValueOnce(new Error('keychain write failed'))
await expect(writeMobileRelayCredentialBundle(HOST_ONE_RELAY_BUNDLE)).rejects.toThrow(
'keychain write failed'
)
await expect(staleCleanup(HOST_ONE.id)).rejects.toThrow('credential write superseded cleanup')
expect(secureStoreMock.deleteItemAsync).not.toHaveBeenCalled()
})
it('stops stale cleanup when a replacement relay write starts during token deletion', async () => {
await removeHost(HOST_ONE.id)
const staleCleanup = scheduledCleanup(HOST_ONE.id)
let releaseTokenDelete: () => void = () => {}
secureStoreMock.deleteItemAsync.mockReturnValueOnce(
new Promise<void>((resolve) => {
releaseTokenDelete = resolve
})
)
const cleanup = staleCleanup(HOST_ONE.id)
await vi.waitFor(() => {
expect(secureStoreMock.deleteItemAsync).toHaveBeenCalledWith(
'orca.host-token.host-1',
expect.anything()
)
})
const bundleWrite = writeMobileRelayCredentialBundle(HOST_ONE_RELAY_BUNDLE)
releaseTokenDelete()
await bundleWrite
await expect(cleanup).rejects.toThrow('credential write superseded cleanup')
expect(secureStoreMock.deleteItemAsync).not.toHaveBeenCalledWith(
'orca.mobile-relay.credentials.host-1',
expect.anything()
)
})
it('rechecks the write generation immediately before starting credential deletion', async () => {
await removeHost(HOST_ONE.id)
const staleCleanup = scheduledCleanup(HOST_ONE.id)
let resolveCleanupRead: (raw: string) => void = () => {}
const cleanupRead = new Promise<string>((resolve) => {
resolveCleanupRead = resolve
})
let cleanupReadStarted = false
asyncStorageMock.getItem.mockImplementation(async (key: string) => {
if (key === HOSTS_STORAGE_KEY && !cleanupReadStarted) {
cleanupReadStarted = true
return cleanupRead
}
if (key === HOSTS_STORAGE_KEY) {
return storedHostsRaw
}
if (key === OVERLAY_STORAGE_KEY) {
return storedOverlayRaw
}
return null
})
const cleanup = staleCleanup(HOST_ONE.id)
await vi.waitFor(() => expect(cleanupReadStarted).toBe(true))
let bundleWrite: Promise<void> | null = null
resolveCleanupRead(storedHostsRaw)
queueMicrotask(() => {
bundleWrite = writeMobileRelayCredentialBundle(HOST_ONE_RELAY_BUNDLE)
})
await expect(cleanup).rejects.toThrow('credential write superseded cleanup')
await vi.waitFor(() => expect(bundleWrite).not.toBeNull())
await bundleWrite
expect(secureStoreMock.deleteItemAsync).not.toHaveBeenCalled()
})
it('clears stale relay state when an existing host is re-paired direct-only', async () => {
const overlay: MobileRelayHostOverlay = {
v: 2,
@@ -209,7 +450,63 @@ describe('host-store list mutations', () => {
expect(scheduleCleanupMock).toHaveBeenCalledWith(HOST_ONE.id, expect.any(Function))
})
it('merges v2 endpoints only onto an existing legacy base host', async () => {
it('cancels write-ahead cleanup when host metadata removal fails', async () => {
asyncStorageMock.setItem.mockRejectedValueOnce(new Error('metadata write failed'))
await expect(removeHost(HOST_ONE.id)).rejects.toThrow('metadata write failed')
expect(JSON.parse(storedHostsRaw)).toEqual([HOST_ONE, HOST_TWO])
expect(cancelCleanupMock).toHaveBeenCalledWith(HOST_ONE.id)
})
it('does not cancel cleanup from a removal committed during a stalled save', async () => {
let releaseTokenWrite: () => void = () => {}
secureStoreMock.setItemAsync.mockReturnValueOnce(
new Promise<void>((resolve) => {
releaseTokenWrite = resolve
})
)
const save = saveHost({ ...HOST_ONE, deviceToken: 'replacement-token' })
await vi.waitFor(() => expect(secureStoreMock.setItemAsync).toHaveBeenCalledOnce())
await removeHost(HOST_ONE.id)
releaseTokenWrite()
await save
await loadHostCatalog()
expect(JSON.parse(storedHostsRaw)).toEqual([HOST_TWO])
expect(scheduleCleanupMock).toHaveBeenCalledWith(HOST_ONE.id, expect.any(Function))
expect(cancelCleanupMock).not.toHaveBeenCalled()
})
it('refuses an early same-key token write without durable cleanup intent', async () => {
const replacement = {
...HOST_TWO,
id: 'host-replacement',
publicKeyB64: HOST_ONE.publicKeyB64,
deviceToken: 'replacement-token'
}
recordCleanupIntentMock.mockRejectedValueOnce(new Error('intent storage unavailable'))
await expect(saveHost(replacement)).rejects.toThrow('intent storage unavailable')
expect(JSON.parse(storedHostsRaw)).toEqual([HOST_ONE, HOST_TWO])
expect(secureStoreMock.setItemAsync).not.toHaveBeenCalled()
})
it('deletes an unpaired credential when cleanup runs without a newer write', async () => {
await removeHost(HOST_ONE.id)
const cleanup = scheduledCleanup(HOST_ONE.id)
await cleanup(HOST_ONE.id)
expect(secureStoreMock.deleteItemAsync).toHaveBeenCalledWith(
'orca.host-token.host-1',
expect.anything()
)
})
it('keeps a concurrently re-published orphan overlay authoritative', async () => {
const overlay: MobileRelayHostOverlay = {
v: 2,
hostId: HOST_ONE.id,
@@ -232,8 +529,26 @@ describe('host-store list mutations', () => {
}
}
storedOverlayRaw = JSON.stringify([overlay, { ...overlay, hostId: 'removed-by-old-build' }])
let releaseCleanup: () => void = () => {}
scheduleCleanupMock.mockReturnValueOnce(
new Promise<void>((resolve) => {
releaseCleanup = resolve
})
)
const hosts = await loadHosts()
const hostsLoad = loadHosts()
await vi.waitFor(() => expect(scheduleCleanupMock).toHaveBeenCalled())
await saveHost({
...HOST_ONE,
id: 'removed-by-old-build',
publicKeyB64: 'restored-key',
deviceToken: 'restored-token',
endpoints: overlay.endpoints,
relayHostId: overlay.relayHostId,
relay: overlay.relay
})
releaseCleanup()
const hosts = await hostsLoad
expect(hosts.find(({ id }) => id === HOST_ONE.id)).toMatchObject({
endpoints: overlay.endpoints,
@@ -241,6 +556,10 @@ describe('host-store list mutations', () => {
relay: overlay.relay
})
expect(hosts.some(({ id }) => id === 'removed-by-old-build')).toBe(false)
expect(JSON.parse(storedOverlayRaw!)).toContainEqual({
...overlay,
hostId: 'removed-by-old-build'
})
})
it('refuses to resurrect a removed host during relay upgrade publication', async () => {
@@ -270,7 +589,9 @@ describe('host-store list mutations', () => {
expect(JSON.parse(storedHostsRaw)).toEqual([HOST_TWO])
})
expect(settled).toBe(false)
expect(scheduleCleanupMock).toHaveBeenCalledOnce()
expect(recordCleanupIntentMock.mock.invocationCallOrder[0]).toBeLessThan(
asyncStorageMock.setItem.mock.invocationCallOrder[0]!
)
resolveSchedule?.()
await removal
@@ -522,6 +843,10 @@ describe('host-store pairing save after an Android encryption rejection', () =>
resetMobileRelayHostOverlayStoreForTests()
scheduleCleanupMock.mockReset()
scheduleCleanupMock.mockResolvedValue(undefined)
cancelCleanupMock.mockReset()
cancelCleanupMock.mockResolvedValue(undefined)
recordCleanupIntentMock.mockReset()
recordCleanupIntentMock.mockResolvedValue(undefined)
storedHostsRaw = '[]'
storedGenerationRaw = null
asyncStorageMock.getItem.mockImplementation(async (key: string) => {
+183 -187
View File
@@ -1,20 +1,13 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import { Platform } from 'react-native'
import {
HostProfileSchema,
StoredHostProfileSchema,
type HostProfile,
type StoredHostProfile
} from './types'
import { HostProfileSchema } from './types'
import type { HostCatalogEntry, HostProfile, StoredHostProfile } from './types'
import { getNextHostNameFromHosts } from './host-names'
import * as hostListLoads from './host-list-load-sharing'
import { joinHostCatalogCredentials } from './host-catalog-credential-join'
import { resetPairingKeychainForTests } from './pairing-keychain'
import { readHostDeviceToken, writeHostDeviceToken } from './host-device-token-store'
import {
deletePairingKeychainItem,
readPairingKeychainItem,
resetPairingKeychainForTests,
writePairingKeychainItem
} from './pairing-keychain'
import {
cancelPendingHostCredentialCleanup,
recordHostCredentialCleanupIntent,
retryPendingHostCredentialCleanups,
scheduleHostCredentialCleanup
} from './host-credential-cleanup'
@@ -24,137 +17,67 @@ import {
removeMobileRelayHostOverlays,
saveMobileRelayHostOverlay
} from './mobile-relay-host-overlay-store'
import { deleteMobileRelayCredentialBundle } from './mobile-relay-credential-bundle'
import { deleteMobileRelayDirectUpgradeJournal } from './mobile-relay-direct-upgrade-journal'
import { scheduleOrphanedMobileRelayCleanup } from './mobile-relay-orphan-cleanup'
import {
getHostCredentialWriteRevision,
markHostCredentialWrite,
resetHostCredentialWriteRevisionsForTests
} from './host-credential-write-revision'
import { createUnpairedHostCredentialDeletion } from './unpaired-host-credential-deletion'
import {
loadStoredHostProfiles,
readStoredHostProfilesForMutation,
toStoredHostProfile,
writeStoredHostProfiles
} from './host-metadata-store'
const STORAGE_KEY = 'orca:hosts'
// Why: SecureStore keys must match [A-Za-z0-9._-] (colons rejected), so use dots as the separator.
const TOKEN_KEY_PREFIX = 'orca.host-token.'
const WEB_TOKEN_KEY_PREFIX = 'orca:web-host-token:'
function tokenKey(hostId: string): string {
return `${TOKEN_KEY_PREFIX}${hostId}`
}
function webTokenKey(hostId: string): string {
return `${WEB_TOKEN_KEY_PREFIX}${hostId}`
}
async function readDeviceToken(hostId: string): Promise<string | null> {
// Why: Expo SecureStore has no working web backend; fall back to AsyncStorage only on web so native still uses the keychain.
if (Platform.OS === 'web') {
return AsyncStorage.getItem(webTokenKey(hostId))
}
return readPairingKeychainItem(tokenKey(hostId))
}
async function writeDeviceToken(hostId: string, token: string): Promise<void> {
if (Platform.OS === 'web') {
await AsyncStorage.setItem(webTokenKey(hostId), token)
return
}
await writePairingKeychainItem(tokenKey(hostId), token)
}
async function deleteDeviceToken(hostId: string): Promise<void> {
if (Platform.OS === 'web') {
await AsyncStorage.removeItem(webTokenKey(hostId))
return
}
await deletePairingKeychainItem(tokenKey(hostId))
}
async function deleteHostCredentials(hostId: string): Promise<void> {
await deleteDeviceToken(hostId)
await deleteMobileRelayCredentialBundle(hostId)
await deleteMobileRelayDirectUpgradeJournal(hostId)
async function commitDeviceToken(hostId: string, token: string): Promise<void> {
markHostCredentialWrite(hostId)
await writeHostDeviceToken(hostId, token)
tokenCache.set(hostId, token)
hostListLoads.dropSharedHostListLoad()
}
// Why: Keychain reads are slow (50-200ms) and loadHosts() runs on every screen mount; cache per-hostId in memory, invalidate on save/remove.
const tokenCache = new Map<string, string>()
// Why: serialize RMW of the shared hosts JSON; without a queue concurrent writers drop writes (resurrect a removed host, drop a rename).
// Why: serialize host metadata RMW so concurrent writers cannot drop updates.
let hostListMutation: Promise<void> = Promise.resolve()
function parseStoredHosts(raw: string | null): StoredHostProfile[] | null {
if (!raw) {
return []
}
try {
const parsed = JSON.parse(raw) as unknown
if (!Array.isArray(parsed)) {
return null
}
return parsed.flatMap((item) => {
// Why: pre-v0.0.3 records stored deviceToken in AsyncStorage; drop them (users re-pair) rather than carry a migration shim.
if (item && typeof item === 'object' && 'deviceToken' in item) {
return []
}
const result = StoredHostProfileSchema.safeParse(item)
return result.success ? [result.data] : []
})
} catch {
return null
}
}
export const loadHosts = async (): Promise<HostProfile[]> => (await loadHostListSnapshot()).profiles
export const loadHostCatalog = async (): Promise<HostCatalogEntry[]> =>
(await loadHostListSnapshot()).catalog
export async function loadHosts(): Promise<HostProfile[]> {
async function loadHostListSnapshot(): Promise<hostListLoads.HostListSnapshot> {
// Why: writers hold the mutation chain across their full RMW; wait so a load doesn't race a half-written list.
await hostListMutation
// Why: deduplicate concurrent loadHosts() calls so simultaneously mounting screens share one Keychain read pass.
return hostListLoads.shareHostListLoad(doLoadHosts)
return hostListLoads.shareHostListLoad(doLoadHostListSnapshot)
}
async function doLoadHosts(): Promise<HostProfile[]> {
const raw = await AsyncStorage.getItem(STORAGE_KEY)
const storedHosts = parseStoredHosts(raw)
async function doLoadHostListSnapshot(): Promise<hostListLoads.HostListSnapshot> {
const storedHosts = await loadStoredHostProfiles()
if (!storedHosts) {
return []
return { catalog: [], profiles: [] }
}
const overlayState = await loadMobileRelayHostOverlayState(
new Set(storedHosts.map(({ id }) => id))
)
const orphanWriteRevisions = new Map(
overlayState.orphanHostIds.map((hostId) => [hostId, getHostCredentialWriteRevision(hostId)])
)
await scheduleOrphanedMobileRelayCleanup({
hostIds: overlayState.orphanHostIds,
deleteCredential: deleteHostCredentials
deleteCredential: (hostId) =>
deleteUnpairedHostCredentials(hostId, orphanWriteRevisions.get(hostId) ?? 0),
removeOverlay: removeOrphanOverlayIfUnpaired
})
return joinHostCatalogCredentials({
storedHosts,
overlays: overlayState.overlays,
tokenCache,
readToken: readHostDeviceToken,
getRevision: hostListLoads.getHostListLoadRevision
})
const overlays = overlayState.overlays
const out: HostProfile[] = []
for (const stored of storedHosts) {
let token = tokenCache.get(stored.id)
if (!token) {
const readRevision = hostListLoads.getHostListLoadRevision()
let fetched: string | null
try {
fetched = await readDeviceToken(stored.id)
} catch {
// Why: a transient Keychain failure for one entry (e.g. errSecInteractionNotAllowed while locked) must not blank the whole host list; skip it.
continue
}
if (!fetched) {
// Why: orphaned metadata with no matching keychain entry; skip rather than surface a half-broken host.
continue
}
token = fetched
if (readRevision === hostListLoads.getHostListLoadRevision()) {
tokenCache.set(stored.id, token)
}
}
const overlay = overlays.get(stored.id)
out.push({
...stored,
deviceToken: token,
...(overlay
? {
endpoints: overlay.endpoints,
relayHostId: overlay.relayHostId,
relay: overlay.relay
}
: {})
})
}
return out
}
export async function resolvePairingHostIdentity(
@@ -163,91 +86,146 @@ export async function resolvePairingHostIdentity(
): Promise<{ id: string; name: string }> {
// Why: one durable read both preserves an existing identity and names a new host, avoiding duplicate cards.
await hostListMutation
const hosts = await readStoredHostsForMutation()
const hosts = await readStoredHostProfilesForMutation()
const match = hosts.find((host) => host.publicKeyB64 === publicKeyB64)
return match
? { id: match.id, name: match.name }
: { id: newHostId, name: getNextHostNameFromHosts(hosts) }
}
async function readStoredHostsForMutation(): Promise<StoredHostProfile[]> {
try {
const parsed = parseStoredHosts(await AsyncStorage.getItem(STORAGE_KEY))
if (!parsed) {
// Why: refuse to RMW over unreadable payload — treating it as [] would wipe the durable host list on the next write.
throw new Error('host list storage unreadable')
}
return parsed
} catch (error) {
if (error instanceof Error && error.message === 'host list storage unreadable') {
throw error
}
throw new Error('host list storage unreadable')
const deleteUnpairedHostCredentials = createUnpairedHostCredentialDeletion({
waitForHostMutations: () => hostListMutation,
hasStoredHost: async (hostId) =>
(await readStoredHostProfilesForMutation()).some(({ id }) => id === hostId),
onDeleted: (hostId) => {
tokenCache.delete(hostId)
hostListLoads.dropSharedHostListLoad()
}
})
function scheduleUnpairedHostCredentialCleanup(hostId: string): Promise<void> {
const writeRevision = getHostCredentialWriteRevision(hostId)
return scheduleHostCredentialCleanup(hostId, (id) =>
deleteUnpairedHostCredentials(id, writeRevision)
)
}
async function mutateStoredHosts(
update: (hosts: StoredHostProfile[]) => StoredHostProfile[]
): Promise<void> {
const mutation = hostListMutation.then(async () => {
const current = await readStoredHostsForMutation()
const next = update(current)
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(next))
hostListLoads.dropSharedHostListLoad()
function cancelCleanupForStoredHost(hostId: string): void {
const cancellation = hostListMutation.then(async () => {
const hosts = await readStoredHostProfilesForMutation()
if (hosts.some(({ id }) => id === hostId)) {
// Register before later removals enqueue their intent, without blocking host loads on cleanup storage.
void cancelPendingHostCredentialCleanup(hostId).catch(() => undefined)
}
})
hostListMutation = cancellation.catch(() => {})
}
async function cancelCleanupForDurablyStoredHosts(hostIds: Iterable<string>): Promise<void> {
const targets = [...hostIds]
return enqueueHostListMutation(async () => {
const storedIds = new Set((await readStoredHostProfilesForMutation()).map(({ id }) => id))
await Promise.all(
targets
.filter((hostId) => storedIds.has(hostId))
.map((hostId) => cancelPendingHostCredentialCleanup(hostId).catch(() => undefined))
)
}).catch(() => undefined)
}
function enqueueHostListMutation(operation: () => Promise<void>): Promise<void> {
const mutation = hostListMutation.then(operation)
hostListMutation = mutation.catch(() => {})
return mutation
}
function toStored(host: HostProfile): StoredHostProfile {
return {
id: host.id,
name: host.name,
endpoint: host.endpoint,
publicKeyB64: host.publicKeyB64,
lastConnected: host.lastConnected
}
function removeOrphanOverlayIfUnpaired(hostId: string): Promise<void> {
return enqueueHostListMutation(async () => {
const hosts = await readStoredHostProfilesForMutation()
if (!hosts.some(({ id }) => id === hostId)) {
await removeMobileRelayHostOverlay(hostId)
}
})
}
async function mutateStoredHosts(
update: (hosts: StoredHostProfile[]) => StoredHostProfile[] | Promise<StoredHostProfile[]>
): Promise<void> {
return enqueueHostListMutation(async () => {
const current = await readStoredHostProfilesForMutation()
const next = await update(current)
await writeStoredHostProfiles(next)
hostListLoads.dropSharedHostListLoad()
})
}
export class MobileRelayUpgradeHostRemovedError extends Error {}
export async function saveHost(host: HostProfile): Promise<void> {
await persistHost(host, false)
}
export const saveHost = (host: HostProfile): Promise<void> => persistHost(host, false)
export async function saveExistingHostRelayUpgrade(host: HostProfile): Promise<void> {
await persistHost(host, true)
}
export const saveExistingHostRelayUpgrade = (host: HostProfile): Promise<void> =>
persistHost(host, true)
async function persistHost(host: HostProfile, requireExisting: boolean): Promise<void> {
const validated = HostProfileSchema.parse(host)
const stored = toStored(validated)
const stored = toStoredHostProfile(validated)
const duplicateHostIds = new Set<string>()
let updatedExistingHost = false
await mutateStoredHosts((hosts) => {
const index = hosts.findIndex((h) => h.id === stored.id)
for (const candidate of hosts) {
if (candidate.id !== stored.id && candidate.publicKeyB64 === stored.publicKeyB64) {
duplicateHostIds.add(candidate.id)
let cleanupIntentRecordedBeforeMetadata = false
let tokenCommittedBeforeMetadata = false
try {
await mutateStoredHosts(async (hosts) => {
const index = hosts.findIndex((h) => h.id === stored.id)
for (const candidate of hosts) {
if (candidate.id !== stored.id && candidate.publicKeyB64 === stored.publicKeyB64) {
duplicateHostIds.add(candidate.id)
}
}
let next: StoredHostProfile[]
if (index >= 0) {
updatedExistingHost = true
// Why: an authoritative save is the safe point to collapse pre-existing duplicate rows to the preserved host id.
next = hosts
.filter(({ id }) => !duplicateHostIds.has(id))
.map((candidate) => (candidate.id === stored.id ? stored : candidate))
} else if (requireExisting) {
// Why: an in-flight relay upgrade must not resurrect a host the user removed.
throw new MobileRelayUpgradeHostRemovedError('mobile relay upgrade host was removed')
} else {
next = [...hosts.filter(({ id }) => !duplicateHostIds.has(id)), stored]
}
if (duplicateHostIds.size > 0) {
if (index < 0) {
// Why: process death between the early token write and metadata publication must leave cleanup discoverable.
await recordHostCredentialCleanupIntent(stored.id)
cleanupIntentRecordedBeforeMetadata = true
}
for (const duplicateHostId of duplicateHostIds) {
await recordHostCredentialCleanupIntent(duplicateHostId)
}
// Why: never remove the only usable same-key row until its replacement credential is durable.
await commitDeviceToken(stored.id, validated.deviceToken)
tokenCommittedBeforeMetadata = true
}
return next
})
} catch (error) {
await cancelCleanupForDurablyStoredHosts(duplicateHostIds)
if (cleanupIntentRecordedBeforeMetadata) {
try {
await scheduleUnpairedHostCredentialCleanup(stored.id)
} catch {
// The write-ahead cleanup intent remains available for retry.
}
}
if (index >= 0) {
updatedExistingHost = true
// Why: an authoritative save is the safe point to collapse pre-existing duplicate rows to the preserved host id.
return hosts
.filter(({ id }) => !duplicateHostIds.has(id))
.map((candidate) => (candidate.id === stored.id ? stored : candidate))
}
if (requireExisting) {
// Why: an in-flight relay upgrade must not resurrect a host the user removed.
throw new MobileRelayUpgradeHostRemovedError('mobile relay upgrade host was removed')
}
return [...hosts.filter(({ id }) => !duplicateHostIds.has(id)), stored]
})
// Why: write metadata before the keychain token so a crash leaves recoverable orphaned metadata, not an orphaned token that persists forever.
await writeDeviceToken(stored.id, validated.deviceToken)
tokenCache.set(stored.id, validated.deviceToken)
hostListLoads.dropSharedHostListLoad()
throw error
}
if (!tokenCommittedBeforeMetadata) {
// Why: the catalog can now surface a failed token write for recovery instead of losing the host.
await commitDeviceToken(stored.id, validated.deviceToken)
}
// Why: a later removal owns its cleanup intent; cancel only while this publication remains authoritative.
cancelCleanupForStoredHost(stored.id)
if (validated.endpoints) {
await saveMobileRelayHostOverlay({
v: 2,
@@ -268,9 +246,8 @@ async function persistHost(host: HostProfile, requireExisting: boolean): Promise
hostListLoads.dropSharedHostListLoad()
}
for (const duplicateHostId of duplicateHostIds) {
tokenCache.delete(duplicateHostId)
try {
await scheduleHostCredentialCleanup(duplicateHostId, deleteHostCredentials)
await scheduleUnpairedHostCredentialCleanup(duplicateHostId)
} catch {
// Metadata is already deduplicated; orphan-token recovery is best-effort.
}
@@ -278,7 +255,23 @@ async function persistHost(host: HostProfile, requireExisting: boolean): Promise
}
export async function removeHost(hostId: string): Promise<void> {
await mutateStoredHosts((hosts) => hosts.filter((h) => h.id !== hostId))
let cleanupIntentRecorded = false
try {
await mutateStoredHosts(async (hosts) => {
try {
await recordHostCredentialCleanupIntent(hostId)
cleanupIntentRecorded = true
} catch {
// Removal remains authoritative when cleanup intent storage is unavailable.
}
return hosts.filter((h) => h.id !== hostId)
})
} catch (error) {
if (cleanupIntentRecorded) {
await cancelCleanupForDurablyStoredHosts([hostId])
}
throw error
}
tokenCache.delete(hostId)
try {
await removeMobileRelayHostOverlay(hostId)
@@ -288,7 +281,7 @@ export async function removeHost(hostId: string): Promise<void> {
}
// Why: keychain delete can stall/reject; await only the durable cleanup intent so removeHost can't freeze the UI.
try {
await scheduleHostCredentialCleanup(hostId, deleteHostCredentials)
await scheduleUnpairedHostCredentialCleanup(hostId)
} catch {
// Metadata is already committed; orphan-token recovery is best-effort.
}
@@ -299,7 +292,9 @@ export async function retryPendingHostCredentialCleanup(): Promise<{
remainingIds: string[]
storageUnreadable: boolean
}> {
return retryPendingHostCredentialCleanups(deleteHostCredentials)
return retryPendingHostCredentialCleanups((hostId) =>
deleteUnpairedHostCredentials(hostId, getHostCredentialWriteRevision(hostId))
)
}
// Why: single mutation pass commits name + endpoint atomically so a mid-save failure can't persist one without the other.
@@ -342,6 +337,7 @@ export async function updateLastConnected(hostId: string): Promise<void> {
export function resetHostStoreForTests(): void {
hostListMutation = Promise.resolve()
tokenCache.clear()
resetHostCredentialWriteRevisionsForTests()
hostListLoads.dropSharedHostListLoad()
resetPairingKeychainForTests()
}
@@ -7,6 +7,7 @@ import {
readPairingKeychainItem,
writePairingKeychainItem
} from './pairing-keychain'
import { markHostCredentialWrite } from './host-credential-write-revision'
const Base64Url32ByteSchema = z.string().regex(/^[A-Za-z0-9_-]{43}$/)
const ResumeCredentialSchema = z
@@ -91,6 +92,7 @@ export async function writeMobileRelayCredentialBundle(
): Promise<void> {
requireNativeSecretStore()
const validated = MobileRelayCredentialBundleSchema.parse(bundle)
markHostCredentialWrite(validated.hostId)
await writePairingKeychainItem(credentialKey(validated.hostId), JSON.stringify(validated))
}
@@ -6,6 +6,7 @@ import {
readPairingKeychainItem,
writePairingKeychainItem
} from './pairing-keychain'
import { markHostCredentialWrite } from './host-credential-write-revision'
const Base64Url32ByteSchema = z.string().regex(/^[A-Za-z0-9_-]{43}$/)
@@ -60,6 +61,7 @@ export async function writeMobileRelayDirectUpgradeJournal(
): Promise<void> {
requireNativeSecretStore()
const parsed = MobileRelayDirectUpgradeJournalSchema.parse(journal)
markHostCredentialWrite(parsed.hostId)
await writePairingKeychainItem(journalKey(parsed.hostId), JSON.stringify(parsed))
}
@@ -220,4 +220,53 @@ describe('mobile relay pairing recovery', () => {
expect(saved.metadata.authorizationMode).toBe('authenticated-direct')
expect(deps.updateJournal).toHaveBeenCalledTimes(2)
})
// Why: a journal stranded by a relay outage used to block every later pairing
// with "recovery pending" forever, because recovery only ever deferred.
it('abandons a journal once its invite expired and no credential can reconcile', async () => {
const saved = journal()
const unreachable = client(async () => {
throw new Error('relay unreachable')
})
const deps = {
...dependencies({ journal: saved, connectRelay: vi.fn(() => unreachable) }),
now: () => saved.metadata.relay.inviteExpiresAt + 10 * 60 * 1000 + 1
}
await expect(recoverMobileRelayPairing(deps)).resolves.toBe('abandoned')
expect(deps.clearJournal).toHaveBeenCalledWith(saved.metadata.journalId)
})
it('keeps a just-expired journal so a brief outage cannot discard it', async () => {
const saved = journal()
const unreachable = client(async () => {
throw new Error('relay unreachable')
})
const deps = {
...dependencies({ journal: saved, connectRelay: vi.fn(() => unreachable) }),
now: () => saved.metadata.relay.inviteExpiresAt + 1
}
await expect(recoverMobileRelayPairing(deps)).resolves.toBe('deferred')
expect(deps.clearJournal).not.toHaveBeenCalled()
})
// Why: a committed install whose local persistence failed is the one case the
// journal must survive — it is the only record left to retry the write from.
it('keeps a journal when the server committed but the local write failed', async () => {
const saved = journal()
const directInstalled = installed(saved, 'authenticated-direct')
const committed = client(async () =>
response(endpoints(saved, { state: 'committed', result: directInstalled }))
)
const deps = {
...dependencies({ journal: saved, connectRelay: vi.fn(() => committed) }),
now: () => saved.metadata.relay.inviteExpiresAt + 10 * 60 * 1000 + 1,
writeCredentialBundle: vi.fn(async () => {
throw new Error('keychain unavailable')
})
}
await expect(recoverMobileRelayPairing(deps)).resolves.toBe('deferred')
expect(deps.clearJournal).not.toHaveBeenCalled()
})
})
@@ -27,7 +27,7 @@ import {
import { createRecoveringPairingRelayCandidate } from './pairing-relay-candidate'
import type { HostProfile, RpcResponse } from './types'
export type MobileRelayPairingRecoveryResult = 'none' | 'recovered' | 'deferred'
export type MobileRelayPairingRecoveryResult = 'none' | 'recovered' | 'deferred' | 'abandoned'
type RecoveryDependencies = {
loadJournal: typeof loadMobileRelayPairingJournal
@@ -57,6 +57,10 @@ const defaultDependencies: RecoveryDependencies = {
platform: Platform.OS
}
// One full invite lifetime past expiry, so a momentary outage never discards a
// journal that a later launch could still reconcile.
const ABANDON_GRACE_MS = 10 * 60 * 1000
let recoveryPromise: Promise<MobileRelayPairingRecoveryResult> | null = null
export function recoverMobileRelayPairing(
@@ -96,6 +100,10 @@ async function runRecovery(
}
const credentials = recoveryCredentials(journal, bundle, dependencies.now())
// Why: publishCommitted runs inside the catch below, so a failed local write
// of an authoritatively committed install must not look like "nothing to
// reconcile" — that journal is the only record left to retry the write from.
let observedCommitted = false
for (const credential of credentials) {
let client: PairingCandidateClient | null = null
try {
@@ -113,6 +121,7 @@ async function runRecovery(
})
const endpoints = await getRecoveryStatus(client, journal, credential.kind)
if (endpoints.installStatus?.state === 'committed') {
observedCommitted = true
await publishCommitted(journal, endpoints, dependencies)
return 'recovered'
}
@@ -128,6 +137,7 @@ async function runRecovery(
)
const reconciled = await getRecoveryStatus(client, journal, 'invite')
assertCommitted(reconciled, installed)
observedCommitted = true
await publishCommitted(journal, reconciled, dependencies)
return 'recovered'
}
@@ -138,6 +148,19 @@ async function runRecovery(
client?.close()
}
}
// Why: past invite expiry no credential can still establish what happened, so
// retaining the journal cannot reconcile anything — it only fails every later
// pairing with "recovery pending" forever. Re-pairing mints a fresh device and
// any uncommitted server-side install expires on its own. The extra invite
// lifetime of slack keeps a brief relay outage from discarding a journal whose
// resume credential would have reconciled it on the next launch.
if (
!observedCommitted &&
journal.metadata.relay.inviteExpiresAt + ABANDON_GRACE_MS <= dependencies.now()
) {
await dependencies.clearJournal(journal.metadata.journalId).catch(() => {})
return 'abandoned'
}
return 'deferred'
}
@@ -0,0 +1,19 @@
import { useCallback, useState } from 'react'
import { useFocusEffect } from 'expo-router'
import { useAllHostClients } from './use-all-host-clients'
export function useFocusedSettingsHostClients(hostIds: string[]) {
const [focused, setFocused] = useState(false)
useFocusEffect(
useCallback(() => {
setFocused(true)
return () => setFocused(false)
}, [])
)
const clients = useAllHostClients(focused ? hostIds : [], {
closeUnusedOnRelease: true
})
return { clients, focused }
}
@@ -0,0 +1,776 @@
import { createElement, Fragment, useEffect } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useAllHostClients } from './use-all-host-clients'
import { useFocusedSettingsHostClients } from './settings-host-client-connections'
import {
RpcClientProvider,
useHostClient,
usePrimeHosts,
useRpcClientContext,
type RpcClientContextValue
} from './client-context'
import { selectHomeAutoConnectHostIds } from './home-host-auto-connect'
import type { RpcClient } from './rpc-client'
import type { ConnectionState, HostProfile } from './types'
const connectMock = vi.fn()
const loadHostsMock = vi.fn()
const routeFocus = vi.hoisted(() => ({
effect: null as null | (() => void | (() => void))
}))
vi.mock('expo-router', () => ({
useFocusEffect: (effect: () => void | (() => void)) => {
routeFocus.effect = effect
}
}))
vi.mock('./host-logical-client', () => ({
openHostLogicalClient: (...args: unknown[]) => connectMock(...args)
}))
vi.mock('./host-store', () => ({
loadHosts: () => loadHostsMock()
}))
vi.mock('./connection-revival-triggers', () => ({
subscribeConnectionRevivalTriggers: () => () => {}
}))
type FakeClient = RpcClient & {
closeMock: ReturnType<typeof vi.fn>
}
function makeFakeClient(initialState: ConnectionState): FakeClient {
const listeners = new Set<(state: ConnectionState) => void>()
const closeMock = vi.fn()
return {
sendRequest: vi.fn(),
subscribe: vi.fn(() => () => {}),
updateTerminalSubscriptionViewport: vi.fn(),
getState: () => initialState,
getReconnectAttempt: () => (initialState === 'reconnecting' ? 4 : 0),
getLastConnectedAt: () => null,
onStateChange: (listener) => {
listeners.add(listener)
return () => listeners.delete(listener)
},
notifyForeground: vi.fn(),
close: closeMock,
closeMock
} as FakeClient
}
function host(id: string, lastConnected: number, extra: Partial<HostProfile> = {}): HostProfile {
return {
id,
name: id,
endpoint: `ws://${id}.internal:8787`,
deviceToken: `token-${id}`,
publicKeyB64: `key-${id}`,
lastConnected,
...extra
}
}
const HOSTS = [
host('direct-recent', 50),
host('relay-recent', 40, { relayHostId: 'AbCdEf0123_-xyZ9' }),
host('ssh-provider-recent', 30),
host('folder-workspace-host', 20),
host('offline-host', 10)
]
const HOST_IDS = HOSTS.map((profile) => profile.id)
const HOME_HOST_IDS = selectHomeAutoConnectHostIds(HOSTS)
let context: RpcClientContextValue | null = null
function ContextProbe(): null {
context = useRpcClientContext()
return null
}
function HomeProbe(): null {
useAllHostClients(HOST_IDS, {
autoConnectHostIds: HOME_HOST_IDS,
closeUnusedOnRelease: true
})
const primeHosts = usePrimeHosts()
useEffect(() => {
primeHosts(HOSTS)
}, [primeHosts])
return null
}
function SettingsProbe(): null {
useAllHostClients(HOST_IDS, {
closeUnusedOnRelease: true
})
return null
}
function FocusedSettingsProbe(): null {
useFocusedSettingsHostClients(HOST_IDS)
return null
}
function DynamicSettingsProbe({ hostIds }: { hostIds: string[] }): null {
useAllHostClients(hostIds, { closeUnusedOnRelease: true })
return null
}
function DetailProbe({ hostId }: { hostId: string }): null {
useHostClient(hostId)
return null
}
type Screen = 'empty' | 'home' | 'settings'
function TestApp({
screen,
detailHostId
}: {
screen: Screen
detailHostId?: string
}): React.JSX.Element {
const screenProbe =
screen === 'home'
? createElement(HomeProbe)
: screen === 'settings'
? createElement(SettingsProbe)
: null
return createElement(
RpcClientProvider,
null,
createElement(
Fragment,
null,
createElement(ContextProbe),
detailHostId ? createElement(DetailProbe, { hostId: detailHostId }) : null,
screenProbe
)
)
}
function suppressRendererWarning(): () => void {
const originalConsoleError = console.error
const spy = vi.spyOn(console, 'error').mockImplementation((...args) => {
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
return
}
originalConsoleError(...args)
})
return () => spy.mockRestore()
}
async function renderScreen(screen: Screen, detailHostId?: string): Promise<ReactTestRenderer> {
let renderer: ReactTestRenderer | null = null
const restore = suppressRendererWarning()
try {
await act(async () => {
renderer = create(createElement(TestApp, { screen, detailHostId }))
await Promise.resolve()
await Promise.resolve()
})
} finally {
restore()
}
if (!renderer) {
throw new Error('settings lifecycle harness did not render')
}
return renderer
}
async function navigate(
renderer: ReactTestRenderer,
screen: Screen,
detailHostId?: string
): Promise<void> {
await act(async () => {
renderer.update(createElement(TestApp, { screen, detailHostId }))
await Promise.resolve()
await Promise.resolve()
})
}
function activeHostIds(): string[] {
if (!context) {
throw new Error('client context was not captured')
}
return context
.getAllClients()
.map(({ hostId }) => hostId)
.sort()
}
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
context = null
routeFocus.effect = null
connectMock.mockReset()
loadHostsMock.mockReset()
})
describe('settings host client lifecycle', () => {
it('closes settings-only clients while a mounted Home owner keeps its bounded set', async () => {
const clients = new Map<string, FakeClient[]>()
connectMock.mockImplementation((profile: HostProfile) => {
const client = makeFakeClient(profile.id === 'offline-host' ? 'reconnecting' : 'connected')
clients.set(profile.id, [...(clients.get(profile.id) ?? []), client])
return client
})
loadHostsMock.mockResolvedValue(HOSTS)
function NavigationStack({ settingsVisible }: { settingsVisible: boolean }) {
return createElement(
RpcClientProvider,
null,
createElement(
Fragment,
null,
createElement(ContextProbe),
createElement(HomeProbe),
settingsVisible ? createElement(SettingsProbe) : null
)
)
}
let renderer: ReactTestRenderer | null = null
const restore = suppressRendererWarning()
try {
await act(async () => {
renderer = create(createElement(NavigationStack, { settingsVisible: false }))
await Promise.resolve()
await Promise.resolve()
})
} finally {
restore()
}
if (!renderer) {
throw new Error('navigation stack harness did not render')
}
expect(activeHostIds()).toEqual([...HOME_HOST_IDS].sort())
expect(connectMock).toHaveBeenCalledTimes(3)
await act(async () => {
renderer?.update(createElement(NavigationStack, { settingsVisible: true }))
await Promise.resolve()
await Promise.resolve()
})
expect(activeHostIds()).toEqual([...HOST_IDS].sort())
expect(connectMock).toHaveBeenCalledTimes(HOSTS.length)
expect(loadHostsMock).toHaveBeenCalledTimes(HOME_HOST_IDS.length)
await act(async () => {
renderer?.update(createElement(NavigationStack, { settingsVisible: false }))
})
expect(activeHostIds()).toEqual([...HOME_HOST_IDS].sort())
for (const hostId of HOME_HOST_IDS) {
expect(clients.get(hostId)).toHaveLength(1)
expect(clients.get(hostId)?.[0]?.closeMock).not.toHaveBeenCalled()
}
for (const hostId of ['folder-workspace-host', 'offline-host']) {
expect(clients.get(hostId)?.[0]?.closeMock).toHaveBeenCalledOnce()
}
await act(async () => {
renderer?.update(createElement(NavigationStack, { settingsVisible: true }))
await Promise.resolve()
await Promise.resolve()
})
expect(loadHostsMock).toHaveBeenCalledTimes(HOME_HOST_IDS.length)
await act(async () => {
renderer?.update(createElement(NavigationStack, { settingsVisible: false }))
})
expect(activeHostIds()).toEqual([...HOME_HOST_IDS].sort())
for (const hostId of HOME_HOST_IDS) {
expect(clients.get(hostId)).toHaveLength(1)
}
for (const hostId of ['folder-workspace-host', 'offline-host']) {
expect(clients.get(hostId)).toHaveLength(2)
expect(clients.get(hostId)?.every((client) => client.closeMock.mock.calls.length === 1)).toBe(
true
)
}
act(() => renderer.unmount())
})
it('leaves no zero-reference clients after repeated settings navigation', async () => {
const clients = new Map<string, FakeClient[]>()
connectMock.mockImplementation((profile: HostProfile) => {
const client = makeFakeClient('reconnecting')
clients.set(profile.id, [...(clients.get(profile.id) ?? []), client])
return client
})
loadHostsMock.mockResolvedValue(HOSTS)
const renderer = await renderScreen('settings')
expect(activeHostIds()).toEqual([...HOST_IDS].sort())
await navigate(renderer, 'empty')
expect(activeHostIds()).toEqual([])
for (const hostClients of clients.values()) {
expect(hostClients).toHaveLength(1)
expect(hostClients[0]?.closeMock).toHaveBeenCalledOnce()
}
await navigate(renderer, 'settings')
await navigate(renderer, 'empty')
expect(activeHostIds()).toEqual([])
expect(connectMock).toHaveBeenCalledTimes(HOSTS.length * 2)
for (const hostClients of clients.values()) {
expect(hostClients).toHaveLength(2)
expect(hostClients.every((client) => client.closeMock.mock.calls.length === 1)).toBe(true)
}
act(() => renderer.unmount())
})
it('reconciles host-list changes without restarting retained or shared clients', async () => {
const clients = new Map<string, FakeClient>()
connectMock.mockImplementation((profile: HostProfile) => {
const client = makeFakeClient('reconnecting')
clients.set(profile.id, client)
return client
})
loadHostsMock.mockResolvedValue(HOSTS)
const retainedHostId = 'direct-recent'
const sharedHostId = 'relay-recent'
const removedHostId = 'folder-workspace-host'
const addedHostId = 'offline-host'
function HostListApp({ settingsHostIds }: { settingsHostIds: string[] }) {
return createElement(
RpcClientProvider,
null,
createElement(
Fragment,
null,
createElement(ContextProbe),
createElement(DetailProbe, { hostId: sharedHostId }),
createElement(DynamicSettingsProbe, { hostIds: settingsHostIds })
)
)
}
let renderer: ReactTestRenderer | null = null
const restore = suppressRendererWarning()
try {
await act(async () => {
renderer = create(
createElement(HostListApp, {
settingsHostIds: [retainedHostId, sharedHostId, removedHostId]
})
)
await Promise.resolve()
await Promise.resolve()
})
} finally {
restore()
}
if (!renderer) {
throw new Error('host-list lifecycle harness did not render')
}
await act(async () => {
renderer?.update(
createElement(HostListApp, {
settingsHostIds: [retainedHostId, addedHostId]
})
)
await Promise.resolve()
await Promise.resolve()
})
expect(connectMock).toHaveBeenCalledTimes(4)
expect(activeHostIds()).toEqual([addedHostId, retainedHostId, sharedHostId].sort())
expect(clients.get(retainedHostId)?.closeMock).not.toHaveBeenCalled()
expect(clients.get(sharedHostId)?.closeMock).not.toHaveBeenCalled()
expect(clients.get(removedHostId)?.closeMock).toHaveBeenCalledOnce()
expect(clients.get(addedHostId)?.closeMock).not.toHaveBeenCalled()
act(() => renderer.unmount())
expect(clients.get(retainedHostId)?.closeMock).toHaveBeenCalledOnce()
expect(clients.get(sharedHostId)?.closeMock).toHaveBeenCalledOnce()
expect(clients.get(addedHostId)?.closeMock).toHaveBeenCalledOnce()
})
it('does not close a settings client still held by an active consumer', async () => {
const clients = new Map<string, FakeClient>()
connectMock.mockImplementation((profile: HostProfile) => {
const client = makeFakeClient('connected')
clients.set(profile.id, client)
return client
})
loadHostsMock.mockResolvedValue(HOSTS)
const detailHostId = 'folder-workspace-host'
const renderer = await renderScreen('settings', detailHostId)
await navigate(renderer, 'home', detailHostId)
expect(activeHostIds()).toEqual([...HOME_HOST_IDS, detailHostId].sort())
expect(clients.get(detailHostId)?.closeMock).not.toHaveBeenCalled()
act(() => renderer.unmount())
expect(clients.get(detailHostId)?.closeMock).toHaveBeenCalledOnce()
})
it('keeps a reconnect alive when another consumer remains after settings leaves', async () => {
const retryHost = host('ssh-retry-host', 1)
let resolveInitial: ((hosts: HostProfile[]) => void) | null = null
let resolveRetry: ((hosts: HostProfile[]) => void) | null = null
const initialLookup = new Promise<HostProfile[]>((resolve) => {
resolveInitial = resolve
})
const retryLookup = new Promise<HostProfile[]>((resolve) => {
resolveRetry = resolve
})
loadHostsMock.mockReturnValueOnce(initialLookup).mockReturnValueOnce(retryLookup)
const initialClient = makeFakeClient('connected')
const retryClient = makeFakeClient('reconnecting')
connectMock.mockReturnValueOnce(initialClient).mockReturnValueOnce(retryClient)
function RetrySettingsProbe(): null {
useAllHostClients([retryHost.id], {
closeUnusedOnRelease: true
})
return null
}
function RetryApp({ settingsVisible }: { settingsVisible: boolean }): React.JSX.Element {
return createElement(
RpcClientProvider,
null,
createElement(
Fragment,
null,
createElement(ContextProbe),
createElement(DetailProbe, { hostId: retryHost.id }),
settingsVisible ? createElement(RetrySettingsProbe) : null
)
)
}
let renderer: ReactTestRenderer | null = null
const restore = suppressRendererWarning()
try {
act(() => {
renderer = create(createElement(RetryApp, { settingsVisible: true }))
})
} finally {
restore()
}
if (!renderer || !resolveInitial || !resolveRetry) {
throw new Error('retry lifecycle harness did not initialize')
}
await act(async () => {
resolveInitial?.([retryHost])
await initialLookup
})
if (!context) {
throw new Error('client context was not captured')
}
const reconnect = context.forceReconnect(retryHost.id)
expect(initialClient.closeMock).toHaveBeenCalledOnce()
act(() => renderer?.update(createElement(RetryApp, { settingsVisible: false })))
await act(async () => {
resolveRetry?.([retryHost])
await retryLookup
await reconnect
})
expect(activeHostIds()).toEqual([retryHost.id])
expect(retryClient.closeMock).not.toHaveBeenCalled()
act(() => renderer?.unmount())
expect(retryClient.closeMock).toHaveBeenCalledOnce()
})
it('cancels a released open without cancelling a rapid replacement acquisition', async () => {
const settingsOnlyHost = host('settings-only-offline', 0, {
deviceToken: '',
publicKeyB64: ''
})
let resolveFirst: ((hosts: HostProfile[]) => void) | null = null
let resolveSecond: ((hosts: HostProfile[]) => void) | null = null
const firstLookup = new Promise<HostProfile[]>((resolve) => {
resolveFirst = resolve
})
const secondLookup = new Promise<HostProfile[]>((resolve) => {
resolveSecond = resolve
})
loadHostsMock.mockReturnValueOnce(firstLookup).mockReturnValueOnce(secondLookup)
const client = makeFakeClient('reconnecting')
connectMock.mockReturnValue(client)
function PendingSettingsProbe(): null {
useAllHostClients([settingsOnlyHost.id], {
closeUnusedOnRelease: true
})
return null
}
function PendingApp({ visible }: { visible: boolean }): React.JSX.Element {
return createElement(
RpcClientProvider,
null,
visible ? createElement(PendingSettingsProbe) : null
)
}
let renderer: ReactTestRenderer | null = null
const restore = suppressRendererWarning()
try {
act(() => {
renderer = create(createElement(PendingApp, { visible: true }))
})
} finally {
restore()
}
if (!renderer || !resolveFirst || !resolveSecond) {
throw new Error('pending settings harness did not initialize')
}
act(() => renderer?.update(createElement(PendingApp, { visible: false })))
act(() => renderer?.update(createElement(PendingApp, { visible: true })))
await act(async () => {
resolveFirst?.([settingsOnlyHost])
await firstLookup
})
expect(connectMock).not.toHaveBeenCalled()
await act(async () => {
resolveSecond?.([settingsOnlyHost])
await secondLookup
})
expect(connectMock).toHaveBeenCalledOnce()
act(() => renderer?.update(createElement(PendingApp, { visible: false })))
expect(client.closeMock).toHaveBeenCalledOnce()
act(() => renderer?.unmount())
})
it('preserves Home ownership when re-pairing replaces a selected host client', async () => {
const clients = new Map<string, FakeClient[]>()
connectMock.mockImplementation((profile: HostProfile) => {
const client = makeFakeClient('connected')
clients.set(profile.id, [...(clients.get(profile.id) ?? []), client])
return client
})
loadHostsMock.mockResolvedValue(HOSTS)
const replacedHostId = HOME_HOST_IDS[0]!
function ReplacementApp({
detailVisible,
settingsVisible
}: {
detailVisible: boolean
settingsVisible: boolean
}): React.JSX.Element {
return createElement(
RpcClientProvider,
null,
createElement(
Fragment,
null,
createElement(ContextProbe),
createElement(HomeProbe),
detailVisible ? createElement(DetailProbe, { hostId: replacedHostId }) : null,
settingsVisible ? createElement(SettingsProbe) : null
)
)
}
let renderer: ReactTestRenderer | null = null
const restore = suppressRendererWarning()
try {
await act(async () => {
renderer = create(
createElement(ReplacementApp, { detailVisible: false, settingsVisible: false })
)
await Promise.resolve()
})
} finally {
restore()
}
if (!renderer || !context) {
throw new Error('replacement lifecycle harness did not initialize')
}
const originalClient = clients.get(replacedHostId)?.[0]
act(() => context?.closeHost(replacedHostId))
expect(originalClient?.closeMock).toHaveBeenCalledOnce()
await act(async () => {
renderer?.update(
createElement(ReplacementApp, { detailVisible: true, settingsVisible: false })
)
await Promise.resolve()
await Promise.resolve()
})
const replacementClient = clients.get(replacedHostId)?.[1]
expect(replacementClient).toBeDefined()
await act(async () => {
renderer?.update(
createElement(ReplacementApp, { detailVisible: false, settingsVisible: true })
)
await Promise.resolve()
await Promise.resolve()
})
act(() =>
renderer?.update(
createElement(ReplacementApp, { detailVisible: false, settingsVisible: false })
)
)
expect(activeHostIds()).toEqual([...HOME_HOST_IDS].sort())
expect(replacementClient?.closeMock).not.toHaveBeenCalled()
act(() => renderer?.unmount())
expect(replacementClient?.closeMock).toHaveBeenCalledOnce()
})
it('releases a manual host after Home demotes or stops tracking it', async () => {
const manualHost = host('manual-relay-host', 1, { relayHostId: 'AbCdEf0123_-xyZ9' })
const clients: FakeClient[] = []
connectMock.mockImplementation(() => {
const client = makeFakeClient('reconnecting')
clients.push(client)
return client
})
loadHostsMock.mockResolvedValue([manualHost])
function ManualHomeProbe({ selected }: { selected: boolean }): null {
useAllHostClients([manualHost.id], {
autoConnectHostIds: selected ? [manualHost.id] : [],
closeUnusedOnRelease: true
})
return null
}
function ManualApp({
selected,
homeVisible = true
}: {
selected: boolean
homeVisible?: boolean
}) {
return createElement(
RpcClientProvider,
null,
createElement(
Fragment,
null,
createElement(ContextProbe),
homeVisible ? createElement(ManualHomeProbe, { selected }) : null
)
)
}
let renderer: ReactTestRenderer | null = null
const restore = suppressRendererWarning()
try {
act(() => {
renderer = create(createElement(ManualApp, { selected: false }))
})
} finally {
restore()
}
if (!renderer || !context) {
throw new Error('manual connection harness did not initialize')
}
await act(async () => {
await context?.forceReconnect(manualHost.id)
})
expect(activeHostIds()).toEqual([manualHost.id])
await act(async () => {
renderer?.update(createElement(ManualApp, { selected: true }))
await Promise.resolve()
})
expect(activeHostIds()).toEqual([manualHost.id])
await act(async () => {
renderer?.update(createElement(ManualApp, { selected: false }))
await Promise.resolve()
})
expect(activeHostIds()).toEqual([])
expect(clients[0]?.closeMock).toHaveBeenCalledOnce()
await act(async () => {
await context?.forceReconnect(manualHost.id)
})
expect(activeHostIds()).toEqual([manualHost.id])
expect(clients).toHaveLength(2)
act(() => renderer?.update(createElement(ManualApp, { selected: false, homeVisible: false })))
expect(activeHostIds()).toEqual([])
expect(clients[1]?.closeMock).toHaveBeenCalledOnce()
act(() => renderer?.unmount())
expect(clients.every((client) => client.closeMock.mock.calls.length === 1)).toBe(true)
})
it('releases settings-only clients on blur while shared owners stay mounted', async () => {
const clients = new Map<string, FakeClient>()
connectMock.mockImplementation((profile: HostProfile) => {
const client = makeFakeClient('reconnecting')
clients.set(profile.id, client)
return client
})
loadHostsMock.mockResolvedValue(HOSTS)
const detailHostId = 'folder-workspace-host'
function FocusStack(): React.JSX.Element {
return createElement(
RpcClientProvider,
null,
createElement(
Fragment,
null,
createElement(ContextProbe),
createElement(HomeProbe),
createElement(DetailProbe, { hostId: detailHostId }),
createElement(FocusedSettingsProbe)
)
)
}
let renderer: ReactTestRenderer | null = null
const restore = suppressRendererWarning()
try {
await act(async () => {
renderer = create(createElement(FocusStack))
await Promise.resolve()
await Promise.resolve()
})
} finally {
restore()
}
if (!renderer || !routeFocus.effect) {
throw new Error('settings focus harness did not initialize')
}
expect(activeHostIds()).toEqual([...HOME_HOST_IDS, detailHostId].sort())
let blur: (() => void) | undefined
await act(async () => {
const cleanup = routeFocus.effect?.()
if (typeof cleanup === 'function') {
blur = cleanup
}
await Promise.resolve()
await Promise.resolve()
})
expect(activeHostIds()).toEqual([...HOST_IDS].sort())
await act(async () => {
blur?.()
await Promise.resolve()
})
expect(activeHostIds()).toEqual([...HOME_HOST_IDS, detailHostId].sort())
for (const hostId of [...HOME_HOST_IDS, detailHostId]) {
expect(clients.get(hostId)?.closeMock).not.toHaveBeenCalled()
}
expect(clients.get('offline-host')?.closeMock).toHaveBeenCalledOnce()
act(() => renderer?.unmount())
})
})
+7
View File
@@ -75,6 +75,13 @@ export type HostProfile = {
relay?: MobileRelayHostOverlay['relay']
}
export type HostCredentialStatus = 'ready' | 'temporarily-unavailable' | 'missing'
export type HostCatalogEntry = Omit<HostProfile, 'deviceToken'> & {
credentialStatus: HostCredentialStatus
profile: HostProfile | null
}
export const HostProfileSchema = z.object({
id: z.string().min(1),
name: z.string().min(1),
@@ -0,0 +1,58 @@
import { deleteHostDeviceToken } from './host-device-token-store'
import {
clearHostCredentialWriteRevision,
getHostCredentialWriteRevision
} from './host-credential-write-revision'
import { deleteMobileRelayCredentialBundle } from './mobile-relay-credential-bundle'
import { deleteMobileRelayDirectUpgradeJournal } from './mobile-relay-direct-upgrade-journal'
type DeletionDependencies = {
waitForHostMutations: () => Promise<void>
hasStoredHost: (hostId: string) => Promise<boolean>
onDeleted: (hostId: string) => void
}
export function createUnpairedHostCredentialDeletion(dependencies: DeletionDependencies) {
function writeRevisionChanged(hostId: string, writeRevision: number): boolean {
return getHostCredentialWriteRevision(hostId) !== writeRevision
}
function assertWriteRevisionUnchanged(hostId: string, writeRevision: number): void {
if (writeRevisionChanged(hostId, writeRevision)) {
throw new Error('credential write superseded cleanup')
}
}
async function shouldSkip(hostId: string, writeRevision: number): Promise<boolean> {
await dependencies.waitForHostMutations()
if (await dependencies.hasStoredHost(hostId)) {
return true
}
assertWriteRevisionUnchanged(hostId, writeRevision)
return false
}
return async (hostId: string, writeRevision: number): Promise<void> => {
if (await shouldSkip(hostId, writeRevision)) {
return
}
assertWriteRevisionUnchanged(hostId, writeRevision)
await deleteHostDeviceToken(hostId)
if (await shouldSkip(hostId, writeRevision)) {
return
}
assertWriteRevisionUnchanged(hostId, writeRevision)
await deleteMobileRelayCredentialBundle(hostId)
if (await shouldSkip(hostId, writeRevision)) {
return
}
assertWriteRevisionUnchanged(hostId, writeRevision)
await deleteMobileRelayDirectUpgradeJournal(hostId)
if (await shouldSkip(hostId, writeRevision)) {
return
}
assertWriteRevisionUnchanged(hostId, writeRevision)
clearHostCredentialWriteRevision(hostId)
dependencies.onDeleted(hostId)
}
}
+106 -36
View File
@@ -1,56 +1,126 @@
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import type { RpcClient } from './rpc-client'
import type { MobileConnectionPath } from './stable-logical-rpc-client'
import type { ConnectionState } from './types'
import { useRpcClientContext } from './client-context'
// Why: refcounting prevents a double-open when a host-detail screen shares one of these hosts.
export function useAllHostClients(hostIds: string[]) {
type UseAllHostClientsOptions = {
autoConnectHostIds?: readonly string[]
closeUnusedOnRelease?: boolean
}
export function useAllHostClients(hostIds: string[], options?: UseAllHostClientsOptions) {
const ctx = useRpcClientContext()
// Stable key so we don't tear down on every render of the array.
const key = useMemo(() => [...hostIds].sort().join(','), [hostIds])
const autoConnectHostIds = options?.autoConnectHostIds ?? hostIds
const closeUnusedOnRelease = options?.closeUnusedOnRelease ?? false
const key = useMemo(
() =>
[
[...hostIds].sort().join(','),
[...autoConnectHostIds].sort().join(','),
closeUnusedOnRelease ? 'close' : 'keep'
].join('|'),
[autoConnectHostIds, closeUnusedOnRelease, hostIds]
)
const [tick, setTick] = useState(0)
const acquiredHostIdsRef = useRef<Set<string>>(new Set())
const hostUnsubscribesRef = useRef<Map<string, () => void>>(new Map())
const closeUnusedRef = useRef(closeUnusedOnRelease)
useEffect(() => {
if (hostIds.length === 0) {
return
}
for (const id of hostIds) {
ctx.acquire(id)
}
const unsubs: (() => void)[] = []
for (const id of hostIds) {
unsubs.push(ctx.subscribeHostState(id, () => setTick((n) => n + 1)))
}
unsubs.push(ctx.subscribeAllHosts(() => setTick((n) => n + 1)))
closeUnusedRef.current = closeUnusedOnRelease
}, [closeUnusedOnRelease])
useEffect(() => {
const unsubscribeAllHosts = ctx.subscribeAllHosts(() => setTick((value) => value + 1))
return () => {
for (const u of unsubs) {
u()
unsubscribeAllHosts()
const trackedHostIds = [...hostUnsubscribesRef.current.keys()]
const acquiredHostIds = new Set(acquiredHostIdsRef.current)
for (const unsubscribe of hostUnsubscribesRef.current.values()) {
unsubscribe()
}
for (const id of hostIds) {
ctx.release(id)
hostUnsubscribesRef.current.clear()
for (const id of acquiredHostIds) {
if (closeUnusedRef.current) {
ctx.releaseAndCloseIfUnused(id)
} else {
ctx.release(id)
}
}
if (closeUnusedRef.current) {
for (const id of trackedHostIds) {
if (!acquiredHostIds.has(id)) {
ctx.closeIfUnused(id)
}
}
}
acquiredHostIdsRef.current.clear()
}
}, [ctx])
useEffect(() => {
const trackedHostIds = new Set(hostIds)
const nextAcquiredHostIds = new Set(autoConnectHostIds.filter((id) => trackedHostIds.has(id)))
const removedTrackedHostIds: string[] = []
for (const [id, unsubscribe] of hostUnsubscribesRef.current) {
if (!trackedHostIds.has(id)) {
unsubscribe()
hostUnsubscribesRef.current.delete(id)
removedTrackedHostIds.push(id)
}
}
}, [key])
for (const id of trackedHostIds) {
if (!hostUnsubscribesRef.current.has(id)) {
hostUnsubscribesRef.current.set(
id,
ctx.subscribeHostState(id, () => setTick((value) => value + 1))
)
}
}
for (const id of acquiredHostIdsRef.current) {
if (!nextAcquiredHostIds.has(id)) {
if (closeUnusedOnRelease) {
ctx.releaseAndCloseIfUnused(id)
} else {
ctx.release(id)
}
}
}
for (const id of nextAcquiredHostIds) {
if (!acquiredHostIdsRef.current.has(id)) {
ctx.acquire(id)
}
}
if (closeUnusedOnRelease) {
for (const id of removedTrackedHostIds) {
ctx.closeIfUnused(id)
}
for (const id of trackedHostIds) {
if (!nextAcquiredHostIds.has(id)) {
ctx.closeIfUnused(id)
}
}
}
acquiredHostIdsRef.current = nextAcquiredHostIds
}, [ctx, key])
return useMemo(() => {
const out: {
const clientsByHostId = new Map(
ctx.getAllClients().map((entry) => [entry.hostId, entry.client])
)
return hostIds.flatMap<{
hostId: string
client: RpcClient
state: ConnectionState
path: MobileConnectionPath
}[] = []
for (const id of hostIds) {
const all = ctx.getAllClients().find((entry) => entry.hostId === id)
if (all) {
out.push({
hostId: id,
client: all.client,
state: ctx.getState(id),
path: ctx.getActivePath(id)
})
}
}
return out
}, [key, tick])
}>((hostId) => {
const client = clientsByHostId.get(hostId)
return client
? [{ hostId, client, state: ctx.getState(hostId), path: ctx.getActivePath(hostId) }]
: []
})
}, [ctx, hostIds, tick])
}
@@ -740,25 +740,30 @@ final class Provider {
}
if let point = center(record.localFrame, in: snapshot.windowBounds) {
try Input.click(
pid: snapshot.app.pid,
at: point,
button: mouseButton(button),
count: count,
modifiers: modifiers
)
return actionMetadata(path: "synthetic", fallbackReason: "actionUnsupported")
return actionMetadata(
path: "synthetic",
fallbackReason: "actionUnsupported",
verification: unverifiedAction(reason: "synthetic_input")
)
}
throw ProviderError.coded("element_not_clickable", "element \(record.index) has no clickable frame")
}
let point = try coordinatePoint(params: params, xKey: "x", yKey: "y", snapshot: snapshot)
try Input.click(
pid: snapshot.app.pid,
at: point,
button: mouseButton(button),
count: count,
modifiers: modifiers
)
return actionMetadata(path: "synthetic")
return actionMetadata(
path: "synthetic",
verification: unverifiedAction(reason: "synthetic_input")
)
}
private func performClickAction(record: ElementRecord, mouseButton: String) throws -> String? {
@@ -2286,7 +2291,6 @@ private func resizePng(_ image: CGImage, scale: CGFloat) -> BoundedPNG? {
private enum Input {
static func click(
pid: pid_t,
at point: CGPoint,
button: MouseButton,
count: Int,
@@ -2298,10 +2302,32 @@ private enum Input {
let flags = modifiers.reduce(into: CGEventFlags()) { result, modifier in
result.insert(modifier.flag)
}
for _ in 0..<max(count, 1) {
try mouse(.mouseMoved, source: source, point: point, button: button.cgButton, flags: flags, pid: pid)
try mouse(button.downEvent, source: source, point: point, button: button.cgButton, flags: flags, pid: pid)
try mouse(button.upEvent, source: source, point: point, button: button.cgButton, flags: flags, pid: pid)
// Why HID tap + pacing: see SyntheticMouseClickDelivery (STA-3433).
for step in SyntheticMouseClickDelivery.steps(clickCount: count) {
let type: CGEventType
switch step {
case .move:
type = .mouseMoved
case .buttonDown:
type = button.downEvent
case .buttonUp:
type = button.upEvent
}
guard let event = CGEvent(
mouseEventSource: source,
mouseType: type,
mouseCursorPosition: point,
mouseButton: button.cgButton
) else {
throw ProviderError.coded("accessibility_error", "failed to create mouse event")
}
event.flags = flags
let clickState = SyntheticMouseClickDelivery.clickState(for: step)
if clickState > 0 {
event.setIntegerValueField(.mouseEventClickState, value: clickState)
}
event.post(tap: .cghidEventTap)
usleep(SyntheticMouseClickDelivery.interEventPauseMicroseconds)
}
}
@@ -0,0 +1,40 @@
/// Event plan for synthetic mouse clicks (STA-3433).
///
/// Clicks must be posted to the HID event tap, not `CGEventPostToPid`:
/// pid-targeted mouse events reach the app with no window association, so
/// AppKit never routes the press to a view (hover fires, activation never
/// happens). The window server also drops a mouseUp posted back-to-back
/// with its mouseDown, so consecutive events need a pause between them.
public enum SyntheticMouseClickDelivery {
public enum Step: Equatable {
case move
case buttonDown(pressIndex: Int)
case buttonUp(pressIndex: Int)
}
/// Pause after posting each event; unpaced posts race the window
/// server's routing and the mouseUp is silently dropped.
public static let interEventPauseMicroseconds: UInt32 = 50_000
/// One move, then a paired down/up per press. `pressIndex` becomes the
/// event's click state so repeated presses register as double/triple
/// clicks instead of independent single clicks.
public static func steps(clickCount: Int) -> [Step] {
var steps: [Step] = [.move]
for press in 1...max(clickCount, 1) {
steps.append(.buttonDown(pressIndex: press))
steps.append(.buttonUp(pressIndex: press))
}
return steps
}
/// Click state field value for a step; 0 leaves the field unset.
public static func clickState(for step: Step) -> Int64 {
switch step {
case .move:
return 0
case let .buttonDown(pressIndex), let .buttonUp(pressIndex):
return Int64(pressIndex)
}
}
}

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