mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 00:03:46 +00:00
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:
@@ -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
@@ -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')
|
||||
})
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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 = {
|
||||
'': {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}, [])
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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())
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+40
@@ -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
Reference in New Issue
Block a user