mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 16:03:26 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/agent-dashboard-ui-revamp
This commit is contained in:
@@ -182,20 +182,15 @@ jobs:
|
||||
MAIN_REPO_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Why read the highest number off existing titles rather than counting
|
||||
# releases: the prune step trims to HOURLY_RETAIN_COUNT, so a count would
|
||||
# roll backwards after three days and reissue numbers already in use. The
|
||||
# maximum is always among the retained builds, because pruning drops the
|
||||
# oldest. `capture(...)?` swallows the non-match on legacy titles, which
|
||||
# were the raw tag, so the series simply starts at 01.
|
||||
# Existing titles, which carry the build number this series continues
|
||||
# from. The script picks the number, because it restarts per base version
|
||||
# and only the script knows which base this build resolved to.
|
||||
#
|
||||
# Why drafts count here but not in the freshness check: that check asks
|
||||
# "did this commit ship", where a draft is a no. This one asks "is the
|
||||
# number free", where a stranded draft still holds one.
|
||||
last_number="$(gh release list --repo "$HOURLY_REPO" --limit 200 --json name,isDraft \
|
||||
--jq '[.[] | (.name // "") | capture(" • (?<n>[0-9]+) • ")? | .n | tonumber] | max // 0')"
|
||||
build_number=$(( last_number + 1 ))
|
||||
echo "Hourly build number $build_number (previous high: $last_number)"
|
||||
names="$(gh release list --repo "$HOURLY_REPO" --limit 200 --json name \
|
||||
--jq '.[].name // empty')"
|
||||
# Why the main repo's tags decide the base version rather than
|
||||
# package.json: main's version only moves on `release:` commits, and
|
||||
# stable patches are cut from release branches that never merge back, so
|
||||
@@ -207,9 +202,10 @@ jobs:
|
||||
--repo "$GITHUB_REPOSITORY" --limit 100 --exclude-drafts \
|
||||
--json tagName --jq '.[].tagName' || true)"
|
||||
echo "Highest published tag seen: $(head -1 <<<"$published")"
|
||||
ORCA_PUBLISHED_VERSIONS="$published" \
|
||||
ORCA_HOURLY_BUILD_NUMBER="$build_number" node config/scripts/hourly-build-version.mjs \
|
||||
ORCA_PUBLISHED_VERSIONS="$published" ORCA_HOURLY_RELEASE_NAMES="$names" \
|
||||
node config/scripts/hourly-build-version.mjs \
|
||||
>"$RUNNER_TEMP/hourly-identity.txt"
|
||||
grep -E '^(version|build_number)=' "$RUNNER_TEMP/hourly-identity.txt"
|
||||
# Why check rather than trust: the checkout above pins `ref: main`, but a
|
||||
# workflow_dispatch runs this file from whatever branch was dispatched. A
|
||||
# branch that edits this step while main still has the old script yields
|
||||
|
||||
@@ -2338,6 +2338,120 @@
|
||||
],
|
||||
"demotionRule": "Demote or quarantine if the gate flakes without a product or harness bug, if a Pi handler can again await receiver I/O, or if coalescing allows an older status to overwrite a newer one."
|
||||
},
|
||||
{
|
||||
"id": "agent-status.manual-compact-identity",
|
||||
"title": "Manual Claude compact completion retires only its exact status generation",
|
||||
"maturity": "experimental",
|
||||
"protection": "partial",
|
||||
"owner": "agent-session",
|
||||
"layer": "shared-main-relay-contract",
|
||||
"surfaces": [
|
||||
"Claude status hooks",
|
||||
"manual compact lifecycle",
|
||||
"last-status persistence",
|
||||
"SSH relay ingest"
|
||||
],
|
||||
"platforms": ["macos", "linux", "windows"],
|
||||
"providers": ["local", "daemon", "ssh", "wsl", "remote-runtime"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["local", "ssh"],
|
||||
"coverageNotes": "Deterministic listener, loopback HTTP, relay-restart, persisted-restore, and fake-SSH transport contracts run on macOS. Claude Code 2.1.220 captures prove manual PreCompact starts a new prompt UUID and exact PreCompact/PostCompact pairs share it. The shared Node paths are platform-independent; live Linux, Windows, WSL, and paired-runtime journeys remain gaps.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/issues/11352",
|
||||
"https://github.com/stablyai/orca/pull/11353"
|
||||
],
|
||||
"invariant": "A Claude manual PreCompact may replace only a current Claude row from the same receiving connection and provider session; a manual PostCompact may mark done only when the current authoritative row is its exact manual PreCompact generation, including provider prompt UUID. Missing identity is never a wildcard; later cross-provider work, replay, duplication, pane or tab retirement, and server stop invalidate completion. Automatic Claude and Kimi completion remain fail-closed until their hooks expose independently proven generation identity.",
|
||||
"oracle": "Run one byte-identical state-machine oracle plus local HTTP, relay-restart, SSH, and persisted-restore streams using Claude's observed different prior-turn and compact prompt UUIDs. Require matching manual completion to emit done while wrong or absent prompt UUID, session-presence mismatch, wrong source or connection, stale replay and duplicate delivery, later Codex work, automatic completion, and lifecycle cleanup cannot retire the current row. Assert both emitted event order and the authoritative lastStatusByPaneKey snapshot.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/shared/manual-compact-prompt-identity.test.ts src/main/agent-hooks/manual-compact-hook-stream.test.ts src/main/agent-hooks/manual-compact-status-cleanup.test.ts src/shared/agent-hook-listener.test.ts src/shared/agent-hook-relay.test.ts src/relay/agent-hook-server.test.ts src/main/agent-hooks/server.test.ts src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts --reporter=dot",
|
||||
"Manual Claude Code 2.1.220 interactive compact: isolate UserPromptSubmit, PreCompact, and PostCompact hooks; submit one ordinary prompt then /compact; compare source, trigger, session_id, and prompt_id"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/shared/manual-compact-prompt-identity.test.ts",
|
||||
"src/main/agent-hooks/manual-compact-hook-stream.test.ts",
|
||||
"src/main/agent-hooks/manual-compact-status-cleanup.test.ts",
|
||||
"src/shared/agent-hook-listener.test.ts",
|
||||
"src/shared/agent-hook-relay.test.ts",
|
||||
"src/relay/agent-hook-server.test.ts",
|
||||
"src/main/agent-hooks/server.test.ts",
|
||||
"src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/shared/manual-compact-prompt-identity.test.ts",
|
||||
"assertions": [
|
||||
"matching manual identity settles once",
|
||||
"stale, duplicate, session-mismatched, malformed, automatic, and unproven provider completion fail closed",
|
||||
"4,097 rejected pane keys allocate no ownership state"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/agent-hooks/manual-compact-hook-stream.test.ts",
|
||||
"assertions": [
|
||||
"local HTTP and relay-restart streams preserve the distinct prior-turn and compact prompt identities",
|
||||
"later provider work and wrong transport identity remain authoritative",
|
||||
"persisted PreCompact restores its prompt"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/agent-hooks/manual-compact-status-cleanup.test.ts",
|
||||
"assertions": ["pane, tab, and server cleanup revoke compact completion authority"]
|
||||
},
|
||||
{
|
||||
"file": "src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts",
|
||||
"assertions": [
|
||||
"SSH stamps the receiving connection and forwards source, prompt UUID, session, and manual trigger"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-08-02",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/shared/manual-compact-prompt-identity.test.ts src/main/agent-hooks/manual-compact-hook-stream.test.ts src/main/agent-hooks/manual-compact-status-cleanup.test.ts src/shared/agent-hook-listener.test.ts src/shared/agent-hook-relay.test.ts src/relay/agent-hook-server.test.ts src/main/agent-hooks/server.test.ts src/main/ssh/ssh-relay-session-agent-hooks.integration.test.ts --reporter=dot",
|
||||
"result": "passed",
|
||||
"durationSeconds": 2.66,
|
||||
"summary": "Eight focused files and 435 tests passed with local HTTP, relay, SSH, persistence, adversarial identity, cleanup, and existing provider-regression coverage."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-02",
|
||||
"runner": "manual",
|
||||
"platform": "macos",
|
||||
"command": "Manual Claude Code 2.1.220 interactive compact: isolate UserPromptSubmit, PreCompact, and PostCompact hooks; submit one ordinary prompt then /compact; compare source, trigger, session_id, and prompt_id",
|
||||
"result": "passed",
|
||||
"durationSeconds": 120,
|
||||
"summary": "A fresh capture proved /compact emits no UserPromptSubmit and its PreCompact uses a new UUID distinct from the preceding user turn; the preserved authenticated capture proved the matching manual PreCompact and PostCompact share that UUID and session. Automatic capture reused one prompt UUID across multiple compact generations, so automatic completion remains disabled."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 20,
|
||||
"scope": "shared, main-process, relay, persistence, and fake-SSH contract tests"
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "unknown",
|
||||
"evidence": "New deterministic gate with one local run; CI soak history is not yet available."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "complete",
|
||||
"evidence": "The byte-identical oracle (SHA-256 24e80c15b467ffa9c47a81099f11bf26dea71293e47b5d950b2fad1497094764) failed 5 of 6 on origin/main@a20165a43d, passed 6 of 6 on the candidate, failed 3 of 6 when only the exact-transition guard was disabled, and passed 6 of 6 after restoration."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "Each compact event performs one O(1) current-status lookup plus bounded UUID, source, connection, session, and event comparisons. Identity lives on the existing status row and is removed by its existing lifecycle; the change adds no owner map, global scan, polling, timer, subprocess, network request, or listener. The 4,097-key adversarial arm retains zero status, prompt, or ownership entries."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Collect 100 consecutive focused CI passes or 14 days of soak history.",
|
||||
"Run live manual compact journeys on Linux and Windows and through a real SSH target.",
|
||||
"Keep automatic compact completion disabled until a generation-unique provider identity is proven."
|
||||
],
|
||||
"knownGaps": [
|
||||
"No live Linux, Windows, WSL, paired-runtime, or real-SSH manual compact journey was run.",
|
||||
"Claude versions before prompt_id support intentionally cannot complete a manual compact row through PostCompact.",
|
||||
"Automatic Claude and Kimi completion remain intentionally fail-closed because their hooks do not prove an exact compact generation."
|
||||
],
|
||||
"demotionRule": "Demote or quarantine if identity-mismatched completion can retire newer work, cleanup retains compact authority, or the focused gate flakes without a product or harness bug."
|
||||
},
|
||||
{
|
||||
"id": "agent-session.provider-ownership",
|
||||
"title": "Provider sessions are resumed once per workspace ownership claim",
|
||||
@@ -4321,7 +4435,7 @@
|
||||
"providers": ["local", "daemon", "ssh", "wsl", "remote-runtime"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": [],
|
||||
"coverageNotes": "Platform-neutral unit coverage proves the Resource Manager closed badge performs one readiness seed, coalesces unknown spawn signals, skips known-session reattach signals, and installs no interval. Broader terminal interaction coverage remains on the pending reliability stack.",
|
||||
"coverageNotes": "Platform-neutral unit coverage proves the Resource Manager closed badge performs one readiness seed, coalesces unknown spawn signals, skips known-session reattach signals, installs no interval, and re-reads only once per explicit daemon-management kill/restart (which emits no pty:exit). Broader terminal interaction coverage remains on the pending reliability stack.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/pull/7002",
|
||||
"https://github.com/stablyai/orca/pull/6858",
|
||||
@@ -4350,7 +4464,8 @@
|
||||
"spawn signals during a slow inventory read never overlap provider-wide scans and cause at most one required follow-up",
|
||||
"unknown sessions that exit before reconciliation cancel their queued inventory read",
|
||||
"unmount during a slow inventory read cannot schedule follow-up work",
|
||||
"exit and out-of-order refresh races cannot resurrect stale sessions"
|
||||
"exit and out-of-order refresh races cannot resurrect stale sessions",
|
||||
"an explicit daemon-management invalidation performs exactly one inventory read, and none before readiness or after unmount"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -71,7 +71,7 @@ export function normalizeAdhocLabel(label) {
|
||||
}
|
||||
|
||||
/**
|
||||
* `1.4.163 • wasm-terminal • 08-01 14:25 • abc1234` — the human-facing release
|
||||
* `1.4.163 • wasm-terminal • Aug 1, 2:25PM • abc1234` — the human-facing release
|
||||
* title, shown verbatim in both the GitHub releases list and the build picker.
|
||||
*
|
||||
* Why the label sits where hourly puts its build number: several adhoc builds
|
||||
|
||||
@@ -88,20 +88,20 @@ describe('formatAdhocReleaseName', () => {
|
||||
formatAdhocReleaseName('1.4.163-adhoc.x', label, commit, new Date(iso))
|
||||
|
||||
it('renders version, label, Pacific timestamp, and short sha', () => {
|
||||
expect(name('2026-07-31T20:54:00Z')).toBe('1.4.163 • wasm-terminal • 07-31 13:54 • e698241')
|
||||
expect(name('2026-07-31T20:54:00Z')).toBe('1.4.163 • wasm-terminal • Jul 31, 1:54PM • e698241')
|
||||
})
|
||||
|
||||
// Why both sides of DST: the tag's stamp is UTC and the title is Pacific, so
|
||||
// the offset between them is not a constant. A test pinned to one season would
|
||||
// pass all summer and start failing in November.
|
||||
it('follows the Pacific offset across DST', () => {
|
||||
expect(name('2026-01-15T02:30:00Z')).toContain(' 01-14 18:30 ')
|
||||
expect(name('2026-07-31T07:00:00Z')).toContain(' 07-31 00:00 ')
|
||||
expect(name('2026-01-15T02:30:00Z')).toContain(' Jan 14, 6:30PM ')
|
||||
expect(name('2026-07-31T07:00:00Z')).toContain(' Jul 31, 12:00AM ')
|
||||
})
|
||||
|
||||
it('sanitizes the label before it reaches the title', () => {
|
||||
expect(name('2026-07-31T20:54:00Z', 'refs/heads/fix • now')).toBe(
|
||||
'1.4.163 • fix-now • 07-31 13:54 • e698241'
|
||||
'1.4.163 • fix-now • Jul 31, 1:54PM • e698241'
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -34,8 +34,34 @@ export function createHourlyBuildVersion(baseVersion, date) {
|
||||
}
|
||||
|
||||
/**
|
||||
* `1.4.163 • 01 • 07-31 13:54 • e698241` — the human-facing release title, shown
|
||||
* verbatim in both the GitHub releases list and the in-app build picker.
|
||||
* The next build number for `baseVersion`, counting from the titles of existing
|
||||
* hourly releases.
|
||||
*
|
||||
* Why the series restarts at 01 on every base version: the number answers "which
|
||||
* build of 1.4.163 is this", so a counter shared across versions makes it
|
||||
* meaningless — 1.4.164 would open at 38 for no reason a reader can see.
|
||||
*
|
||||
* Why the maximum rather than a count: the prune step trims to
|
||||
* HOURLY_RETAIN_COUNT, so a count would roll backwards and reissue a number
|
||||
* already in use. Titles that predate this naming simply do not match, which is
|
||||
* how the first build of a version lands on 01.
|
||||
*/
|
||||
export function nextHourlyBuildNumber(baseVersion, releaseNames = []) {
|
||||
const prefix = `${baseVersion} • `
|
||||
const highest = releaseNames.reduce((max, entry) => {
|
||||
const name = String(entry ?? '')
|
||||
if (!name.startsWith(prefix)) {
|
||||
return max
|
||||
}
|
||||
const match = /^(\d+) • /.exec(name.slice(prefix.length))
|
||||
return match ? Math.max(max, Number(match[1])) : max
|
||||
}, 0)
|
||||
return highest + 1
|
||||
}
|
||||
|
||||
/**
|
||||
* `1.4.163 • 01 • Jul 31, 1:54PM • e698241` — the human-facing release title,
|
||||
* shown verbatim in both the GitHub releases list and the in-app build picker.
|
||||
*/
|
||||
export function formatHourlyReleaseName(version, buildNumber, commit, date) {
|
||||
if (!Number.isInteger(buildNumber) || buildNumber < 1) {
|
||||
@@ -49,21 +75,35 @@ export function formatHourlyReleaseName(version, buildNumber, commit, date) {
|
||||
].join(' • ')
|
||||
}
|
||||
|
||||
export function getHourlyBuildIdentity(now = new Date(), buildNumber = 1, publishedVersions = []) {
|
||||
// Why the number is derived here rather than passed in: it counts builds of the
|
||||
// base version, and the base is only known once the published tags have been
|
||||
// resolved just above. Computing it outside meant numbering against whatever
|
||||
// version the caller guessed.
|
||||
export function getHourlyBuildIdentity(now = new Date(), { publishedVersions, releaseNames } = {}) {
|
||||
const packageJson = JSON.parse(readFileSync(resolve('package.json'), 'utf8'))
|
||||
const commit = execFileSync('git', ['rev-parse', '--short=12', 'HEAD'], {
|
||||
encoding: 'utf8'
|
||||
}).trim()
|
||||
const base = resolveDevChannelBaseVersion(packageJson.version, publishedVersions)
|
||||
const base = resolveDevChannelBaseVersion(packageJson.version, publishedVersions ?? [])
|
||||
const version = createHourlyBuildVersion(base, now)
|
||||
return { commit, version, name: formatHourlyReleaseName(version, buildNumber, commit, now) }
|
||||
const buildNumber = nextHourlyBuildNumber(base, releaseNames ?? [])
|
||||
return {
|
||||
commit,
|
||||
version,
|
||||
buildNumber,
|
||||
name: formatHourlyReleaseName(version, buildNumber, commit, now)
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) {
|
||||
const buildNumber = Number(process.env.ORCA_HOURLY_BUILD_NUMBER ?? '1')
|
||||
const identity = getHourlyBuildIdentity(new Date(), buildNumber, readPublishedVersionsFromEnv())
|
||||
const identity = getHourlyBuildIdentity(new Date(), {
|
||||
publishedVersions: readPublishedVersionsFromEnv(),
|
||||
// Titles are newline separated and contain spaces, so this cannot reuse the
|
||||
// whitespace split the version list gets.
|
||||
releaseNames: (process.env.ORCA_HOURLY_RELEASE_NAMES ?? '').split('\n').filter(Boolean)
|
||||
})
|
||||
// Consumed by the workflow via $GITHUB_OUTPUT.
|
||||
process.stdout.write(
|
||||
`version=${identity.version}\ncommit=${identity.commit}\nname=${identity.name}\n`
|
||||
`version=${identity.version}\ncommit=${identity.commit}\nbuild_number=${identity.buildNumber}\nname=${identity.name}\n`
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createHourlyBuildVersion, formatHourlyReleaseName } from './hourly-build-version.mjs'
|
||||
import {
|
||||
createHourlyBuildVersion,
|
||||
formatHourlyReleaseName,
|
||||
nextHourlyBuildNumber
|
||||
} from './hourly-build-version.mjs'
|
||||
import { compareAppVersions } from '../../src/shared/app-version'
|
||||
|
||||
describe('createHourlyBuildVersion', () => {
|
||||
@@ -34,21 +38,28 @@ describe('formatHourlyReleaseName', () => {
|
||||
formatHourlyReleaseName('1.4.163-hourly.x', buildNumber, commit, new Date(iso))
|
||||
|
||||
it('renders version, number, Pacific timestamp, and short sha', () => {
|
||||
expect(name('2026-07-31T20:54:00Z')).toBe('1.4.163 • 01 • 07-31 13:54 • e698241')
|
||||
expect(name('2026-07-31T20:54:00Z')).toBe('1.4.163 • 01 • Jul 31, 1:54PM • e698241')
|
||||
})
|
||||
|
||||
// Why both sides of DST: the tag's stamp is UTC and the title is Pacific, so
|
||||
// the offset between them is not a constant. A test pinned to one season would
|
||||
// pass all summer and start failing in November.
|
||||
it('follows the Pacific offset across DST', () => {
|
||||
expect(name('2026-01-15T02:30:00Z')).toBe('1.4.163 • 01 • 01-14 18:30 • e698241')
|
||||
expect(name('2026-07-31T07:00:00Z')).toBe('1.4.163 • 01 • 07-31 00:00 • e698241')
|
||||
expect(name('2026-01-15T02:30:00Z')).toBe('1.4.163 • 01 • Jan 14, 6:30PM • e698241')
|
||||
expect(name('2026-07-31T07:00:00Z')).toBe('1.4.163 • 01 • Jul 31, 12:00AM • e698241')
|
||||
})
|
||||
|
||||
// Why: hour12: false renders midnight as "24" on some ICU builds, which would
|
||||
// read as an hour that does not exist and sort oddly beside 00:xx.
|
||||
it('renders midnight as 00, never 24', () => {
|
||||
expect(name('2026-07-31T07:00:00Z')).toContain(' 00:00 ')
|
||||
// Why pinned: 12-hour clocks are where off-by-twelve bugs live, and midnight in
|
||||
// particular renders as 0:00 or 24:00 on a formatter set up carelessly.
|
||||
it('renders both noon and midnight as 12', () => {
|
||||
expect(name('2026-07-31T07:00:00Z')).toContain('12:00AM')
|
||||
expect(name('2026-07-31T19:00:00Z')).toContain('12:00PM')
|
||||
})
|
||||
|
||||
// Recent ICU puts U+202F before AM/PM; the title must carry no separator at all.
|
||||
it('joins the meridiem with no whitespace of any kind', () => {
|
||||
expect(name('2026-07-31T20:54:00Z')).toMatch(/\d:\d{2}(AM|PM) •/)
|
||||
expect(name('2026-07-31T20:54:00Z')).not.toMatch(/\s[AP]M/u)
|
||||
})
|
||||
|
||||
it('pads to two digits and grows past them', () => {
|
||||
@@ -70,3 +81,42 @@ describe('formatHourlyReleaseName', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('nextHourlyBuildNumber', () => {
|
||||
const titles = [
|
||||
'1.4.163 • 01 • Jul 31, 1:54PM • e698241',
|
||||
'1.4.163 • 02 • Jul 31, 2:54PM • aaaaaaa',
|
||||
'1.4.163 • 37 • Aug 01, 9:00AM • bbbbbbb'
|
||||
]
|
||||
|
||||
it('continues the series for the version being built', () => {
|
||||
expect(nextHourlyBuildNumber('1.4.163', titles)).toBe(38)
|
||||
})
|
||||
|
||||
// The point of the change: 1.4.164 opens its own series at 01 rather than
|
||||
// inheriting 1.4.163's count, which said nothing about 1.4.164.
|
||||
it('restarts at 1 when the base version moves', () => {
|
||||
expect(nextHourlyBuildNumber('1.4.164', titles)).toBe(1)
|
||||
expect(
|
||||
nextHourlyBuildNumber('1.4.164', [...titles, '1.4.164 • 01 • Aug 02, 8:00AM • ccccccc'])
|
||||
).toBe(2)
|
||||
})
|
||||
|
||||
// Why max and not count: pruning trims to HOURLY_RETAIN_COUNT, so counting
|
||||
// would roll backwards and reissue a number already used.
|
||||
it('takes the highest number, not the count', () => {
|
||||
expect(nextHourlyBuildNumber('1.4.163', ['1.4.163 • 09 • Jul 31, 1:54PM • e698241'])).toBe(10)
|
||||
})
|
||||
|
||||
it('starts at 1 with no history at all', () => {
|
||||
expect(nextHourlyBuildNumber('1.4.163')).toBe(1)
|
||||
expect(nextHourlyBuildNumber('1.4.163', [])).toBe(1)
|
||||
})
|
||||
|
||||
// Legacy titles were the raw tag, and a prefix match must not treat 1.4.16 as
|
||||
// a prefix of 1.4.163's series.
|
||||
it('ignores titles that are not this version', () => {
|
||||
expect(nextHourlyBuildNumber('1.4.16', ['1.4.163 • 37 • Aug 01, 9:00AM • bbbbbbb'])).toBe(1)
|
||||
expect(nextHourlyBuildNumber('1.4.163', ['v1.4.163-hourly.202607311354', null, ''])).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const RELEASE_NAME_TIME_ZONE = 'America/Los_Angeles'
|
||||
|
||||
/**
|
||||
* `07-31 13:54` — the timestamp segment of a dev build's release title, shown
|
||||
* `Jul 31, 8:10PM` — the timestamp segment of a dev build's release title, shown
|
||||
* verbatim in both the GitHub releases list and the in-app build picker.
|
||||
*
|
||||
* Why Pacific while the tag's own stamp stays UTC: that stamp is a sort key, and
|
||||
@@ -16,15 +16,17 @@ export function formatReleaseTitleTimestamp(date) {
|
||||
const parts = Object.fromEntries(
|
||||
new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: RELEASE_NAME_TIME_ZONE,
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
// Why h23 rather than hour12: false: some ICU builds render midnight as 24.
|
||||
hourCycle: 'h23'
|
||||
hour12: true
|
||||
})
|
||||
.formatToParts(date)
|
||||
.map((part) => [part.type, part.value])
|
||||
)
|
||||
return `${parts.month}-${parts.day} ${parts.hour}:${parts.minute}`
|
||||
// Assembled from parts rather than by string-editing the formatted output:
|
||||
// recent ICU separates the time from AM/PM with U+202F, not a plain space, so
|
||||
// a naive replace(' ', '') leaves the gap on some runtimes and not others.
|
||||
return `${parts.month} ${parts.day}, ${parts.hour}:${parts.minute}${parts.dayPeriod.toUpperCase()}`
|
||||
}
|
||||
|
||||
@@ -196,7 +196,7 @@ import { MobileTerminalLiveInputStatus } from '../../../../src/session/MobileTer
|
||||
import { MobileTerminalInputActions } from '../../../../src/session/MobileTerminalInputActions'
|
||||
import { resolveMobileFileTabDoc } from '../../../../src/files/mobile-file-tab-doc'
|
||||
import { captureMobileFileMutationOwnership } from '../../../../src/files/mobile-file-mutation-ownership'
|
||||
import { openMobileTerminalFileTap } from '../../../../src/session/mobile-terminal-file-tap-open'
|
||||
import { useMobileFileTapHandlers } from '../../../../src/session/use-mobile-file-tap-handlers'
|
||||
import { useLiveWorktreeName } from '../../../../src/session/use-live-worktree-name'
|
||||
import {
|
||||
acceptSessionSnapshot,
|
||||
@@ -1693,7 +1693,8 @@ export default function SessionScreen() {
|
||||
|
||||
try {
|
||||
const response = await client.sendRequest('terminal.list', {
|
||||
worktree: `id:${worktreeId}`
|
||||
worktree: `id:${worktreeId}`,
|
||||
includeVisualLayouts: false
|
||||
})
|
||||
if (response.ok) {
|
||||
const result = (response as RpcSuccess).result as { terminals: Terminal[] }
|
||||
@@ -3192,44 +3193,23 @@ export default function SessionScreen() {
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Tap a terminal file path → resolve on host, open as file tab (mirrors desktop Cmd/Ctrl-click); silent on a miss.
|
||||
const handleFileTapActivationSeqRef = useRef(0)
|
||||
const handleFileTap = useCallback(
|
||||
(handle: string, pathText: string, line: number | null, column: number | null) => {
|
||||
if (handle !== activeHandleRef.current || !client) {
|
||||
return
|
||||
}
|
||||
const activationSeq = ++handleFileTapActivationSeqRef.current
|
||||
openMobileTerminalFileTap<MobileSessionTab>({
|
||||
client,
|
||||
hostId,
|
||||
worktreeId,
|
||||
worktreeName: routeWorktreeName,
|
||||
terminalHandle: handle,
|
||||
pathText,
|
||||
cwd: terminalCwdRef.current.get(handle) ?? null,
|
||||
line,
|
||||
column,
|
||||
pushPreviewRoute: (href) => router.push(href),
|
||||
openBrowser: (url) => void handleCreateBrowserRef.current?.(url),
|
||||
triggerOpenFeedback: triggerSelection,
|
||||
fetchSessionTabs,
|
||||
getSessionTabs: () => sessionTabsRef.current,
|
||||
getActiveSessionTabId: () => activeSessionTabIdRef.current,
|
||||
getActivationState: (activated) => ({
|
||||
activated,
|
||||
activationSeq,
|
||||
latestActivationSeq: handleFileTapActivationSeqRef.current,
|
||||
sourceTerminalHandle: handle,
|
||||
activeTerminalHandle: activeHandleRef.current,
|
||||
activeTabType: activeSessionTabTypeRef.current
|
||||
}),
|
||||
switchSessionTab: (tab) => switchSessionTabRef.current?.(tab),
|
||||
scheduleDelayedAction
|
||||
})
|
||||
},
|
||||
[client, fetchSessionTabs, hostId, routeWorktreeName, router, scheduleDelayedAction, worktreeId]
|
||||
)
|
||||
// Tap a terminal or chat file path → resolve on host, open as file tab/preview.
|
||||
const { handleFileTap, handleNativeChatFileTap } = useMobileFileTapHandlers<MobileSessionTab>({
|
||||
client,
|
||||
hostId,
|
||||
worktreeId,
|
||||
worktreeName: routeWorktreeName,
|
||||
activeHandleRef,
|
||||
terminalCwdRef,
|
||||
openBrowser: (url) => void handleCreateBrowserRef.current?.(url),
|
||||
fetchSessionTabs,
|
||||
getSessionTabs: () => sessionTabsRef.current,
|
||||
getActiveSessionTabId: () => activeSessionTabIdRef.current,
|
||||
getActiveSessionTabType: () => activeSessionTabTypeRef.current,
|
||||
switchSessionTab: (tab) => switchSessionTabRef.current?.(tab),
|
||||
scheduleDelayedAction,
|
||||
reportChatTapFailure: nativeChatSendError.show
|
||||
})
|
||||
|
||||
const handleOpenedFileDiffActivationSeqRef = useRef(0)
|
||||
// Capture active tab at tap time; reading it after openDiff would misread a mid-RPC switch and let the retry steal focus.
|
||||
@@ -4750,6 +4730,7 @@ export default function SessionScreen() {
|
||||
))}
|
||||
<MobileNativeChatOverlay
|
||||
controller={nativeChatController}
|
||||
onOpenFile={handleNativeChatFileTap}
|
||||
images={nativeChatImages}
|
||||
onMicPress={handleDictationToggle}
|
||||
micActive={dictation.isRecording}
|
||||
|
||||
+10
-10
@@ -16,7 +16,7 @@ import {
|
||||
} from '../src/components/AccountUsage'
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import { loadHosts } from '../src/transport/host-store'
|
||||
import { navigateToMobileHostEdit } from '../src/transport/host-edit-navigation'
|
||||
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'
|
||||
import { totalHomeStats, type HomeStatsSummary } from '../src/stats/home-stats-total'
|
||||
@@ -54,7 +54,7 @@ import {
|
||||
} from '../src/tasks/mobile-task-providers'
|
||||
import { useOpenMobileTasks } from '../src/tasks/use-open-mobile-tasks'
|
||||
import { useResponsiveLayout } from '../src/layout/responsive-layout'
|
||||
import { createMobileSessionHref } from '../src/session/mobile-session-route'
|
||||
import { useOpenMobileSession } from '../src/session/use-open-mobile-session'
|
||||
|
||||
function endpointLabel(endpoint: string): string {
|
||||
try {
|
||||
@@ -206,7 +206,9 @@ function repoColor(name: string): string {
|
||||
|
||||
export default function HomeScreen() {
|
||||
const router = useRouter()
|
||||
const openMobileHostEdit = useOpenMobileHostEdit()
|
||||
const openMobileTasks = useOpenMobileTasks()
|
||||
const openMobileSession = useOpenMobileSession()
|
||||
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()
|
||||
@@ -736,13 +738,11 @@ export default function HomeScreen() {
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.resumeCard, pressed && styles.hostCardPressed]}
|
||||
onPress={() =>
|
||||
router.push(
|
||||
createMobileSessionHref({
|
||||
hostId: resumeWorktree.hostId,
|
||||
worktreeId: resumeWorktree.worktree.worktreeId,
|
||||
name: resumeWorktree.worktree.displayName || resumeWorktree.worktree.repo
|
||||
})
|
||||
)
|
||||
openMobileSession({
|
||||
hostId: resumeWorktree.hostId,
|
||||
worktreeId: resumeWorktree.worktree.worktreeId,
|
||||
name: resumeWorktree.worktree.displayName || resumeWorktree.worktree.repo
|
||||
})
|
||||
}
|
||||
>
|
||||
<View style={styles.resumeIcon}>
|
||||
@@ -906,7 +906,7 @@ export default function HomeScreen() {
|
||||
onDismiss: () => setActionTarget(null),
|
||||
onReconnect: (hostId) => void forceReconnectHost(hostId),
|
||||
onDisconnect: closeHostClient,
|
||||
onEdit: (hostId) => navigateToMobileHostEdit(router, hostId),
|
||||
onEdit: openMobileHostEdit,
|
||||
onRemove: setConfirmRemove
|
||||
})}
|
||||
onClose={() => setActionTarget(null)}
|
||||
|
||||
@@ -139,7 +139,7 @@ async function chooseWorktree(ws: WebSocket): Promise<string> {
|
||||
}
|
||||
|
||||
async function chooseTerminal(ws: WebSocket, worktree: string): Promise<string> {
|
||||
const list = await send(ws, 'terminal.list', { worktree })
|
||||
const list = await send(ws, 'terminal.list', { worktree, includeVisualLayouts: false })
|
||||
if (!list.ok) {
|
||||
throw new Error(`terminal.list failed: ${formatResponse(list)}`)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer, type ReactTestInstance } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { MobileMarkdown } from './MobileMarkdown'
|
||||
|
||||
const openURL = vi.fn(() => Promise.resolve())
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
Linking: { openURL: (url: string) => openURL(url) },
|
||||
Pressable: 'Pressable',
|
||||
ScrollView: 'ScrollView',
|
||||
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 },
|
||||
Text: 'Text',
|
||||
View: 'View'
|
||||
}))
|
||||
vi.mock('./pr-sidebar/MermaidDiagram', () => ({ MermaidDiagram: 'MermaidDiagram' }))
|
||||
|
||||
function flattenText(node: ReactTestInstance): string {
|
||||
return node.children
|
||||
.map((child) => (typeof child === 'string' ? child : flattenText(child)))
|
||||
.join('')
|
||||
}
|
||||
|
||||
function pressables(renderer: ReactTestRenderer): ReactTestInstance[] {
|
||||
return renderer.root.findAll(
|
||||
(node) => node.type === ('Text' as never) && typeof node.props.onPress === 'function'
|
||||
)
|
||||
}
|
||||
|
||||
function pressByText(renderer: ReactTestRenderer, text: string): void {
|
||||
const target = pressables(renderer).find((node) => flattenText(node) === text)
|
||||
expect(target, `no pressable text ${JSON.stringify(text)}`).toBeDefined()
|
||||
target!.props.onPress()
|
||||
}
|
||||
|
||||
describe('MobileMarkdown file links', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
const onOpenFile = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
onOpenFile.mockClear()
|
||||
openURL.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
})
|
||||
|
||||
function render(content: string): ReactTestRenderer {
|
||||
act(() => {
|
||||
renderer = create(createElement(MobileMarkdown, { content, onOpenFile }))
|
||||
})
|
||||
return renderer!
|
||||
}
|
||||
|
||||
it('opens a tapped POSIX absolute path in prose', () => {
|
||||
pressByText(render('Edit /Users/me/wt/src/app.tsx now'), '/Users/me/wt/src/app.tsx')
|
||||
expect(onOpenFile).toHaveBeenCalledWith('/Users/me/wt/src/app.tsx')
|
||||
})
|
||||
|
||||
it('opens a tapped path:line citation in prose', () => {
|
||||
pressByText(render('see src/foo.ts:42 for the fix'), 'src/foo.ts:42')
|
||||
expect(onOpenFile).toHaveBeenCalledWith('src/foo.ts:42')
|
||||
})
|
||||
|
||||
it('routes a relative markdown href to the file opener with its #L line', () => {
|
||||
pressByText(render('read [the plan](docs/plan.md#L7) first'), 'the plan')
|
||||
expect(onOpenFile).toHaveBeenCalledWith('docs/plan.md:7')
|
||||
expect(openURL).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes a file: href to the file opener', () => {
|
||||
pressByText(render('[artifact](file:///tmp/out/result.json)'), 'artifact')
|
||||
expect(onOpenFile).toHaveBeenCalledWith('/tmp/out/result.json')
|
||||
})
|
||||
|
||||
it('keeps web links on the system browser', () => {
|
||||
pressByText(render('go to [site](https://example.com/docs)'), 'site')
|
||||
expect(openURL).toHaveBeenCalledWith('https://example.com/docs')
|
||||
expect(onOpenFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('drops unknown-scheme hrefs without opening anything', () => {
|
||||
pressByText(render('[ide](editor://file/x.ts)'), 'ide')
|
||||
expect(openURL).not.toHaveBeenCalled()
|
||||
expect(onOpenFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps snake_case paths whole instead of shredding them as emphasis', () => {
|
||||
const rendered = render('compare src/foo_bar.ts and src/baz_qux.ts now')
|
||||
pressByText(rendered, 'src/foo_bar.ts')
|
||||
pressByText(rendered, 'src/baz_qux.ts')
|
||||
expect(onOpenFile).toHaveBeenNthCalledWith(1, 'src/foo_bar.ts')
|
||||
expect(onOpenFile).toHaveBeenNthCalledWith(2, 'src/baz_qux.ts')
|
||||
})
|
||||
|
||||
it('keeps markdown links between snake_case paths tappable', () => {
|
||||
pressByText(
|
||||
render('Updated src/foo_bar.py; see [the PR](https://example.com/x) before src/baz_qux.py'),
|
||||
'the PR'
|
||||
)
|
||||
expect(openURL).toHaveBeenCalledWith('https://example.com/x')
|
||||
})
|
||||
|
||||
it('opens a dunder path as one link', () => {
|
||||
pressByText(render('see a/__tests__/x.ts now'), 'a/__tests__/x.ts')
|
||||
expect(onOpenFile).toHaveBeenCalledExactlyOnceWith('a/__tests__/x.ts')
|
||||
})
|
||||
|
||||
it('detects paths inside bold spans', () => {
|
||||
pressByText(render('changed **src/foo.ts** heavily'), 'src/foo.ts')
|
||||
expect(onOpenFile).toHaveBeenCalledWith('src/foo.ts')
|
||||
})
|
||||
|
||||
it('excludes trailing sentence punctuation from autolinks', () => {
|
||||
pressByText(render('see https://example.com/a.'), 'https://example.com/a')
|
||||
expect(openURL).toHaveBeenCalledWith('https://example.com/a')
|
||||
})
|
||||
|
||||
it('opens inline-code path:line citations', () => {
|
||||
pressByText(render('fix `src/foo.ts:42` now'), 'src/foo.ts:42')
|
||||
expect(onOpenFile).toHaveBeenCalledWith('src/foo.ts:42')
|
||||
})
|
||||
|
||||
it('renders paths as plain text without onOpenFile', () => {
|
||||
act(() => {
|
||||
renderer = create(createElement(MobileMarkdown, { content: 'Edit src/app/Main.tsx now' }))
|
||||
})
|
||||
expect(pressables(renderer!)).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -7,6 +7,11 @@ import {
|
||||
isFilePathCodeSpan,
|
||||
normalizeFilePath
|
||||
} from './markdown-file-path-detection'
|
||||
import { routeMarkdownHref } from './markdown-href-routing'
|
||||
import {
|
||||
isIntrawordUnderscoreToken,
|
||||
trimAutolinkTrailingPunctuation
|
||||
} from './markdown-inline-token-rules'
|
||||
import { isMobileMermaidLanguage } from './mobile-mermaid-language'
|
||||
import { parseMobileMarkdown } from './mobile-markdown-parser'
|
||||
import { MermaidDiagram } from './pr-sidebar/MermaidDiagram'
|
||||
@@ -17,10 +22,11 @@ type Props = {
|
||||
/** Multiplier for prose font size (paragraphs, lists, quotes). Defaults to 1;
|
||||
* the chat view passes >1 so agent prose reads larger than the compact base. */
|
||||
textScale?: number
|
||||
/** When provided, detected file-path tokens render as tappable and invoke this
|
||||
* with the worktree-relative path. Omitted on screens with no file viewer, where
|
||||
/** When provided, detected file paths and file-target hrefs render as tappable
|
||||
* and invoke this with the path text (worktree-relative or absolute, with an
|
||||
* optional :line(:col) suffix). Omitted on screens with no file viewer, where
|
||||
* paths render as plain text (no behavior change). */
|
||||
onOpenFile?: (relativePath: string) => void
|
||||
onOpenFile?: (pathText: string) => void
|
||||
}
|
||||
|
||||
const MAX_TABLE_ROWS = 40
|
||||
@@ -28,10 +34,16 @@ const MAX_TABLE_COLUMNS = 8
|
||||
/** Prose base size — passed to MermaidDiagram fallback mono text. */
|
||||
const MERMAID_BASE = 13
|
||||
|
||||
function openMarkdownUrl(url: string): void {
|
||||
const trimmed = url.trim()
|
||||
if (/^(https?:|mailto:)/i.test(trimmed)) {
|
||||
void Linking.openURL(trimmed).catch(() => {})
|
||||
// Web/mail hrefs open the system handler; file-target hrefs (file: URIs and
|
||||
// scheme-less paths — the entire desktop file-link contract) go to onOpenFile.
|
||||
function openMarkdownHref(href: string, onOpenFile?: (pathText: string) => void): void {
|
||||
const route = routeMarkdownHref(href)
|
||||
if (route.kind === 'web') {
|
||||
void Linking.openURL(route.url).catch(() => {})
|
||||
return
|
||||
}
|
||||
if (route.kind === 'file' && onOpenFile) {
|
||||
onOpenFile(route.pathText)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +52,7 @@ function openMarkdownUrl(url: string): void {
|
||||
function renderTextRun(
|
||||
text: string,
|
||||
keyPrefix: string,
|
||||
onOpenFile?: (relativePath: string) => void
|
||||
onOpenFile?: (pathText: string) => void
|
||||
): ReactNode {
|
||||
if (!onOpenFile) {
|
||||
return text
|
||||
@@ -65,39 +77,54 @@ function renderTextRun(
|
||||
})
|
||||
}
|
||||
|
||||
function renderInline(text: string, onOpenFile?: (relativePath: string) => void): ReactNode[] {
|
||||
function renderInline(text: string, onOpenFile?: (pathText: string) => void): ReactNode[] {
|
||||
const parts: ReactNode[] = []
|
||||
const pattern =
|
||||
/(!\[[^\]]*\]\([^)]+\)|`[^`]+`|~~[^~]+~~|\*\*[^*]+\*\*|__[^_]+__|\*[^*\n]+\*|_[^_\n]+_|\[[^\]]+\]\([^)]+\)|https?:\/\/[^\s<]+)/g
|
||||
let lastIndex = 0
|
||||
let pendingStart = 0
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
while ((match = pattern.exec(text))) {
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(renderTextRun(text.slice(lastIndex, match.index), `t${lastIndex}`, onOpenFile))
|
||||
}
|
||||
const token = match[0]
|
||||
// Intraword `_` runs (snake_case, dunder tails) are literal text per
|
||||
// CommonMark; leaving them unflushed keeps surrounding file paths whole
|
||||
// for detection in the eventual text run.
|
||||
if (token.startsWith('_') && isIntrawordUnderscoreToken(text, match.index, token)) {
|
||||
// Resume after the opener so real tokens inside the rejected span are still scanned.
|
||||
pattern.lastIndex = match.index + 1
|
||||
continue
|
||||
}
|
||||
if (match.index > pendingStart) {
|
||||
parts.push(
|
||||
renderTextRun(text.slice(pendingStart, match.index), `t${pendingStart}`, onOpenFile)
|
||||
)
|
||||
}
|
||||
pendingStart = pattern.lastIndex
|
||||
const key = `${match.index}:${token}`
|
||||
const image = token.match(/^!\[([^\]]*)\]\(([^)]+)\)$/)
|
||||
const link = token.match(/^\[([^\]]+)\]\(([^)]+)\)$/)
|
||||
if (image) {
|
||||
parts.push(
|
||||
<Text key={key} style={styles.link} onPress={() => openMarkdownUrl(image[2]!)}>
|
||||
<Text key={key} style={styles.link} onPress={() => openMarkdownHref(image[2]!, onOpenFile)}>
|
||||
{image[1] || 'image'}
|
||||
</Text>
|
||||
)
|
||||
} else if (link) {
|
||||
parts.push(
|
||||
<Text key={key} style={styles.link} onPress={() => openMarkdownUrl(link[2]!)}>
|
||||
<Text key={key} style={styles.link} onPress={() => openMarkdownHref(link[2]!, onOpenFile)}>
|
||||
{link[1]}
|
||||
</Text>
|
||||
)
|
||||
} else if (/^https?:\/\//i.test(token)) {
|
||||
const { url, trailing } = trimAutolinkTrailingPunctuation(token)
|
||||
parts.push(
|
||||
<Text key={key} style={styles.link} onPress={() => openMarkdownUrl(token)}>
|
||||
{token}
|
||||
<Text key={key} style={styles.link} onPress={() => openMarkdownHref(url, onOpenFile)}>
|
||||
{url}
|
||||
</Text>
|
||||
)
|
||||
if (trailing) {
|
||||
parts.push(<Fragment key={`${key}p`}>{trailing}</Fragment>)
|
||||
}
|
||||
} else if (token.startsWith('`')) {
|
||||
const code = token.slice(1, -1)
|
||||
if (onOpenFile && isFilePathCodeSpan(code)) {
|
||||
@@ -120,27 +147,26 @@ function renderInline(text: string, onOpenFile?: (relativePath: string) => void)
|
||||
} else if (token.startsWith('~~')) {
|
||||
parts.push(
|
||||
<Text key={key} style={styles.strike}>
|
||||
{token.slice(2, -2)}
|
||||
{renderTextRun(token.slice(2, -2), `${key}i`, onOpenFile)}
|
||||
</Text>
|
||||
)
|
||||
} else if (token.startsWith('**') || token.startsWith('__')) {
|
||||
parts.push(
|
||||
<Text key={key} style={styles.bold}>
|
||||
{token.slice(2, -2)}
|
||||
{renderTextRun(token.slice(2, -2), `${key}i`, onOpenFile)}
|
||||
</Text>
|
||||
)
|
||||
} else {
|
||||
parts.push(
|
||||
<Text key={key} style={styles.italic}>
|
||||
{token.slice(1, -1)}
|
||||
{renderTextRun(token.slice(1, -1), `${key}i`, onOpenFile)}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
lastIndex = pattern.lastIndex
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(renderTextRun(text.slice(lastIndex), `t${lastIndex}`, onOpenFile))
|
||||
if (pendingStart < text.length) {
|
||||
parts.push(renderTextRun(text.slice(pendingStart), `t${pendingStart}`, onOpenFile))
|
||||
}
|
||||
return parts
|
||||
}
|
||||
@@ -206,7 +232,7 @@ function MobileMarkdownInner({ content, fallback = '', textScale = 1, onOpenFile
|
||||
<Pressable
|
||||
key={index}
|
||||
style={styles.imageFrame}
|
||||
onPress={() => openMarkdownUrl(block.url)}
|
||||
onPress={() => openMarkdownHref(block.url, onOpenFile)}
|
||||
>
|
||||
<Text style={styles.link}>{block.alt || 'Open image'}</Text>
|
||||
<Text style={styles.imageCaption} numberOfLines={1}>
|
||||
|
||||
@@ -2,7 +2,8 @@ import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
detectFilePathSegments,
|
||||
isFilePathCodeSpan,
|
||||
normalizeFilePath
|
||||
normalizeFilePath,
|
||||
splitFilePathLineSuffix
|
||||
} from './markdown-file-path-detection'
|
||||
|
||||
describe('detectFilePathSegments', () => {
|
||||
@@ -64,6 +65,69 @@ describe('detectFilePathSegments', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('detects POSIX absolute paths', () => {
|
||||
expect(detectFilePathSegments('Wrote /Users/me/wt/src/app.tsx today')).toEqual([
|
||||
{ type: 'text', value: 'Wrote ' },
|
||||
{ type: 'file', value: '/Users/me/wt/src/app.tsx', path: '/Users/me/wt/src/app.tsx' },
|
||||
{ type: 'text', value: ' today' }
|
||||
])
|
||||
expect(detectFilePathSegments('/repo/src/index.ts')).toEqual([
|
||||
{ type: 'file', value: '/repo/src/index.ts', path: '/repo/src/index.ts' }
|
||||
])
|
||||
expect(detectFilePathSegments('/root.ts')).toEqual([
|
||||
{ type: 'file', value: '/root.ts', path: '/root.ts' }
|
||||
])
|
||||
})
|
||||
|
||||
it('detects files directly under explicit Windows and relative roots', () => {
|
||||
expect(detectFilePathSegments(String.raw`C:\root.ts`)).toEqual([
|
||||
{ type: 'file', value: String.raw`C:\root.ts`, path: String.raw`C:\root.ts` }
|
||||
])
|
||||
expect(detectFilePathSegments('./root.ts')).toEqual([
|
||||
{ type: 'file', value: './root.ts', path: 'root.ts' }
|
||||
])
|
||||
expect(detectFilePathSegments('../root.ts')).toEqual([
|
||||
{ type: 'file', value: '../root.ts', path: '../root.ts' }
|
||||
])
|
||||
})
|
||||
|
||||
it('detects paths with :line and :line:col suffixes', () => {
|
||||
expect(detectFilePathSegments('see src/foo.ts:42 here')).toEqual([
|
||||
{ type: 'text', value: 'see ' },
|
||||
{ type: 'file', value: 'src/foo.ts:42', path: 'src/foo.ts:42' },
|
||||
{ type: 'text', value: ' here' }
|
||||
])
|
||||
expect(
|
||||
detectFilePathSegments('/wt/src/app.tsx:120:7 and C:\\repo\\a.ts:3').filter(
|
||||
(s) => s.type === 'file'
|
||||
)
|
||||
).toEqual([
|
||||
{ type: 'file', value: '/wt/src/app.tsx:120:7', path: '/wt/src/app.tsx:120:7' },
|
||||
{ type: 'file', value: 'C:\\repo\\a.ts:3', path: 'C:\\repo\\a.ts:3' }
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps a non-line colon tail out of the match', () => {
|
||||
expect(detectFilePathSegments('edit src/foo.ts: then run')).toEqual([
|
||||
{ type: 'text', value: 'edit ' },
|
||||
{ type: 'file', value: 'src/foo.ts', path: 'src/foo.ts' },
|
||||
{ type: 'text', value: ': then run' }
|
||||
])
|
||||
})
|
||||
|
||||
it('does not partially parse numeric-looking non-line tails', () => {
|
||||
expect(detectFilePathSegments('log src/app.ts:1e3 oops')).toEqual([
|
||||
{ type: 'text', value: 'log ' },
|
||||
{ type: 'file', value: 'src/app.ts', path: 'src/app.ts' },
|
||||
{ type: 'text', value: ':1e3 oops' }
|
||||
])
|
||||
expect(detectFilePathSegments('coverage src/app.ts:80% of lines')).toEqual([
|
||||
{ type: 'text', value: 'coverage ' },
|
||||
{ type: 'file', value: 'src/app.ts', path: 'src/app.ts' },
|
||||
{ type: 'text', value: ':80% of lines' }
|
||||
])
|
||||
})
|
||||
|
||||
it('does not match bare filenames without a slash', () => {
|
||||
expect(detectFilePathSegments('open Main.tsx please')).toEqual([
|
||||
{ type: 'text', value: 'open Main.tsx please' }
|
||||
@@ -74,6 +138,15 @@ describe('detectFilePathSegments', () => {
|
||||
expect(detectFilePathSegments('https://example.com/path/file.ts')).toEqual([
|
||||
{ type: 'text', value: 'https://example.com/path/file.ts' }
|
||||
])
|
||||
expect(detectFilePathSegments('see https://example.com/path/file.ts:42 now')).toEqual([
|
||||
{ type: 'text', value: 'see https://example.com/path/file.ts:42 now' }
|
||||
])
|
||||
})
|
||||
|
||||
it('does not match protocol-relative URLs', () => {
|
||||
expect(detectFilePathSegments('load //cdn.example.com/lib/app.js')).toEqual([
|
||||
{ type: 'text', value: 'load //cdn.example.com/lib/app.js' }
|
||||
])
|
||||
})
|
||||
|
||||
it('does not match version numbers', () => {
|
||||
@@ -171,12 +244,61 @@ describe('isFilePathCodeSpan', () => {
|
||||
expect(isFilePathCodeSpan('node_modules/@scope/pkg/file.ts')).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts POSIX absolute paths and :line citations', () => {
|
||||
expect(isFilePathCodeSpan('/Users/me/wt/src/app.tsx')).toBe(true)
|
||||
expect(isFilePathCodeSpan('src/foo.ts:42')).toBe(true)
|
||||
expect(isFilePathCodeSpan('src/foo.ts:42:7')).toBe(true)
|
||||
expect(isFilePathCodeSpan('MobileNativeChatComposer.tsx:23')).toBe(true)
|
||||
expect(isFilePathCodeSpan(String.raw`C:\repo\Main.tsx:12`)).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects emails and git URLs with a mid-token @', () => {
|
||||
expect(isFilePathCodeSpan('git@github.com:user/repo.git')).toBe(false)
|
||||
expect(isFilePathCodeSpan('user@host.com/path/file.txt')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('splitFilePathLineSuffix', () => {
|
||||
it('splits :line and :line:col suffixes', () => {
|
||||
expect(splitFilePathLineSuffix('src/foo.ts:42')).toEqual({
|
||||
path: 'src/foo.ts',
|
||||
line: 42,
|
||||
column: null
|
||||
})
|
||||
expect(splitFilePathLineSuffix('src/foo.ts:42:7')).toEqual({
|
||||
path: 'src/foo.ts',
|
||||
line: 42,
|
||||
column: 7
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps Windows drive colons intact', () => {
|
||||
expect(splitFilePathLineSuffix(String.raw`C:\repo\a.ts`)).toEqual({
|
||||
path: String.raw`C:\repo\a.ts`,
|
||||
line: null,
|
||||
column: null
|
||||
})
|
||||
expect(splitFilePathLineSuffix(String.raw`C:\repo\a.ts:12`)).toEqual({
|
||||
path: String.raw`C:\repo\a.ts`,
|
||||
line: 12,
|
||||
column: null
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores non-numeric and zero suffixes', () => {
|
||||
expect(splitFilePathLineSuffix('src/foo.ts')).toEqual({
|
||||
path: 'src/foo.ts',
|
||||
line: null,
|
||||
column: null
|
||||
})
|
||||
expect(splitFilePathLineSuffix('src/foo.ts:0')).toEqual({
|
||||
path: 'src/foo.ts:0',
|
||||
line: null,
|
||||
column: null
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeFilePath', () => {
|
||||
it('strips a leading ./', () => {
|
||||
expect(normalizeFilePath('./a/b.ts')).toBe('a/b.ts')
|
||||
|
||||
@@ -83,9 +83,11 @@ const FILE_EXTENSIONS = [
|
||||
const EXTENSION_SET = new Set<string>(FILE_EXTENSIONS)
|
||||
|
||||
// Accept the host's native separator because transcript paths originate on the
|
||||
// connected runtime, which may be Windows even when the phone is not.
|
||||
// connected runtime, which may be Windows even when the phone is not. Leading
|
||||
// alternatives cover Windows drives, UNC, and POSIX absolute roots; the optional
|
||||
// tail captures only bounded agent-style :line(:col) citations.
|
||||
const CANDIDATE_PATTERN =
|
||||
/(?:[A-Za-z]:[\\/]|\\\\)?(?:\.{1,2}[\\/])?(?:[\w.@~+-]+[\\/])+[\w.@+-]+\.[A-Za-z0-9]+/g
|
||||
/(?:(?:[A-Za-z]:[\\/]|\\\\|[\\/]|\.{1,2}[\\/])(?:[\w.@~+-]+[\\/])*|(?:[\w.@~+-]+[\\/])+)[\w.@+-]+\.[A-Za-z0-9]+(?::[1-9]\d*(?::[1-9]\d*)?(?![\w@%]))?/g
|
||||
|
||||
// A path candidate in chat prose is short; a much longer run can't hold one worth
|
||||
// linkifying but can push CANDIDATE_PATTERN into super-linear backtracking, so we
|
||||
@@ -99,7 +101,31 @@ function hasMidTokenAt(candidate: string): boolean {
|
||||
return /[^\\/]@/.test(candidate)
|
||||
}
|
||||
|
||||
function isOpenablePath(candidate: string): boolean {
|
||||
const LINE_SUFFIX_PATTERN = /^(.+?):([1-9]\d*)(?::([1-9]\d*))?$/
|
||||
|
||||
/**
|
||||
* Split an agent-style `path:line(:col)` citation into its parts. Windows drive
|
||||
* colons are safe: only a trailing all-digit suffix is treated as a line ref.
|
||||
*/
|
||||
export function splitFilePathLineSuffix(pathText: string): {
|
||||
path: string
|
||||
line: number | null
|
||||
column: number | null
|
||||
} {
|
||||
const match = LINE_SUFFIX_PATTERN.exec(pathText)
|
||||
if (!match) {
|
||||
return { path: pathText, line: null, column: null }
|
||||
}
|
||||
return {
|
||||
path: match[1]!,
|
||||
line: Number.parseInt(match[2]!, 10),
|
||||
column: match[3] ? Number.parseInt(match[3], 10) : null
|
||||
}
|
||||
}
|
||||
|
||||
function isOpenablePath(pathText: string): boolean {
|
||||
// A :line(:col) tail is part of the citation, not the file name.
|
||||
const { path: candidate } = splitFilePathLineSuffix(pathText)
|
||||
// Reject anything URL-ish or scheme-bearing — those are handled as web links.
|
||||
if (candidate.includes('://') || hasMidTokenAt(candidate)) {
|
||||
return false
|
||||
@@ -153,10 +179,11 @@ export function detectFilePathSegments(text: string): FilePathSegment[] {
|
||||
|
||||
while ((match = CANDIDATE_PATTERN.exec(text))) {
|
||||
const candidate = match[0]
|
||||
// Skip candidates that are part of a URL (preceded by a scheme colon or an
|
||||
// alphanumeric/host char that would make this a domain tail, not a path).
|
||||
// Skip candidates that are part of a URL: a scheme colon, a domain tail, or
|
||||
// a preceding slash (the leading slash of an absolute path is part of the
|
||||
// match itself, so prev '/' means a '://' or '//' remainder, not a path).
|
||||
const prev = match.index > 0 ? text[match.index - 1]! : ''
|
||||
if (prev === ':' || prev === '/' || /[\w.@]/.test(prev)) {
|
||||
if (prev === ':' || prev === '/' || prev === '\\' || /[\w.@]/.test(prev)) {
|
||||
continue
|
||||
}
|
||||
if (!isOpenablePath(candidate)) {
|
||||
@@ -195,16 +222,18 @@ export function isFilePathCodeSpan(code: string): boolean {
|
||||
if (isOpenablePath(trimmed)) {
|
||||
return true
|
||||
}
|
||||
// Separator-less code span: accept a clean name.ext with a known extension.
|
||||
if (/[\\/]/.test(trimmed)) {
|
||||
// Separator-less code span: accept a clean name.ext (with an optional
|
||||
// :line(:col) citation tail) and a known extension.
|
||||
const { path } = splitFilePathLineSuffix(trimmed)
|
||||
if (/[\\/]/.test(path)) {
|
||||
return false
|
||||
}
|
||||
const dot = trimmed.lastIndexOf('.')
|
||||
const dot = path.lastIndexOf('.')
|
||||
if (dot <= 0) {
|
||||
return false
|
||||
}
|
||||
const name = trimmed.slice(0, dot)
|
||||
const ext = trimmed.slice(dot + 1).toLowerCase()
|
||||
const name = path.slice(0, dot)
|
||||
const ext = path.slice(dot + 1).toLowerCase()
|
||||
if (/[^\w.@+-]/.test(name)) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { routeMarkdownHref } from './markdown-href-routing'
|
||||
|
||||
describe('routeMarkdownHref', () => {
|
||||
it('routes web and mail links to the system handler', () => {
|
||||
expect(routeMarkdownHref('https://example.com/docs')).toEqual({
|
||||
kind: 'web',
|
||||
url: 'https://example.com/docs'
|
||||
})
|
||||
expect(routeMarkdownHref('http://localhost:3000/')).toEqual({
|
||||
kind: 'web',
|
||||
url: 'http://localhost:3000/'
|
||||
})
|
||||
expect(routeMarkdownHref(' mailto:dev@example.com ')).toEqual({
|
||||
kind: 'web',
|
||||
url: 'mailto:dev@example.com'
|
||||
})
|
||||
})
|
||||
|
||||
it('routes relative hrefs to the file opener', () => {
|
||||
expect(routeMarkdownHref('src/foo.ts')).toEqual({ kind: 'file', pathText: 'src/foo.ts' })
|
||||
expect(routeMarkdownHref('./docs/plan.md')).toEqual({
|
||||
kind: 'file',
|
||||
pathText: './docs/plan.md'
|
||||
})
|
||||
})
|
||||
|
||||
it('carries a #L fragment as a :line suffix', () => {
|
||||
expect(routeMarkdownHref('docs/plan.md#L42')).toEqual({
|
||||
kind: 'file',
|
||||
pathText: 'docs/plan.md:42'
|
||||
})
|
||||
expect(routeMarkdownHref('docs/plan.md?plain=1#line-7')).toEqual({
|
||||
kind: 'file',
|
||||
pathText: 'docs/plan.md:7'
|
||||
})
|
||||
expect(routeMarkdownHref('docs/plan.md#usage')).toEqual({
|
||||
kind: 'file',
|
||||
pathText: 'docs/plan.md'
|
||||
})
|
||||
})
|
||||
|
||||
it('decodes percent-encoded href paths', () => {
|
||||
expect(routeMarkdownHref('docs/release%20notes.md')).toEqual({
|
||||
kind: 'file',
|
||||
pathText: 'docs/release notes.md'
|
||||
})
|
||||
})
|
||||
|
||||
it('routes file: URIs to the file opener', () => {
|
||||
expect(routeMarkdownHref('file:///Users/me/wt/src/app.tsx')).toEqual({
|
||||
kind: 'file',
|
||||
pathText: '/Users/me/wt/src/app.tsx'
|
||||
})
|
||||
expect(routeMarkdownHref('file:///Users/me/wt/src/app.tsx#L12')).toEqual({
|
||||
kind: 'file',
|
||||
pathText: '/Users/me/wt/src/app.tsx:12'
|
||||
})
|
||||
expect(routeMarkdownHref('file:///C:/repo/src/index.ts')).toEqual({
|
||||
kind: 'file',
|
||||
pathText: 'C:/repo/src/index.ts'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps Windows drive paths out of the scheme filter', () => {
|
||||
expect(routeMarkdownHref(String.raw`C:\repo\src\index.ts`)).toEqual({
|
||||
kind: 'file',
|
||||
pathText: String.raw`C:\repo\src\index.ts`
|
||||
})
|
||||
})
|
||||
|
||||
it('drops anchors, unknown schemes, and empty hrefs', () => {
|
||||
expect(routeMarkdownHref('#section')).toEqual({ kind: 'none' })
|
||||
expect(routeMarkdownHref('')).toEqual({ kind: 'none' })
|
||||
expect(routeMarkdownHref('editor://file/x.ts')).toEqual({ kind: 'none' })
|
||||
expect(routeMarkdownHref('javascript:alert(1)')).toEqual({ kind: 'none' })
|
||||
expect(routeMarkdownHref('data:text/plain,hi')).toEqual({ kind: 'none' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
import { routeNativeChatHref } from '../../../src/shared/native-chat-href-routing'
|
||||
|
||||
export type MarkdownHrefRoute =
|
||||
| { kind: 'web'; url: string }
|
||||
| { kind: 'file'; pathText: string }
|
||||
| { kind: 'none' }
|
||||
|
||||
function withLineSuffix(pathText: string, line: number | null): string {
|
||||
return line === null ? pathText : `${pathText}:${line}`
|
||||
}
|
||||
|
||||
export function routeMarkdownHref(href: string): MarkdownHrefRoute {
|
||||
const route = routeNativeChatHref(href)
|
||||
if (route.kind !== 'file') {
|
||||
return route
|
||||
}
|
||||
return { kind: 'file', pathText: withLineSuffix(route.pathText, route.line) }
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
isIntrawordUnderscoreToken,
|
||||
trimAutolinkTrailingPunctuation
|
||||
} from './markdown-inline-token-rules'
|
||||
|
||||
describe('isIntrawordUnderscoreToken', () => {
|
||||
it('rejects snake_case emphasis spans', () => {
|
||||
const text = 'src/foo_bar.ts and src/baz_qux.ts'
|
||||
const index = text.indexOf('_')
|
||||
const token = text.slice(index, text.lastIndexOf('_') + 1)
|
||||
expect(isIntrawordUnderscoreToken(text, index, token)).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps standalone emphasis', () => {
|
||||
expect(isIntrawordUnderscoreToken('say _hello_ now', 4, '_hello_')).toBe(false)
|
||||
expect(isIntrawordUnderscoreToken('_hello_.', 0, '_hello_')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects emphasis closed against a following word', () => {
|
||||
expect(isIntrawordUnderscoreToken('_foo_s bar', 0, '_foo_')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects dunder emphasis inside a path', () => {
|
||||
expect(isIntrawordUnderscoreToken('src/__init__.py', 4, '__init__')).toBe(true)
|
||||
expect(isIntrawordUnderscoreToken(String.raw`src\__init__.py`, 4, '__init__')).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores non-underscore tokens', () => {
|
||||
expect(isIntrawordUnderscoreToken('a*b*c', 1, '*b*')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('trimAutolinkTrailingPunctuation', () => {
|
||||
it('splits sentence punctuation off the URL', () => {
|
||||
expect(trimAutolinkTrailingPunctuation('https://x.com/a.')).toEqual({
|
||||
url: 'https://x.com/a',
|
||||
trailing: '.'
|
||||
})
|
||||
expect(trimAutolinkTrailingPunctuation('https://x.com/a,')).toEqual({
|
||||
url: 'https://x.com/a',
|
||||
trailing: ','
|
||||
})
|
||||
expect(trimAutolinkTrailingPunctuation('https://x.com/a?!')).toEqual({
|
||||
url: 'https://x.com/a',
|
||||
trailing: '?!'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps balanced parens and strips unbalanced ones', () => {
|
||||
expect(trimAutolinkTrailingPunctuation('https://x.com/a_(b)')).toEqual({
|
||||
url: 'https://x.com/a_(b)',
|
||||
trailing: ''
|
||||
})
|
||||
expect(trimAutolinkTrailingPunctuation('https://x.com/a).')).toEqual({
|
||||
url: 'https://x.com/a',
|
||||
trailing: ').'
|
||||
})
|
||||
})
|
||||
|
||||
it('handles long unmatched closing-parenthesis tails', () => {
|
||||
const url = 'https://x.com/a_(b)'
|
||||
const trailing = ')'.repeat(4096)
|
||||
expect(trimAutolinkTrailingPunctuation(`${url}${trailing}`)).toEqual({ url, trailing })
|
||||
})
|
||||
|
||||
it('leaves clean URLs untouched', () => {
|
||||
expect(trimAutolinkTrailingPunctuation('https://x.com/a')).toEqual({
|
||||
url: 'https://x.com/a',
|
||||
trailing: ''
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
// Post-checks for inline markdown tokens that a single-pass tokenizer regex
|
||||
// cannot express on its own.
|
||||
|
||||
const INTRAWORD_FLANK_PATTERN = /[\w\\/]/
|
||||
|
||||
/**
|
||||
* True when a `_…_` / `__…__` token sits inside a word (snake_case, dunder
|
||||
* tails). CommonMark treats intraword underscores as literal text; path
|
||||
* separators count as flanks so dunder path segments also stay whole.
|
||||
*/
|
||||
export function isIntrawordUnderscoreToken(text: string, index: number, token: string): boolean {
|
||||
if (!token.startsWith('_')) {
|
||||
return false
|
||||
}
|
||||
const prev = index > 0 ? text[index - 1]! : ''
|
||||
const next = text[index + token.length] ?? ''
|
||||
return INTRAWORD_FLANK_PATTERN.test(prev) || INTRAWORD_FLANK_PATTERN.test(next)
|
||||
}
|
||||
|
||||
/**
|
||||
* Split sentence punctuation off an autolinked URL tail ("see https://x.com/a."),
|
||||
* keeping a trailing ')' only when the URL itself opened a paren.
|
||||
*/
|
||||
export function trimAutolinkTrailingPunctuation(url: string): { url: string; trailing: string } {
|
||||
let end = url.length
|
||||
let parenthesisCountsReady = false
|
||||
let openParentheses = 0
|
||||
let closeParentheses = 0
|
||||
while (end > 0) {
|
||||
const char = url[end - 1]!
|
||||
if ('.,;:!?'.includes(char)) {
|
||||
end--
|
||||
continue
|
||||
}
|
||||
if (char === ')') {
|
||||
if (!parenthesisCountsReady) {
|
||||
for (let index = 0; index < end; index++) {
|
||||
if (url[index] === '(') {
|
||||
openParentheses++
|
||||
} else if (url[index] === ')') {
|
||||
closeParentheses++
|
||||
}
|
||||
}
|
||||
parenthesisCountsReady = true
|
||||
}
|
||||
if (closeParentheses > openParentheses) {
|
||||
end--
|
||||
closeParentheses--
|
||||
continue
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
return { url: url.slice(0, end), trailing: url.slice(end) }
|
||||
}
|
||||
@@ -5,8 +5,7 @@ const homeSource = readFileSync(new URL('../app/index.tsx', import.meta.url), 'u
|
||||
|
||||
describe('Home host edit navigation wiring', () => {
|
||||
it('uses the cold-navigator-safe edit transition', () => {
|
||||
expect(homeSource).toMatch(
|
||||
/onEdit:\s*\(hostId\)\s*=>\s*navigateToMobileHostEdit\(router,\s*hostId\)/
|
||||
)
|
||||
expect(homeSource).toMatch(/const openMobileHostEdit = useOpenMobileHostEdit\(\)/)
|
||||
expect(homeSource).toMatch(/onEdit:\s*openMobileHostEdit/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
coordinateHostStackNavigation,
|
||||
hostStackHostRoute,
|
||||
navigateToHostStackRoute,
|
||||
type HostStackNavigationState
|
||||
} from './host-stack-navigation'
|
||||
|
||||
const TARGET = { name: '[hostId]/tasks', params: { hostId: 'host/one' } } as const
|
||||
const OTHER_TARGET = {
|
||||
name: '[hostId]/session/[worktreeId]',
|
||||
params: { hostId: 'host/one', worktreeId: 'repo::/tmp/wt' }
|
||||
} as const
|
||||
|
||||
// Removal is modeled for real: a no-op unsubscribe would let `setState` keep
|
||||
// calling a canceled listener, testing the `active` guard instead of teardown.
|
||||
function navigationHarness(initialState: HostStackNavigationState) {
|
||||
const stateListeners = new Set<() => void>()
|
||||
let state = initialState
|
||||
const unsubscribe = vi.fn()
|
||||
const navigation = {
|
||||
addListener: vi.fn((_event: 'state', listener: () => void) => {
|
||||
stateListeners.add(listener)
|
||||
return () => {
|
||||
unsubscribe()
|
||||
stateListeners.delete(listener)
|
||||
}
|
||||
}),
|
||||
dispatch: vi.fn(),
|
||||
getState: () => state
|
||||
}
|
||||
return {
|
||||
navigation,
|
||||
unsubscribe,
|
||||
listenerCount: () => stateListeners.size,
|
||||
setState(nextState: HostStackNavigationState) {
|
||||
state = nextState
|
||||
for (const listener of stateListeners) {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function committedHostState(hostIdParam: string): HostStackNavigationState {
|
||||
return {
|
||||
index: 0,
|
||||
routes: [
|
||||
{
|
||||
name: 'h',
|
||||
state: {
|
||||
key: '/h',
|
||||
index: 0,
|
||||
routes: [{ key: 'host-index', name: '[hostId]/index', params: { hostId: hostIdParam } }]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
describe('host stack navigation', () => {
|
||||
it('matches a host committed as the encoded segment it was pushed as', () => {
|
||||
const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] })
|
||||
const push = vi.fn()
|
||||
|
||||
navigateToHostStackRoute(harness.navigation, { push }, 'host/one', TARGET)
|
||||
expect(push).toHaveBeenCalledWith(hostStackHostRoute('host/one'))
|
||||
expect(harness.navigation.dispatch).not.toHaveBeenCalled()
|
||||
|
||||
harness.setState(committedHostState(encodeURIComponent('host/one')))
|
||||
|
||||
expect(harness.navigation.dispatch).toHaveBeenCalledTimes(1)
|
||||
expect(harness.navigation.dispatch).toHaveBeenCalledWith({
|
||||
type: 'REPLACE',
|
||||
target: '/h',
|
||||
source: 'host-index',
|
||||
payload: TARGET
|
||||
})
|
||||
})
|
||||
|
||||
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)
|
||||
harness.setState(committedHostState('100%'))
|
||||
|
||||
expect(harness.navigation.dispatch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stops listening and never replaces once canceled', () => {
|
||||
const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] })
|
||||
|
||||
const controller = navigateToHostStackRoute(
|
||||
harness.navigation,
|
||||
{ push: vi.fn() },
|
||||
'host/one',
|
||||
TARGET
|
||||
)
|
||||
expect(harness.listenerCount()).toBe(1)
|
||||
controller.cancel()
|
||||
|
||||
expect(harness.unsubscribe).toHaveBeenCalledTimes(1)
|
||||
expect(harness.listenerCount()).toBe(0)
|
||||
expect(controller.isActive()).toBe(false)
|
||||
|
||||
harness.setState(committedHostState('host/one'))
|
||||
expect(harness.navigation.dispatch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
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 pending = coordinateHostStackNavigation(
|
||||
null,
|
||||
harness.navigation,
|
||||
router,
|
||||
'host/one',
|
||||
TARGET
|
||||
)
|
||||
const retargeted = coordinateHostStackNavigation(
|
||||
pending,
|
||||
harness.navigation,
|
||||
router,
|
||||
'host/one',
|
||||
OTHER_TARGET
|
||||
)
|
||||
|
||||
expect(retargeted).toBe(pending)
|
||||
expect(push).toHaveBeenCalledTimes(1)
|
||||
expect(harness.listenerCount()).toBe(1)
|
||||
|
||||
harness.setState(committedHostState('host/one'))
|
||||
|
||||
expect(harness.navigation.dispatch).toHaveBeenCalledTimes(1)
|
||||
expect(harness.navigation.dispatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ payload: OTHER_TARGET })
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,171 @@
|
||||
export type HostStackNavigationState = Readonly<{
|
||||
key?: string
|
||||
index: number
|
||||
routes: readonly HostStackNavigationRoute[]
|
||||
}>
|
||||
|
||||
export type HostStackNavigationRoute = Readonly<{
|
||||
key?: string
|
||||
name: string
|
||||
params?: Readonly<{ hostId?: unknown }>
|
||||
state?: HostStackNavigationState
|
||||
}>
|
||||
|
||||
/** A screen inside app/h/_layout.tsx plus the params it needs, e.g.
|
||||
* `{ name: '[hostId]/session/[worktreeId]', params: { hostId, worktreeId } }`. */
|
||||
export type HostStackRouteTarget = Readonly<{
|
||||
name: string
|
||||
params: Readonly<Record<string, string>>
|
||||
}>
|
||||
|
||||
export type HostStackReplaceAction = Readonly<{
|
||||
type: 'REPLACE'
|
||||
target: string
|
||||
source: string
|
||||
payload: HostStackRouteTarget
|
||||
}>
|
||||
|
||||
export type HostStackRootNavigation = {
|
||||
addListener: (event: 'state', listener: () => void) => () => void
|
||||
dispatch: (action: HostStackReplaceAction) => void
|
||||
getState: () => HostStackNavigationState
|
||||
}
|
||||
|
||||
export type HostStackHostRoute = `/h/${string}`
|
||||
|
||||
export type HostStackRouter = {
|
||||
push: (route: HostStackHostRoute) => void
|
||||
}
|
||||
|
||||
export type HostStackNavigationController = Readonly<{
|
||||
cancel: () => void
|
||||
isActive: () => boolean
|
||||
retarget: (target: HostStackRouteTarget) => void
|
||||
}>
|
||||
|
||||
export type PendingHostStackNavigation = Readonly<{
|
||||
hostId: string
|
||||
controller: HostStackNavigationController
|
||||
}>
|
||||
|
||||
export function hostStackHostRoute(hostId: string): HostStackHostRoute {
|
||||
return `/h/${encodeURIComponent(hostId)}`
|
||||
}
|
||||
|
||||
// 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 {
|
||||
if (typeof param !== 'string') {
|
||||
return false
|
||||
}
|
||||
if (param === expectedHostId) {
|
||||
return true
|
||||
}
|
||||
try {
|
||||
return decodeURIComponent(param) === expectedHostId
|
||||
} catch {
|
||||
return false // Lone `%` — not our encoding.
|
||||
}
|
||||
}
|
||||
|
||||
function mountedHostStack(
|
||||
state: HostStackNavigationState,
|
||||
expectedHostId: string
|
||||
): { key: string; routeKey: string } | null {
|
||||
const hostContainer = state.routes[state.index]
|
||||
const hostState = hostContainer?.state
|
||||
const hostRoute = hostState?.routes[hostState.index]
|
||||
if (
|
||||
hostContainer?.name !== 'h' ||
|
||||
!hostState?.key ||
|
||||
hostRoute?.name !== '[hostId]/index' ||
|
||||
!hostRoute.key ||
|
||||
!hostParamMatches(hostRoute.params?.hostId, expectedHostId)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return { key: hostState.key, routeKey: hostRoute.key }
|
||||
}
|
||||
|
||||
/** Opens a deep host route by mounting `/h/[hostId]` first and replacing it once
|
||||
* its stack is committed. A cold push straight to a nested route resolves to the
|
||||
* host index without the dynamic id, which lands on a blank host screen. */
|
||||
export function navigateToHostStackRoute(
|
||||
navigation: HostStackRootNavigation,
|
||||
router: HostStackRouter,
|
||||
hostId: string,
|
||||
target: HostStackRouteTarget
|
||||
): HostStackNavigationController {
|
||||
let active = true
|
||||
let hostRouteSeen = false
|
||||
let selectedTarget = target
|
||||
let unsubscribeState = () => {}
|
||||
const dispose = () => {
|
||||
if (!active) {
|
||||
return
|
||||
}
|
||||
active = false
|
||||
unsubscribeState()
|
||||
}
|
||||
|
||||
// Why: cold Expo deep links resolve to index; target the route only after its HostStack exists.
|
||||
const onState = () => {
|
||||
if (!active) {
|
||||
return
|
||||
}
|
||||
const state = navigation.getState()
|
||||
const currentRoute = state.routes[state.index]
|
||||
if (currentRoute?.name === 'h') {
|
||||
hostRouteSeen = true
|
||||
} else if (hostRouteSeen) {
|
||||
dispose()
|
||||
return
|
||||
}
|
||||
const hostStack = mountedHostStack(state, hostId)
|
||||
if (!hostStack) {
|
||||
return
|
||||
}
|
||||
dispose()
|
||||
navigation.dispatch({
|
||||
type: 'REPLACE',
|
||||
target: hostStack.key,
|
||||
source: hostStack.routeKey,
|
||||
payload: selectedTarget
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
unsubscribeState = navigation.addListener('state', onState)
|
||||
router.push(hostStackHostRoute(hostId))
|
||||
} catch (error) {
|
||||
dispose()
|
||||
throw error
|
||||
}
|
||||
return {
|
||||
cancel: dispose,
|
||||
isActive: () => active,
|
||||
retarget: (nextTarget) => {
|
||||
if (active) {
|
||||
selectedTarget = nextTarget
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function coordinateHostStackNavigation(
|
||||
current: PendingHostStackNavigation | null,
|
||||
navigation: HostStackRootNavigation,
|
||||
router: HostStackRouter,
|
||||
hostId: string,
|
||||
target: HostStackRouteTarget
|
||||
): PendingHostStackNavigation {
|
||||
if (current?.hostId === hostId && current.controller.isActive()) {
|
||||
current.controller.retarget(target)
|
||||
return current
|
||||
}
|
||||
current?.controller.cancel()
|
||||
return {
|
||||
hostId,
|
||||
controller: navigateToHostStackRoute(navigation, router, hostId, target)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { useNavigation, useRouter } from 'expo-router'
|
||||
import {
|
||||
coordinateHostStackNavigation,
|
||||
type HostStackRootNavigation,
|
||||
type HostStackRouteTarget,
|
||||
type PendingHostStackNavigation
|
||||
} from './host-stack-navigation'
|
||||
|
||||
// Why: one root navigator means one pending transition. A per-hook ref would let a
|
||||
// Resume tap and a Tasks tap arm two independent pushes that cannot cancel each other.
|
||||
let pendingNavigation: PendingHostStackNavigation | null = null
|
||||
|
||||
export function useOpenHostStackRoute(): (hostId: string, target: HostStackRouteTarget) => void {
|
||||
const navigation = useNavigation<HostStackRootNavigation>()
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
pendingNavigation?.controller.cancel()
|
||||
pendingNavigation = null
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
return useCallback(
|
||||
(hostId, target) => {
|
||||
pendingNavigation = coordinateHostStackNavigation(
|
||||
pendingNavigation,
|
||||
navigation,
|
||||
router,
|
||||
hostId,
|
||||
target
|
||||
)
|
||||
},
|
||||
[navigation, router]
|
||||
)
|
||||
}
|
||||
@@ -282,6 +282,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
option: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
padding: spacing.sm,
|
||||
borderRadius: radii.card,
|
||||
@@ -299,8 +300,7 @@ const styles = StyleSheet.create({
|
||||
borderWidth: 1.5,
|
||||
borderColor: colors.textMuted,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginTop: 1
|
||||
justifyContent: 'center'
|
||||
},
|
||||
checkCircle: {
|
||||
borderRadius: 9
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { radii, spacing } from '../theme/mobile-theme'
|
||||
import { MobileNativeChatComposer } from './MobileNativeChatComposer'
|
||||
|
||||
vi.mock('react-native', async () => {
|
||||
@@ -8,6 +9,7 @@ vi.mock('react-native', async () => {
|
||||
return {
|
||||
ActivityIndicator: 'ActivityIndicator',
|
||||
Image: 'Image',
|
||||
Keyboard: { dismiss: vi.fn() },
|
||||
Pressable: 'Pressable',
|
||||
ScrollView: ({ children, ...props }: { children?: unknown }) =>
|
||||
React.createElement('ScrollView', props, children),
|
||||
@@ -23,12 +25,24 @@ vi.mock('react-native', async () => {
|
||||
|
||||
vi.mock('lucide-react-native', () => ({
|
||||
ArrowUp: 'ArrowUp',
|
||||
Check: 'Check',
|
||||
ChevronDown: 'ChevronDown',
|
||||
ChevronLeft: 'ChevronLeft',
|
||||
ChevronRight: 'ChevronRight',
|
||||
ImagePlus: 'ImagePlus',
|
||||
Mic: 'Mic',
|
||||
Square: 'Square',
|
||||
X: 'X'
|
||||
}))
|
||||
|
||||
vi.mock('../components/BottomDrawer', async () => {
|
||||
const React = await import('react')
|
||||
return {
|
||||
BottomDrawer: ({ visible, children }: { visible: boolean; children?: unknown }) =>
|
||||
visible ? React.createElement('BottomDrawer', { visible }, children) : null
|
||||
}
|
||||
})
|
||||
|
||||
function suppressRendererWarning(): () => void {
|
||||
const original = console.error
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
@@ -90,10 +104,149 @@ describe('MobileNativeChatComposer', () => {
|
||||
|
||||
await act(async () => sendButton().props.onPress())
|
||||
|
||||
expect(onSend).toHaveBeenCalledWith('hello')
|
||||
expect(onSend).toHaveBeenCalledWith(' hello')
|
||||
expect(onChangeText).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stacks the input above the composer action row', async () => {
|
||||
await render(vi.fn().mockResolvedValue(true), vi.fn())
|
||||
|
||||
const composer = renderer!.root.findByProps({ testID: 'native-chat-composer' })
|
||||
const inset = renderer!.root.findByProps({ testID: 'native-chat-composer-inset' })
|
||||
const actions = renderer!.root.findByProps({ testID: 'native-chat-composer-actions' })
|
||||
expect(composer.findAllByType('TextInput')).toHaveLength(1)
|
||||
expect(composer.children[1]).toBe(actions)
|
||||
expect(inset.props.style).toMatchObject({
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingTop: spacing.sm,
|
||||
paddingBottom: spacing.md
|
||||
})
|
||||
expect(composer.props.style).toMatchObject({
|
||||
borderWidth: 1,
|
||||
borderRadius: radii.card,
|
||||
overflow: 'hidden'
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves leading whitespace so prose is not turned into a slash command', async () => {
|
||||
const onSend = vi.fn().mockResolvedValue(true)
|
||||
const restore = suppressRendererWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(MobileNativeChatComposer, {
|
||||
value: ' /clear is prose ',
|
||||
onChangeText: vi.fn(),
|
||||
onSend
|
||||
})
|
||||
)
|
||||
})
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
await act(async () => sendButton().props.onPress())
|
||||
expect(onSend).toHaveBeenCalledWith(' /clear is prose')
|
||||
})
|
||||
|
||||
it('locks the option pickers while a composer send is in flight', async () => {
|
||||
// The reverse of the test below. The host spaces a send's body and its Enter
|
||||
// ~500ms apart, so an apply tapped inside that window would be submitted as
|
||||
// part of the user's prompt instead of running as its own command.
|
||||
let releaseSend: ((accepted: boolean) => void) | undefined
|
||||
const onSend = vi.fn(
|
||||
() =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
releaseSend = resolve
|
||||
})
|
||||
)
|
||||
const controller = {
|
||||
snapshot: [
|
||||
{
|
||||
id: 'model',
|
||||
label: 'Model',
|
||||
category: 'model' as const,
|
||||
kind: {
|
||||
type: 'select' as const,
|
||||
choices: [
|
||||
{ value: 'sonnet', label: 'Sonnet 5' },
|
||||
{ value: 'opus', label: 'Opus 4.8' }
|
||||
]
|
||||
},
|
||||
valueSource: 'unknown' as const,
|
||||
settable: true
|
||||
}
|
||||
],
|
||||
pendingId: null,
|
||||
setOption: vi.fn(),
|
||||
invokeAction: vi.fn(),
|
||||
recordCommand: vi.fn()
|
||||
}
|
||||
const restore = suppressRendererWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(MobileNativeChatComposer, {
|
||||
value: 'run the tests',
|
||||
onChangeText: vi.fn(),
|
||||
onSend,
|
||||
sessionOptions: { isWorking: false, controller }
|
||||
})
|
||||
)
|
||||
})
|
||||
const modelPill = (): { props: { accessibilityState: { disabled: boolean } } } =>
|
||||
renderer!.root.find(
|
||||
(node) => node.type === 'Pressable' && node.props.accessibilityLabel === 'Model, Model'
|
||||
) as { props: { accessibilityState: { disabled: boolean } } }
|
||||
expect(modelPill().props.accessibilityState).toMatchObject({ disabled: false })
|
||||
// Start the send but don't await it — it stays in flight on purpose.
|
||||
let pressed!: Promise<void>
|
||||
await act(async () => {
|
||||
pressed = sendButton().props.onPress()
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(onSend).toHaveBeenCalled()
|
||||
expect(modelPill().props.accessibilityState).toMatchObject({ disabled: true })
|
||||
await act(async () => {
|
||||
releaseSend?.(true)
|
||||
await pressed
|
||||
})
|
||||
expect(modelPill().props.accessibilityState).toMatchObject({ disabled: false })
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
})
|
||||
|
||||
it('blocks composer submission while a session-option command is pending', async () => {
|
||||
const onSend = vi.fn().mockResolvedValue(true)
|
||||
const restore = suppressRendererWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(MobileNativeChatComposer, {
|
||||
value: 'hello',
|
||||
onChangeText: vi.fn(),
|
||||
onSend,
|
||||
sessionOptions: {
|
||||
isWorking: false,
|
||||
controller: {
|
||||
snapshot: [],
|
||||
pendingId: 'model',
|
||||
setOption: vi.fn(),
|
||||
invokeAction: vi.fn(),
|
||||
recordCommand: vi.fn()
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
})
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
expect(sendButton().props).toMatchObject({ disabled: true })
|
||||
await act(async () => sendButton().props.onPress())
|
||||
expect(onSend).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the draft when the send is rejected', async () => {
|
||||
const onChangeText = vi.fn()
|
||||
const onSend = vi.fn().mockResolvedValue(false)
|
||||
@@ -101,7 +254,7 @@ describe('MobileNativeChatComposer', () => {
|
||||
|
||||
await act(async () => sendButton().props.onPress())
|
||||
|
||||
expect(onSend).toHaveBeenCalledWith('hello')
|
||||
expect(onSend).toHaveBeenCalledWith(' hello')
|
||||
expect(onChangeText).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -202,7 +355,8 @@ describe('MobileNativeChatComposer', () => {
|
||||
createElement(MobileNativeChatComposer, {
|
||||
value: '/c',
|
||||
onChangeText: vi.fn(),
|
||||
onSend: vi.fn().mockResolvedValue(true)
|
||||
onSend: vi.fn().mockResolvedValue(true),
|
||||
agent: 'claude'
|
||||
})
|
||||
)
|
||||
})
|
||||
@@ -235,6 +389,36 @@ describe('MobileNativeChatComposer', () => {
|
||||
expect(input().props.selection).toBeUndefined()
|
||||
})
|
||||
|
||||
it('serves the active agent’s shared command catalog with descriptions', async () => {
|
||||
const restore = suppressRendererWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(MobileNativeChatComposer, {
|
||||
value: '/',
|
||||
onChangeText: vi.fn(),
|
||||
onSend: vi.fn().mockResolvedValue(true),
|
||||
agent: 'codex'
|
||||
})
|
||||
)
|
||||
})
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
const input = renderer!.root.find((node) => node.type === 'TextInput') as {
|
||||
props: { onSelectionChange: (e: { nativeEvent: { selection: { end: number } } }) => void }
|
||||
}
|
||||
await act(async () => input.props.onSelectionChange({ nativeEvent: { selection: { end: 1 } } }))
|
||||
const texts = renderer!.root
|
||||
.findAll((node) => node.type === 'Text')
|
||||
.map((node) => (node.props as { children?: unknown }).children)
|
||||
// Codex-only commands from the shared catalog, with their description rows —
|
||||
// and none of the old hardcoded provider-agnostic list's phantom entries.
|
||||
expect(texts).toContain('/permissions')
|
||||
expect(texts).toContain('Choose what Codex is allowed to do')
|
||||
expect(texts).not.toContain('/cost')
|
||||
})
|
||||
|
||||
it('wires the mic for hold vs toggle dictation like the terminal composer', async () => {
|
||||
const onMicPress = vi.fn()
|
||||
const onMicPressIn = vi.fn()
|
||||
|
||||
@@ -5,32 +5,29 @@ import {
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
View
|
||||
} from 'react-native'
|
||||
import { ArrowUp, ImagePlus, Mic, Square, X } from 'lucide-react-native'
|
||||
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
|
||||
import { getVerifiedNativeChatCommands } from '../../../src/shared/native-chat-agent-profiles'
|
||||
import {
|
||||
applyAutocomplete,
|
||||
detectAutocompleteTrigger,
|
||||
rankSlashCommandSuggestions,
|
||||
rankSuggestions
|
||||
} from './mobile-native-chat-autocomplete'
|
||||
import {
|
||||
composerSuggestionInsertText,
|
||||
MobileNativeChatComposerSuggestions,
|
||||
type ComposerSuggestion
|
||||
} from './MobileNativeChatComposerSuggestions'
|
||||
import {
|
||||
MobileNativeChatSessionOptionPickers,
|
||||
type MobileNativeChatSessionOptionPickersProps
|
||||
} from './MobileNativeChatSessionOptionPickers'
|
||||
import type { PendingNativeChatImage } from './mobile-native-chat-image-attachment'
|
||||
|
||||
// Common agent slash commands offered as autocomplete; sending them is just text
|
||||
// to the agent's terminal, so the set is intentionally provider-agnostic.
|
||||
const SLASH_COMMANDS = [
|
||||
'/clear',
|
||||
'/compact',
|
||||
'/review',
|
||||
'/model',
|
||||
'/help',
|
||||
'/init',
|
||||
'/cost',
|
||||
'/diff'
|
||||
]
|
||||
|
||||
const NO_FILE_PATHS: string[] = []
|
||||
const NO_ATTACHMENTS: PendingNativeChatImage[] = []
|
||||
|
||||
@@ -39,6 +36,11 @@ type Props = {
|
||||
value: string
|
||||
onChangeText: (text: string) => void
|
||||
onSend: (text: string) => Promise<boolean>
|
||||
/** Active tab's agent — the slash autocomplete serves its command catalog. */
|
||||
agent?: string | null
|
||||
/** Model/session-option pickers shown in the composer action row; null when
|
||||
* the agent has no session-option catalog. */
|
||||
sessionOptions?: MobileNativeChatSessionOptionPickersProps | null
|
||||
onAttachImage?: () => void
|
||||
/** Images picked-and-uploaded but not yet sent — shown as removable thumbnails
|
||||
* and ridden along on the next send (desktop native-chat parity). */
|
||||
@@ -61,6 +63,8 @@ export function MobileNativeChatComposer({
|
||||
value,
|
||||
onChangeText,
|
||||
onSend,
|
||||
agent,
|
||||
sessionOptions,
|
||||
onAttachImage,
|
||||
attachments = NO_ATTACHMENTS,
|
||||
onRemoveAttachment,
|
||||
@@ -85,21 +89,36 @@ export function MobileNativeChatComposer({
|
||||
const sendingRef = useRef(false)
|
||||
const [sending, setSending] = useState(false)
|
||||
const trimmed = value.trim()
|
||||
const sessionOptionDispatching = sessionOptions?.controller.pendingId != null
|
||||
// An attached image alone is a valid send (desktop parity), so the image rides
|
||||
// along even when the user sends no accompanying text.
|
||||
const canSend =
|
||||
(trimmed.length > 0 || attachments.length > 0) && !disabled && !sending && !isAttaching
|
||||
(trimmed.length > 0 || attachments.length > 0) &&
|
||||
!disabled &&
|
||||
!sending &&
|
||||
!isAttaching &&
|
||||
!sessionOptionDispatching
|
||||
|
||||
const trigger = useMemo(() => detectAutocompleteTrigger(value, cursor), [value, cursor])
|
||||
const suggestions = useMemo(() => {
|
||||
const suggestions = useMemo<ComposerSuggestion[]>(() => {
|
||||
if (!trigger) {
|
||||
return []
|
||||
}
|
||||
if (trigger.kind === 'slash') {
|
||||
return rankSuggestions(SLASH_COMMANDS, trigger.query)
|
||||
const commands = agent ? getVerifiedNativeChatCommands(agent) : []
|
||||
// Why: Codex's catalog is 45 commands and this list is a plain ScrollView
|
||||
// (~5 rows visible), so an uncapped `/` would mount every row and
|
||||
// re-reconcile them on each streaming tick right above the transcript.
|
||||
return rankSlashCommandSuggestions(commands, trigger.query, 12).map((command) => ({
|
||||
kind: 'command' as const,
|
||||
command
|
||||
}))
|
||||
}
|
||||
return rankSuggestions(filePaths, trigger.query).map((p) => `@${p}`)
|
||||
}, [trigger, filePaths])
|
||||
return rankSuggestions(filePaths, trigger.query).map((path) => ({
|
||||
kind: 'file' as const,
|
||||
path
|
||||
}))
|
||||
}, [trigger, filePaths, agent])
|
||||
|
||||
useEffect(() => {
|
||||
if (trigger?.kind === 'file') {
|
||||
@@ -111,11 +130,15 @@ export function MobileNativeChatComposer({
|
||||
onChangeText(next)
|
||||
}
|
||||
|
||||
const pickSuggestion = (suggestion: string): void => {
|
||||
const pickSuggestion = (suggestion: ComposerSuggestion): void => {
|
||||
if (!trigger) {
|
||||
return
|
||||
}
|
||||
const { text: nextText, cursor: nextCursor } = applyAutocomplete(value, trigger, suggestion)
|
||||
const { text: nextText, cursor: nextCursor } = applyAutocomplete(
|
||||
value,
|
||||
trigger,
|
||||
composerSuggestionInsertText(suggestion)
|
||||
)
|
||||
onChangeText(nextText)
|
||||
setCursor(nextCursor)
|
||||
setPendingSelection({ start: nextCursor, end: nextCursor })
|
||||
@@ -128,7 +151,7 @@ export function MobileNativeChatComposer({
|
||||
sendingRef.current = true
|
||||
setSending(true)
|
||||
try {
|
||||
const accepted = await onSend(trimmed)
|
||||
const accepted = await onSend(value.trimEnd())
|
||||
if (accepted) {
|
||||
setCursor(0)
|
||||
}
|
||||
@@ -141,21 +164,7 @@ export function MobileNativeChatComposer({
|
||||
return (
|
||||
<View>
|
||||
{suggestions.length > 0 ? (
|
||||
<View style={styles.suggestions}>
|
||||
<ScrollView keyboardShouldPersistTaps="always" style={styles.suggestionScroll}>
|
||||
{suggestions.map((s) => (
|
||||
<Pressable
|
||||
key={s}
|
||||
style={({ pressed }) => [styles.suggestion, pressed && styles.suggestionPressed]}
|
||||
onPress={() => pickSuggestion(s)}
|
||||
>
|
||||
<Text style={styles.suggestionText} numberOfLines={1}>
|
||||
{s}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
<MobileNativeChatComposerSuggestions suggestions={suggestions} onPick={pickSuggestion} />
|
||||
) : null}
|
||||
{attachments.length > 0 ? (
|
||||
<ScrollView
|
||||
@@ -186,102 +195,95 @@ export function MobileNativeChatComposer({
|
||||
))}
|
||||
</ScrollView>
|
||||
) : null}
|
||||
<View style={styles.bar}>
|
||||
{onAttachImage ? (
|
||||
<Pressable
|
||||
accessibilityLabel="Attach image"
|
||||
style={({ pressed }) => [styles.iconButton, pressed && styles.pressed]}
|
||||
onPress={onAttachImage}
|
||||
disabled={isAttaching || disabled}
|
||||
>
|
||||
{isAttaching ? (
|
||||
<ActivityIndicator size="small" color={colors.textSecondary} />
|
||||
) : (
|
||||
<ImagePlus size={20} color={colors.textSecondary} strokeWidth={2} />
|
||||
)}
|
||||
</Pressable>
|
||||
) : null}
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={value}
|
||||
onChangeText={handleChange}
|
||||
// Controlled only transiently right after an autocomplete insert.
|
||||
selection={pendingSelection ?? undefined}
|
||||
onSelectionChange={(e) => {
|
||||
setCursor(e.nativeEvent.selection.end)
|
||||
setPendingSelection(null)
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={colors.textMuted}
|
||||
selectionColor={colors.accentBlue}
|
||||
multiline
|
||||
// Why: never revoke `editable` — iOS resigns first responder on a focused
|
||||
// field, so a transient lock would yank the keyboard mid-typing (#10681).
|
||||
// The lock gates sending; the draft survives and rides the next send.
|
||||
textAlignVertical="top"
|
||||
/>
|
||||
{onMicPress ? (
|
||||
<Pressable
|
||||
accessibilityLabel={micActive ? 'Stop dictation' : 'Dictate'}
|
||||
style={({ pressed }) => [styles.iconButton, pressed && styles.pressed]}
|
||||
// Hold mode is walkie-talkie (press-in/out); toggle mode taps.
|
||||
onPress={dictationMode === 'hold' ? undefined : onMicPress}
|
||||
onPressIn={dictationMode === 'hold' ? onMicPressIn : undefined}
|
||||
onPressOut={dictationMode === 'hold' ? onMicPressOut : undefined}
|
||||
disabled={disabled}
|
||||
>
|
||||
{micActive ? (
|
||||
<Square
|
||||
size={18}
|
||||
color={colors.statusRed}
|
||||
strokeWidth={2.4}
|
||||
fill={colors.statusRed}
|
||||
<View style={styles.composerInset} testID="native-chat-composer-inset">
|
||||
<View style={styles.bar} testID="native-chat-composer">
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={value}
|
||||
onChangeText={handleChange}
|
||||
// Controlled only transiently right after an autocomplete insert.
|
||||
selection={pendingSelection ?? undefined}
|
||||
onSelectionChange={(e) => {
|
||||
setCursor(e.nativeEvent.selection.end)
|
||||
setPendingSelection(null)
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={colors.textMuted}
|
||||
selectionColor={colors.accentBlue}
|
||||
multiline
|
||||
// Why: never revoke `editable` — iOS resigns first responder on a focused
|
||||
// field, so a transient lock would yank the keyboard mid-typing (#10681).
|
||||
// The lock gates sending; the draft survives and rides the next send.
|
||||
textAlignVertical="top"
|
||||
/>
|
||||
<View style={styles.actionRow} testID="native-chat-composer-actions">
|
||||
{onAttachImage ? (
|
||||
<Pressable
|
||||
accessibilityLabel="Attach image"
|
||||
style={({ pressed }) => [styles.iconButton, pressed && styles.pressed]}
|
||||
onPress={onAttachImage}
|
||||
disabled={isAttaching || disabled}
|
||||
>
|
||||
{isAttaching ? (
|
||||
<ActivityIndicator size="small" color={colors.textSecondary} />
|
||||
) : (
|
||||
<ImagePlus size={20} color={colors.textSecondary} strokeWidth={2} />
|
||||
)}
|
||||
</Pressable>
|
||||
) : null}
|
||||
{sessionOptions ? (
|
||||
<MobileNativeChatSessionOptionPickers
|
||||
{...sessionOptions}
|
||||
sendInFlight={sending || isAttaching}
|
||||
/>
|
||||
) : (
|
||||
<Mic size={20} color={colors.textSecondary} strokeWidth={2} />
|
||||
)}
|
||||
</Pressable>
|
||||
) : null}
|
||||
<Pressable
|
||||
accessibilityLabel="Send message"
|
||||
style={({ pressed }) => [
|
||||
styles.sendButton,
|
||||
!canSend && styles.sendButtonDisabled,
|
||||
pressed && canSend && styles.pressed
|
||||
]}
|
||||
onPress={handleSend}
|
||||
disabled={!canSend}
|
||||
>
|
||||
<ArrowUp size={20} color={canSend ? colors.bgBase : colors.textMuted} strokeWidth={2.6} />
|
||||
</Pressable>
|
||||
) : null}
|
||||
<View style={styles.actionSpacer} />
|
||||
{onMicPress ? (
|
||||
<Pressable
|
||||
accessibilityLabel={micActive ? 'Stop dictation' : 'Dictate'}
|
||||
style={({ pressed }) => [styles.iconButton, pressed && styles.pressed]}
|
||||
// Hold mode is walkie-talkie (press-in/out); toggle mode taps.
|
||||
onPress={dictationMode === 'hold' ? undefined : onMicPress}
|
||||
onPressIn={dictationMode === 'hold' ? onMicPressIn : undefined}
|
||||
onPressOut={dictationMode === 'hold' ? onMicPressOut : undefined}
|
||||
disabled={disabled}
|
||||
>
|
||||
{micActive ? (
|
||||
<Square
|
||||
size={18}
|
||||
color={colors.statusRed}
|
||||
strokeWidth={2.4}
|
||||
fill={colors.statusRed}
|
||||
/>
|
||||
) : (
|
||||
<Mic size={20} color={colors.textSecondary} strokeWidth={2} />
|
||||
)}
|
||||
</Pressable>
|
||||
) : null}
|
||||
<Pressable
|
||||
accessibilityLabel="Send message"
|
||||
style={({ pressed }) => [
|
||||
styles.sendButton,
|
||||
!canSend && styles.sendButtonDisabled,
|
||||
pressed && canSend && styles.pressed
|
||||
]}
|
||||
onPress={handleSend}
|
||||
disabled={!canSend}
|
||||
>
|
||||
<ArrowUp
|
||||
size={20}
|
||||
color={canSend ? colors.bgBase : colors.textMuted}
|
||||
strokeWidth={2.6}
|
||||
/>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
suggestions: {
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
borderTopColor: colors.borderSubtle,
|
||||
backgroundColor: colors.bgPanel
|
||||
},
|
||||
suggestionScroll: {
|
||||
maxHeight: 180
|
||||
},
|
||||
suggestion: {
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: colors.borderSubtle
|
||||
},
|
||||
suggestionPressed: {
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
suggestionText: {
|
||||
color: colors.textPrimary,
|
||||
fontFamily: typography.monoFamily,
|
||||
fontSize: typography.metaSize
|
||||
},
|
||||
attachmentStrip: {
|
||||
maxHeight: 76,
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
@@ -321,18 +323,32 @@ const styles = StyleSheet.create({
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.borderSubtle
|
||||
},
|
||||
composerInset: {
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingTop: spacing.sm,
|
||||
paddingBottom: spacing.md
|
||||
},
|
||||
bar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-end',
|
||||
gap: spacing.sm,
|
||||
gap: spacing.xs,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
borderTopColor: colors.borderSubtle,
|
||||
backgroundColor: colors.bgPanel
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.borderSubtle,
|
||||
borderRadius: radii.card,
|
||||
backgroundColor: colors.bgPanel,
|
||||
overflow: 'hidden'
|
||||
},
|
||||
actionRow: {
|
||||
minHeight: 40,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm
|
||||
},
|
||||
actionSpacer: {
|
||||
flex: 1
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
width: '100%',
|
||||
maxHeight: 140,
|
||||
minHeight: 40,
|
||||
color: colors.textPrimary,
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'
|
||||
import { colors, spacing, typography } from '../theme/mobile-theme'
|
||||
import type { SlashCommandSuggestion } from '../../../src/shared/native-chat-slash-commands'
|
||||
|
||||
/** One row of the composer autocomplete: an agent slash command (with its
|
||||
* catalog description, desktop parity) or a worktree file path. */
|
||||
export type ComposerSuggestion =
|
||||
| { kind: 'command'; command: SlashCommandSuggestion }
|
||||
| { kind: 'file'; path: string }
|
||||
|
||||
export function composerSuggestionKey(suggestion: ComposerSuggestion): string {
|
||||
return suggestion.kind === 'command'
|
||||
? `command:${suggestion.command.name}`
|
||||
: `file:${suggestion.path}`
|
||||
}
|
||||
|
||||
/** The text the suggestion inserts at the trigger span. */
|
||||
export function composerSuggestionInsertText(suggestion: ComposerSuggestion): string {
|
||||
return suggestion.kind === 'command' ? `/${suggestion.command.name}` : `@${suggestion.path}`
|
||||
}
|
||||
|
||||
export function MobileNativeChatComposerSuggestions({
|
||||
suggestions,
|
||||
onPick
|
||||
}: {
|
||||
suggestions: readonly ComposerSuggestion[]
|
||||
onPick: (suggestion: ComposerSuggestion) => void
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<View style={styles.suggestions}>
|
||||
<ScrollView keyboardShouldPersistTaps="always" style={styles.suggestionScroll}>
|
||||
{suggestions.map((suggestion) => (
|
||||
<Pressable
|
||||
key={composerSuggestionKey(suggestion)}
|
||||
accessibilityRole="button"
|
||||
style={({ pressed }) => [styles.suggestion, pressed && styles.suggestionPressed]}
|
||||
onPress={() => onPick(suggestion)}
|
||||
>
|
||||
<Text style={styles.suggestionText} numberOfLines={1}>
|
||||
{composerSuggestionInsertText(suggestion)}
|
||||
</Text>
|
||||
{suggestion.kind === 'command' && suggestion.command.description ? (
|
||||
<Text style={styles.suggestionDescription} numberOfLines={1}>
|
||||
{suggestion.command.description}
|
||||
</Text>
|
||||
) : null}
|
||||
</Pressable>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
suggestions: {
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
borderTopColor: colors.borderSubtle,
|
||||
backgroundColor: colors.bgPanel
|
||||
},
|
||||
suggestionScroll: {
|
||||
maxHeight: 220
|
||||
},
|
||||
suggestion: {
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: colors.borderSubtle,
|
||||
gap: 1
|
||||
},
|
||||
suggestionPressed: {
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
suggestionText: {
|
||||
color: colors.textPrimary,
|
||||
fontFamily: typography.monoFamily,
|
||||
fontSize: typography.metaSize
|
||||
},
|
||||
suggestionDescription: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.metaSize
|
||||
}
|
||||
})
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import { MAX_TOOL_DETAIL_LENGTH } from './mobile-native-chat-tool-summary'
|
||||
|
||||
vi.mock('react-native', async () => {
|
||||
const React = await import('react')
|
||||
@@ -30,7 +31,11 @@ function userMessage(blocks: NativeChatMessage['blocks']): NativeChatMessage {
|
||||
return { id: 'u1', role: 'user', blocks, timestamp: null, source: 'transcript' }
|
||||
}
|
||||
|
||||
describe('MobileNativeChatMessage image-ref rendering', () => {
|
||||
function toolMessage(blocks: NativeChatMessage['blocks']): NativeChatMessage {
|
||||
return { id: 'a1', role: 'assistant', blocks, timestamp: null, source: 'transcript' }
|
||||
}
|
||||
|
||||
describe('MobileNativeChatMessage', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -41,7 +46,10 @@ describe('MobileNativeChatMessage image-ref rendering', () => {
|
||||
renderer = null
|
||||
})
|
||||
|
||||
function render(message: NativeChatMessage): ReactTestRenderer {
|
||||
function render(
|
||||
message: NativeChatMessage,
|
||||
props: { toolsExpanded?: boolean } = {}
|
||||
): ReactTestRenderer {
|
||||
const original = console.error
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation((...a) => {
|
||||
if (typeof a[0] === 'string' && a[0].includes('react-test-renderer is deprecated')) {
|
||||
@@ -51,7 +59,7 @@ describe('MobileNativeChatMessage image-ref rendering', () => {
|
||||
})
|
||||
try {
|
||||
act(() => {
|
||||
renderer = create(createElement(MobileNativeChatMessage, { message }))
|
||||
renderer = create(createElement(MobileNativeChatMessage, { message, ...props }))
|
||||
})
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
@@ -59,6 +67,9 @@ describe('MobileNativeChatMessage image-ref rendering', () => {
|
||||
return renderer!
|
||||
}
|
||||
|
||||
const textIn = (node: ReactTestInstance): string[] =>
|
||||
node.findAllByType('Text' as never).map((text) => String(text.children.join('')))
|
||||
|
||||
it('renders a loadable preview URI as an image thumbnail', () => {
|
||||
const tree = render(userMessage([{ type: 'image-ref', url: 'file:///a.jpg', alt: 'a photo' }]))
|
||||
const image = tree.root.findByType('Image' as never)
|
||||
@@ -84,4 +95,75 @@ describe('MobileNativeChatMessage image-ref rendering', () => {
|
||||
.map((node) => String(node.children.join('')))
|
||||
expect(texts.some((text) => text.includes('/tmp/host.png'))).toBe(true)
|
||||
})
|
||||
|
||||
it('labels a tool row with the target path instead of raw input JSON', () => {
|
||||
const tree = render(
|
||||
toolMessage([{ type: 'tool-call', name: 'Read', input: { file_path: 'src/index.ts' } }]),
|
||||
{ toolsExpanded: true }
|
||||
)
|
||||
const texts = textIn(tree.root)
|
||||
expect(texts).toContain('src/index.ts')
|
||||
expect(texts.some((text) => text.includes('"file_path":"src/index.ts"'))).toBe(false)
|
||||
})
|
||||
|
||||
it('bounds expanded diff-less tool input before native text layout', () => {
|
||||
const tree = render(
|
||||
toolMessage([
|
||||
{ type: 'tool-call', name: 'CustomTool', input: { payload: 'x'.repeat(100_000) } }
|
||||
]),
|
||||
{ toolsExpanded: true }
|
||||
)
|
||||
const detail = textIn(tree.root).find((text) => text.startsWith('{\n'))
|
||||
expect(detail).toHaveLength(MAX_TOOL_DETAIL_LENGTH + 1)
|
||||
expect(detail?.endsWith('…')).toBe(true)
|
||||
})
|
||||
|
||||
it('expands formatted detail for a collapsed JSON-string tool input', () => {
|
||||
const tree = render(
|
||||
toolMessage([
|
||||
{
|
||||
type: 'tool-call',
|
||||
name: 'CustomTool',
|
||||
input: '{"cmd":"git status","description":"Inspect changes"}'
|
||||
}
|
||||
])
|
||||
)
|
||||
const pressableWith = (label: string): ReactTestInstance =>
|
||||
tree.root.findAllByType('Pressable' as never).find((node) => textIn(node).includes(label))!
|
||||
|
||||
act(() => pressableWith('1×').props.onPress())
|
||||
// The row label is the command, and the detail stays closed until tapped.
|
||||
expect(textIn(tree.root)).toContain('git status')
|
||||
expect(textIn(tree.root).some((text) => text.startsWith('{\n'))).toBe(false)
|
||||
|
||||
act(() => pressableWith('CustomTool').props.onPress())
|
||||
expect(textIn(tree.root)).toContain(
|
||||
'{\n "cmd": "git status",\n "description": "Inspect changes"\n}'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not echo the row label as detail when a row has nothing to expand', () => {
|
||||
// The Tools toggle opens every row at once, bypassing the tap guard — a row
|
||||
// whose formatted input is its own label would echo itself in a panel that
|
||||
// no tap can dismiss.
|
||||
const tree = render(toolMessage([{ type: 'tool-call', name: 'ListTodos', input: '{}' }]), {
|
||||
toolsExpanded: true
|
||||
})
|
||||
expect(textIn(tree.root).filter((text) => text === '{}')).toHaveLength(1)
|
||||
// The chevron has to agree with the panel, or the row claims to be open over
|
||||
// nothing and the tap that would close it is guarded off. Only the run header
|
||||
// is open here; the row itself stays collapsed.
|
||||
expect(tree.root.findAllByType('ChevronDown' as never)).toHaveLength(1)
|
||||
expect(tree.root.findAllByType('SquareChevronRight' as never)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('does not expand a plain input that already fits in the row label', () => {
|
||||
const input = 'x'.repeat(60)
|
||||
const tree = render(toolMessage([{ type: 'tool-call', name: 'CustomTool', input }]), {
|
||||
toolsExpanded: true
|
||||
})
|
||||
expect(textIn(tree.root).filter((text) => text === input)).toHaveLength(1)
|
||||
expect(tree.root.findAllByType('ChevronDown' as never)).toHaveLength(1)
|
||||
expect(tree.root.findAllByType('SquareChevronRight' as never)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,12 +14,12 @@ import {
|
||||
} from './mobile-native-chat-blocks'
|
||||
import { diffFromText, diffFromToolCall, type DiffLine } from './mobile-native-chat-diff'
|
||||
import { isRenderableImageUri } from './mobile-native-chat-image-preview'
|
||||
import { MAX_TOOL_RESULT_CHARS, styles, TEXT_SIZE } from './mobile-native-chat-message-styles'
|
||||
import { styles, TEXT_SIZE } from './mobile-native-chat-message-styles'
|
||||
import { nativeChatMessageText } from './mobile-native-chat-message-text'
|
||||
import {
|
||||
summarizeToolInput,
|
||||
createToolInputDisplay,
|
||||
summarizeToolRun,
|
||||
toolFilePath
|
||||
truncateToolDetail
|
||||
} from './mobile-native-chat-tool-summary'
|
||||
|
||||
const MAX_VISIBLE_TOOL_PAIRS = 6
|
||||
@@ -63,11 +63,7 @@ function ResultBody({
|
||||
}
|
||||
return (
|
||||
<View style={[styles.toolResult, isError && styles.toolResultError]}>
|
||||
<Text style={styles.mono}>
|
||||
{output.length > MAX_TOOL_RESULT_CHARS
|
||||
? `${output.slice(0, MAX_TOOL_RESULT_CHARS)}…`
|
||||
: output}
|
||||
</Text>
|
||||
<Text style={styles.mono}>{truncateToolDetail(output)}</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -88,17 +84,21 @@ function ToolLine({
|
||||
const [expanded, setExpanded] = useState(defaultExpanded)
|
||||
const { call, result } = pair
|
||||
const name = call ? call.name : 'Result'
|
||||
const preview = call
|
||||
? summarizeToolInput(call.input)
|
||||
: (result?.output.split('\n')[0]?.slice(0, 80) ?? '')
|
||||
const inputDisplay = call ? createToolInputDisplay(call.input) : null
|
||||
const preview = inputDisplay?.label ?? result?.output.split('\n')[0]?.slice(0, 80) ?? ''
|
||||
// Why: collapsed tool rows are the common path; defer bounded diff parsing
|
||||
// until the user asks to reveal the detail.
|
||||
// and detail formatting until the user asks to reveal the detail.
|
||||
const callDiff = expanded && call ? diffFromToolCall(call.name, call.input, diffLineLimit) : null
|
||||
const resultDiff = expanded && result ? diffFromText(result.output, diffLineLimit) : null
|
||||
const hasDetail = callDiff !== null || result !== undefined || preview.length > 40
|
||||
const callDetail = expanded && inputDisplay && !callDiff ? inputDisplay.formatDetail() : undefined
|
||||
const hasDetail = callDiff !== null || result !== undefined || inputDisplay?.hasDetail === true
|
||||
// The group toggle opens every line at once, bypassing the tap guard, so the
|
||||
// panel has to consult it too — else a detail-less row echoes its own label
|
||||
// under itself and no tap can dismiss it.
|
||||
const showDetail = hasDetail && expanded
|
||||
// A tool that targets a file (Read/Edit/Write…) renders its preview as a
|
||||
// tappable link that opens the file, independent of the line's expand tap.
|
||||
const filePath = call ? toolFilePath(call.input) : null
|
||||
const filePath = inputDisplay?.filePath ?? null
|
||||
const openable = filePath !== null && onOpenFile !== undefined
|
||||
return (
|
||||
<View>
|
||||
@@ -107,7 +107,7 @@ function ToolLine({
|
||||
onPress={() => hasDetail && setExpanded((v) => !v)}
|
||||
hitSlop={6}
|
||||
>
|
||||
{expanded ? (
|
||||
{showDetail ? (
|
||||
<ChevronDown size={15} color={colors.textMuted} strokeWidth={2} />
|
||||
) : (
|
||||
<SquareChevronRight size={15} color={colors.textMuted} strokeWidth={2} />
|
||||
@@ -124,10 +124,10 @@ function ToolLine({
|
||||
</Text>
|
||||
) : null}
|
||||
</Pressable>
|
||||
{expanded ? (
|
||||
{showDetail ? (
|
||||
<View style={styles.toolDetail}>
|
||||
{callDiff ? <DiffView lines={callDiff} /> : null}
|
||||
{!callDiff && call && preview ? <Text style={styles.mono}>{preview}</Text> : null}
|
||||
{callDetail ? <Text style={styles.mono}>{callDetail}</Text> : null}
|
||||
{result ? (
|
||||
<ResultBody output={result.output} isError={result.isError} diff={resultDiff} />
|
||||
) : null}
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import { MobileNativeChatOverlay } from './MobileNativeChatOverlay'
|
||||
import type { MobileNativeChatController } from './use-mobile-native-chat-controller'
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
StyleSheet: { create: (styles: unknown) => styles, absoluteFillObject: {} },
|
||||
View: 'View'
|
||||
}))
|
||||
|
||||
vi.mock('./MobileNativeChatView', () => ({ MobileNativeChatView: 'ChatView' }))
|
||||
|
||||
function assistantTurn(id: string, text: string): NativeChatMessage {
|
||||
return { id, role: 'assistant', blocks: [{ type: 'text', text }], timestamp: 0, source: 'hook' }
|
||||
}
|
||||
|
||||
function suppressRendererWarning(): () => void {
|
||||
const original = console.error
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
original(...args)
|
||||
})
|
||||
return () => spy.mockRestore()
|
||||
}
|
||||
|
||||
/** One render of the route: chat visible or not, the transcript it currently
|
||||
* holds, and the agent-status stream behind it. */
|
||||
type Tick = {
|
||||
show?: boolean
|
||||
messages?: NativeChatMessage[]
|
||||
streamingText?: string
|
||||
streamLive?: boolean
|
||||
identity?: string
|
||||
}
|
||||
|
||||
function overlayElement(tick: Tick): ReturnType<typeof createElement> {
|
||||
const controller = {
|
||||
showNativeChat: tick.show ?? true,
|
||||
nativeChatSession: { messages: tick.messages ?? [], status: 'ready' },
|
||||
nativeChatAgent: 'claude',
|
||||
nativeChatAgentWorking: tick.streamLive ?? false,
|
||||
nativeChatStreamingText: tick.streamingText,
|
||||
nativeChatStreamLive: tick.streamLive ?? false,
|
||||
nativeChatStreamScopeKey: tick.identity ?? 'tab-a',
|
||||
chatPending: [],
|
||||
chatComposerText: '',
|
||||
setChatComposerText: vi.fn()
|
||||
} as unknown as MobileNativeChatController
|
||||
return createElement(MobileNativeChatOverlay, {
|
||||
controller,
|
||||
images: {} as never,
|
||||
onMicPress: vi.fn(),
|
||||
micActive: false,
|
||||
dictationMode: 'toggle',
|
||||
onMicPressIn: vi.fn(),
|
||||
onMicPressOut: vi.fn(),
|
||||
inputLockReason: null,
|
||||
sendErrorMessage: null,
|
||||
onClearSendError: vi.fn(),
|
||||
keyboardInset: 0
|
||||
})
|
||||
}
|
||||
|
||||
describe('MobileNativeChatOverlay streaming gate', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
})
|
||||
|
||||
async function render(tick: Tick): Promise<void> {
|
||||
const restore = suppressRendererWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(overlayElement(tick))
|
||||
})
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
}
|
||||
|
||||
async function update(tick: Tick): Promise<void> {
|
||||
await act(async () => {
|
||||
renderer?.update(overlayElement(tick))
|
||||
})
|
||||
}
|
||||
|
||||
/** The bubble text handed to the chat list, or `'hidden'` when chat is off. */
|
||||
function streaming(): string | null | 'hidden' {
|
||||
const views = renderer!.root.findAll((node) => node.type === 'ChatView')
|
||||
return views.length === 0 ? 'hidden' : (views[0].props.streaming as string | null)
|
||||
}
|
||||
|
||||
it('keeps streaming a reply that repeats the previous turn as a prefix', async () => {
|
||||
const prior = [assistantTurn('a1', 'The tests pass.')]
|
||||
await render({ messages: prior })
|
||||
expect(streaming()).toBeNull()
|
||||
|
||||
await update({ messages: prior, streamingText: 'The tests', streamLive: true })
|
||||
|
||||
expect(streaming()).toBe('The tests')
|
||||
})
|
||||
|
||||
it('drops the streaming bubble once the reply lands as its own turn', async () => {
|
||||
const prior = [assistantTurn('a1', 'Done.')]
|
||||
await render({ messages: prior })
|
||||
await update({ messages: prior, streamingText: 'Done.', streamLive: true })
|
||||
expect(streaming()).toBe('Done.')
|
||||
|
||||
await update({
|
||||
messages: [...prior, assistantTurn('a2', 'Done.')],
|
||||
streamingText: 'Done.',
|
||||
streamLive: true
|
||||
})
|
||||
|
||||
expect(streaming()).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the bubble across a peek at the terminal view', async () => {
|
||||
// Toggling to the terminal unmounts the chat list and unsubscribes its
|
||||
// transcript. The gate lives above that boundary, so the baseline survives
|
||||
// and the repeated-prefix reply keeps streaming on the way back.
|
||||
const prior = [assistantTurn('a1', 'Done.')]
|
||||
await render({ messages: prior })
|
||||
await update({ messages: prior, streamingText: 'Done.', streamLive: true })
|
||||
expect(streaming()).toBe('Done.')
|
||||
|
||||
await update({ show: false, messages: [], streamLive: true })
|
||||
expect(streaming()).toBe('hidden')
|
||||
// Back on chat the session withholds its transcript until a fresh read
|
||||
// settles, so the throttled stream text returns a round trip ahead of it.
|
||||
await update({ messages: [], streamLive: true })
|
||||
await update({ messages: [], streamingText: 'Done.', streamLive: true })
|
||||
await update({ messages: prior, streamingText: 'Done.', streamLive: true })
|
||||
|
||||
expect(streaming()).toBe('Done.')
|
||||
})
|
||||
|
||||
it('keeps the bubble across a peek at the terminal taken between turns', async () => {
|
||||
// Same toggle, but taken while idle: the transcript empties before the next
|
||||
// turn starts, so the gate has to reject that empty tail as a baseline.
|
||||
const prior = [assistantTurn('a1', 'Done.')]
|
||||
await render({ messages: prior })
|
||||
|
||||
await update({ show: false, messages: [] })
|
||||
await update({ show: false, messages: [], streamLive: true })
|
||||
await update({ messages: [], streamLive: true })
|
||||
await update({ messages: [], streamingText: 'Done.', streamLive: true })
|
||||
await update({ messages: prior, streamingText: 'Done.', streamLive: true })
|
||||
|
||||
expect(streaming()).toBe('Done.')
|
||||
})
|
||||
|
||||
it('hides a repeated part whose own turn landed during a mid-turn gap', async () => {
|
||||
// Between parts the status frame carries no assistant text (a tool call), so
|
||||
// the stream goes textless while the turn is still live and the part that
|
||||
// just finished lands in the transcript. Re-anchoring on that tick would
|
||||
// adopt it as history and render it a second time.
|
||||
const prior = [assistantTurn('a1', 'Done.')]
|
||||
await render({ messages: prior })
|
||||
await update({ messages: prior, streamingText: 'Done.', streamLive: true })
|
||||
expect(streaming()).toBe('Done.')
|
||||
|
||||
const landed = [...prior, assistantTurn('a2', 'Done.')]
|
||||
await update({ messages: landed, streamLive: true })
|
||||
await update({ messages: landed, streamingText: 'Done.', streamLive: true })
|
||||
|
||||
expect(streaming()).toBeNull()
|
||||
})
|
||||
|
||||
it("does not carry one chat's baseline into another stream identity", async () => {
|
||||
const prior = [assistantTurn('a1', 'Shared answer text')]
|
||||
await render({ messages: prior, identity: 'tab-a' })
|
||||
|
||||
await update({
|
||||
messages: prior,
|
||||
streamingText: 'Shared answer',
|
||||
streamLive: true,
|
||||
identity: 'tab-b'
|
||||
})
|
||||
|
||||
expect(streaming()).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,16 @@
|
||||
import { useMemo } from 'react'
|
||||
import { StyleSheet, View } from 'react-native'
|
||||
import { MobileNativeChatView, type MobileNativeChatInputLockReason } from './MobileNativeChatView'
|
||||
import { foldMobileNativeChatMessages } from './mobile-native-chat-render-data'
|
||||
import type { MobileNativeChatImageAttachments } from './use-mobile-native-chat-image-attachments'
|
||||
import type { MobileNativeChatController } from './use-mobile-native-chat-controller'
|
||||
import { useMobileNativeChatStreamingBubble } from './use-mobile-native-chat-streaming-bubble'
|
||||
|
||||
type Props = {
|
||||
controller: MobileNativeChatController
|
||||
/** Opens a tapped file reference (worktree-relative or absolute, optional
|
||||
* :line(:col) suffix) through the shared tap-to-open flow. */
|
||||
onOpenFile: (pathText: string) => void
|
||||
/** Native-chat image attachments: picking adds a composer chip, and sending
|
||||
* rides the pending images along with the message text (desktop parity). */
|
||||
images: MobileNativeChatImageAttachments
|
||||
@@ -22,9 +28,12 @@ type Props = {
|
||||
}
|
||||
|
||||
/** Keeps the terminal mounted underneath chat so its PTY subscription survives
|
||||
* view toggles while the native surface owns the visible composer. */
|
||||
* view toggles while the native surface owns the visible composer. Also owns
|
||||
* the streaming gate: this component stays mounted across those toggles, while
|
||||
* the chat list below it does not. */
|
||||
export function MobileNativeChatOverlay({
|
||||
controller,
|
||||
onOpenFile,
|
||||
images,
|
||||
onMicPress,
|
||||
micActive,
|
||||
@@ -36,28 +45,38 @@ export function MobileNativeChatOverlay({
|
||||
onClearSendError,
|
||||
keyboardInset
|
||||
}: Props): React.JSX.Element | null {
|
||||
const session = controller.nativeChatSession
|
||||
const folded = useMemo(() => foldMobileNativeChatMessages(session.messages), [session.messages])
|
||||
const streaming = useMobileNativeChatStreamingBubble(
|
||||
folded,
|
||||
controller.nativeChatStreamingText,
|
||||
controller.nativeChatStreamScopeKey,
|
||||
controller.nativeChatStreamLive
|
||||
)
|
||||
if (!controller.showNativeChat) {
|
||||
return null
|
||||
}
|
||||
const session = controller.nativeChatSession
|
||||
return (
|
||||
<View style={styles.overlay}>
|
||||
<MobileNativeChatView
|
||||
messages={session.messages}
|
||||
folded={folded}
|
||||
status={session.status}
|
||||
error={session.error}
|
||||
agent={controller.nativeChatAgent}
|
||||
agentWorking={controller.nativeChatAgentWorking}
|
||||
streamingText={controller.nativeChatStreamingText}
|
||||
streaming={streaming}
|
||||
onStop={controller.handleNativeChatStop}
|
||||
ask={controller.nativeChatAsk}
|
||||
askKey={controller.nativeChatAskKey}
|
||||
onDismissAsk={controller.dismissNativeChatAsk}
|
||||
onAnswerAsk={controller.handleNativeChatAnswerAsk}
|
||||
onCancelAsk={controller.handleNativeChatCancelAsk}
|
||||
question={controller.nativeChatQuestion}
|
||||
onAnswerQuestion={controller.handleNativeChatQuestionAnswer}
|
||||
permission={controller.nativeChatPermission}
|
||||
onRespondPermission={controller.handleNativeChatRespondPermission}
|
||||
onOpenFile={controller.handleNativeChatOpenFile}
|
||||
onOpenFile={onOpenFile}
|
||||
hasMore={session.hasMore}
|
||||
loadingEarlier={session.loadingEarlier}
|
||||
onLoadEarlier={session.loadEarlier}
|
||||
@@ -79,6 +98,7 @@ export function MobileNativeChatOverlay({
|
||||
onClearSendError={onClearSendError}
|
||||
filePaths={controller.nativeChatFilePaths}
|
||||
onNeedFiles={controller.loadNativeChatFiles}
|
||||
sessionOptions={controller.nativeChatSessionOptions}
|
||||
keyboardInset={keyboardInset}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SessionOptionDescriptor } from '../../../src/shared/native-chat-session-options'
|
||||
import { MobileNativeChatSessionOptionPickers } from './MobileNativeChatSessionOptionPickers'
|
||||
import type { MobileNativeChatSessionOptionsController } from './use-mobile-native-chat-session-options'
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
ActivityIndicator: 'ActivityIndicator',
|
||||
Keyboard: { dismiss: vi.fn() },
|
||||
Pressable: 'Pressable',
|
||||
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 },
|
||||
Text: 'Text',
|
||||
View: 'View'
|
||||
}))
|
||||
vi.mock('lucide-react-native', () => ({
|
||||
Check: 'Check',
|
||||
ChevronDown: 'ChevronDown',
|
||||
ChevronLeft: 'ChevronLeft',
|
||||
ChevronRight: 'ChevronRight',
|
||||
X: 'X'
|
||||
}))
|
||||
vi.mock('../components/BottomDrawer', async () => {
|
||||
const React = await import('react')
|
||||
return {
|
||||
BottomDrawer: ({ visible, children }: { visible: boolean; children?: unknown }) =>
|
||||
visible ? React.createElement('BottomDrawer', { visible }, children) : null
|
||||
}
|
||||
})
|
||||
|
||||
const MODEL_DESCRIPTOR: SessionOptionDescriptor = {
|
||||
id: 'model',
|
||||
label: 'Model',
|
||||
category: 'model',
|
||||
kind: {
|
||||
type: 'select',
|
||||
currentValue: 'sonnet',
|
||||
choices: [
|
||||
{ value: 'sonnet', label: 'Sonnet 5' },
|
||||
{ value: 'opus', label: 'Opus 4.8', description: 'Most capable' }
|
||||
]
|
||||
},
|
||||
valueSource: 'reported',
|
||||
settable: true
|
||||
}
|
||||
|
||||
const EFFORT_DESCRIPTOR: SessionOptionDescriptor = {
|
||||
id: 'effort',
|
||||
label: 'Effort',
|
||||
category: 'thought_level',
|
||||
kind: {
|
||||
type: 'select',
|
||||
currentValue: 'high',
|
||||
choices: [
|
||||
{ value: 'low', label: 'Low' },
|
||||
{ value: 'high', label: 'High' }
|
||||
]
|
||||
},
|
||||
valueSource: 'dispatched',
|
||||
settable: true
|
||||
}
|
||||
|
||||
const FAST_MODE_DESCRIPTOR: SessionOptionDescriptor = {
|
||||
id: 'fastMode',
|
||||
label: 'Fast mode',
|
||||
category: 'mode',
|
||||
kind: { type: 'boolean', currentValue: false },
|
||||
valueSource: 'reported',
|
||||
settable: true
|
||||
}
|
||||
|
||||
describe('MobileNativeChatSessionOptionPickers', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
const setOption = vi.fn<MobileNativeChatSessionOptionsController['setOption']>()
|
||||
const invokeAction = vi.fn<MobileNativeChatSessionOptionsController['invokeAction']>()
|
||||
|
||||
const mount = (snapshot: SessionOptionDescriptor[], isWorking = false): void => {
|
||||
const controller: MobileNativeChatSessionOptionsController = {
|
||||
snapshot,
|
||||
pendingId: null,
|
||||
setOption,
|
||||
invokeAction,
|
||||
recordCommand: vi.fn()
|
||||
}
|
||||
act(() => {
|
||||
renderer = create(
|
||||
createElement(MobileNativeChatSessionOptionPickers, { controller, isWorking })
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const pill = (
|
||||
name: string
|
||||
): {
|
||||
props: {
|
||||
onPress: () => void
|
||||
accessibilityLabel?: string
|
||||
disabled?: boolean
|
||||
accessibilityRole?: string
|
||||
accessibilityState?: { disabled?: boolean }
|
||||
}
|
||||
} =>
|
||||
renderer!.root.find(
|
||||
(node) =>
|
||||
node.type === 'Pressable' &&
|
||||
typeof node.props.accessibilityLabel === 'string' &&
|
||||
node.props.accessibilityLabel.startsWith(name)
|
||||
) as {
|
||||
props: {
|
||||
onPress: () => void
|
||||
accessibilityLabel?: string
|
||||
disabled?: boolean
|
||||
accessibilityRole?: string
|
||||
accessibilityState?: { disabled?: boolean }
|
||||
}
|
||||
}
|
||||
|
||||
const rowByText = (
|
||||
text: string
|
||||
): {
|
||||
props: {
|
||||
onPress: () => void
|
||||
accessibilityRole?: string
|
||||
accessibilityLabel?: string
|
||||
accessibilityState?: { checked?: boolean; disabled?: boolean }
|
||||
}
|
||||
} => {
|
||||
const label = renderer!.root
|
||||
.findAll((node) => node.type === 'Text')
|
||||
.find((node) => (node.props as { children?: unknown }).children === text)
|
||||
if (!label) {
|
||||
throw new Error(`No row labeled ${text}`)
|
||||
}
|
||||
let parent = label.parent
|
||||
while (parent && parent.type !== 'Pressable') {
|
||||
parent = parent.parent
|
||||
}
|
||||
if (!parent) {
|
||||
throw new Error(`No pressable row for ${text}`)
|
||||
}
|
||||
return parent as unknown as {
|
||||
props: {
|
||||
onPress: () => void
|
||||
accessibilityRole?: string
|
||||
accessibilityLabel?: string
|
||||
accessibilityState?: { checked?: boolean; disabled?: boolean }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
setOption.mockReset()
|
||||
setOption.mockResolvedValue(true)
|
||||
invokeAction.mockReset()
|
||||
invokeAction.mockResolvedValue(true)
|
||||
})
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
renderer?.unmount()
|
||||
})
|
||||
renderer = null
|
||||
})
|
||||
|
||||
it('renders nothing without a model descriptor', () => {
|
||||
mount([])
|
||||
expect(renderer!.toJSON()).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the current model and effort in one pill', () => {
|
||||
mount([MODEL_DESCRIPTOR, EFFORT_DESCRIPTOR])
|
||||
expect(pill('Model').props).toMatchObject({
|
||||
accessibilityLabel: 'Model, Sonnet 5 High',
|
||||
disabled: false
|
||||
})
|
||||
const labels = renderer!.root
|
||||
.findAll((node) => node.type === 'Text')
|
||||
.map((node) => (node.props as { children?: unknown }).children)
|
||||
expect(labels).toContain('Sonnet 5 High')
|
||||
})
|
||||
|
||||
it('opens the model sheet and applies a picked model', async () => {
|
||||
mount([MODEL_DESCRIPTOR, EFFORT_DESCRIPTOR])
|
||||
await act(async () => pill('Model').props.onPress())
|
||||
expect(renderer!.root.findByType('BottomDrawer').props.visible).toBe(true)
|
||||
await act(async () => rowByText('Opus 4.8').props.onPress())
|
||||
expect(setOption).toHaveBeenCalledWith('model', 'opus')
|
||||
})
|
||||
|
||||
it('opens an option picker from the model sheet summary', async () => {
|
||||
mount([MODEL_DESCRIPTOR, EFFORT_DESCRIPTOR])
|
||||
await act(async () => pill('Model').props.onPress())
|
||||
await act(async () => rowByText('Effort').props.onPress())
|
||||
await act(async () => rowByText('Low').props.onPress())
|
||||
expect(setOption).toHaveBeenCalledWith('effort', 'low')
|
||||
})
|
||||
|
||||
it('shows absolute boolean values in option summaries', async () => {
|
||||
mount([MODEL_DESCRIPTOR, FAST_MODE_DESCRIPTOR])
|
||||
await act(async () => pill('Model').props.onPress())
|
||||
expect(rowByText('Off').props.accessibilityLabel).toBe('Fast mode, Off')
|
||||
})
|
||||
|
||||
it('announces choice selection and disabled state', async () => {
|
||||
mount([MODEL_DESCRIPTOR, EFFORT_DESCRIPTOR])
|
||||
expect(pill('Model').props).toMatchObject({
|
||||
accessibilityRole: 'button',
|
||||
accessibilityState: { disabled: false }
|
||||
})
|
||||
await act(async () => pill('Model').props.onPress())
|
||||
expect(rowByText('Sonnet 5').props).toMatchObject({
|
||||
accessibilityRole: 'radio',
|
||||
accessibilityState: { checked: true, disabled: false }
|
||||
})
|
||||
expect(rowByText('Opus 4.8').props.accessibilityState?.checked).toBe(false)
|
||||
})
|
||||
|
||||
it('closes without dispatch when re-picking the tracked value', async () => {
|
||||
mount([MODEL_DESCRIPTOR, EFFORT_DESCRIPTOR])
|
||||
await act(async () => pill('Model').props.onPress())
|
||||
await act(async () => rowByText('Sonnet 5').props.onPress())
|
||||
expect(setOption).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders agent-picker descriptors as a single action row', async () => {
|
||||
mount([
|
||||
{
|
||||
...MODEL_DESCRIPTOR,
|
||||
kind: { type: 'select', choices: [] },
|
||||
valueSource: 'unknown',
|
||||
action: { type: 'agent-picker' }
|
||||
}
|
||||
])
|
||||
await act(async () => pill('Model').props.onPress())
|
||||
expect(rowByText('Choose in agent picker…').props.accessibilityRole).toBe('button')
|
||||
await act(async () => rowByText('Choose in agent picker…').props.onPress())
|
||||
expect(invokeAction).toHaveBeenCalledWith('model')
|
||||
})
|
||||
|
||||
it('locks the pills while the agent is working', () => {
|
||||
mount([MODEL_DESCRIPTOR, EFFORT_DESCRIPTOR], true)
|
||||
expect(pill('Model').props).toMatchObject({ disabled: true })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,206 @@
|
||||
import { useState } from 'react'
|
||||
import { ActivityIndicator, Keyboard, Pressable, StyleSheet, Text, View } from 'react-native'
|
||||
import { ChevronLeft, X } from 'lucide-react-native'
|
||||
import { BottomDrawer } from '../components/BottomDrawer'
|
||||
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
|
||||
import type {
|
||||
SessionOptionDescriptor,
|
||||
SessionOptionValue
|
||||
} from '../../../src/shared/native-chat-session-options'
|
||||
import {
|
||||
mobileModelPillLabel,
|
||||
mobileOptionsPillLabel,
|
||||
mobileSessionOptionSummaryValue,
|
||||
mobileSessionOptionDisabledReason
|
||||
} from './mobile-native-chat-session-option-labels'
|
||||
import {
|
||||
DescriptorRows,
|
||||
Pill,
|
||||
SessionOptionCaption,
|
||||
SessionOptionSummaryRow
|
||||
} from './MobileNativeChatSessionOptionRows'
|
||||
import { sortNativeChatSessionOptions } from '../../../src/shared/native-chat-session-option-snapshot'
|
||||
import type { MobileNativeChatSessionOptionsController } from './use-mobile-native-chat-session-options'
|
||||
|
||||
export type MobileNativeChatSessionOptionPickersProps = {
|
||||
controller: MobileNativeChatSessionOptionsController
|
||||
/** Pickers lock while the agent works — a mid-turn `/model` interleaves with
|
||||
* the agent's own output (desktop parity). */
|
||||
isWorking: boolean
|
||||
/** A composer send owns the TUI input line until it settles. The host spaces a
|
||||
* send's body and its Enter ~500ms apart, so an apply dispatched inside that
|
||||
* window would be submitted as part of the user's prompt. The composer blocks
|
||||
* the reverse direction on `pendingId`; this is the same guard mirrored. */
|
||||
sendInFlight?: boolean
|
||||
}
|
||||
|
||||
/** Combined model/session-option trigger and its mobile bottom drawer. */
|
||||
export function MobileNativeChatSessionOptionPickers({
|
||||
controller,
|
||||
isWorking,
|
||||
sendInFlight = false
|
||||
}: MobileNativeChatSessionOptionPickersProps): React.JSX.Element | null {
|
||||
const [openDescriptorId, setOpenDescriptorId] = useState<string | null>(null)
|
||||
const { snapshot, pendingId } = controller
|
||||
const model = snapshot.find((descriptor) => descriptor.category === 'model')
|
||||
const options = sortNativeChatSessionOptions(snapshot)
|
||||
if (!model) {
|
||||
return null
|
||||
}
|
||||
const disabled = isWorking || pendingId !== null || sendInFlight
|
||||
const activeDescriptor = snapshot.find((descriptor) => descriptor.id === openDescriptorId)
|
||||
const modelView = activeDescriptor?.id === model.id
|
||||
const modelLabel = mobileModelPillLabel(model)
|
||||
const optionsLabel = options.length > 0 ? mobileOptionsPillLabel(options) : null
|
||||
const pillLabel = optionsLabel ? `${modelLabel} ${optionsLabel}` : modelLabel
|
||||
const reason = mobileSessionOptionDisabledReason(activeDescriptor?.disabledReason)
|
||||
|
||||
const closePicker = (): void => setOpenDescriptorId(null)
|
||||
const openPicker = (): void => {
|
||||
Keyboard.dismiss()
|
||||
setOpenDescriptorId(model.id)
|
||||
}
|
||||
|
||||
const applyOption = (descriptor: SessionOptionDescriptor, value: SessionOptionValue): void => {
|
||||
// Re-picking the tracked value is a no-op — never re-dispatch it.
|
||||
if (
|
||||
descriptor.valueSource !== 'unknown' &&
|
||||
descriptor.kind.type === 'select' &&
|
||||
descriptor.kind.currentValue === value
|
||||
) {
|
||||
closePicker()
|
||||
return
|
||||
}
|
||||
void controller.setOption(descriptor.id, value).then((applied) => {
|
||||
if (applied) {
|
||||
closePicker()
|
||||
}
|
||||
})
|
||||
}
|
||||
const invokeAction = (descriptor: SessionOptionDescriptor): void => {
|
||||
void controller.invokeAction(descriptor.id).then((invoked) => {
|
||||
if (invoked) {
|
||||
closePicker()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<View>
|
||||
<Pill
|
||||
label={pillLabel}
|
||||
accessibleName={`Model, ${pillLabel}`}
|
||||
disabled={disabled}
|
||||
onPress={openPicker}
|
||||
/>
|
||||
<BottomDrawer visible={activeDescriptor !== undefined} onClose={closePicker}>
|
||||
{activeDescriptor ? (
|
||||
<View style={styles.sheet}>
|
||||
<View style={styles.sheetHeader}>
|
||||
<Pressable
|
||||
accessibilityLabel={modelView ? 'Close picker' : 'Back to models'}
|
||||
accessibilityRole="button"
|
||||
style={({ pressed }) => [styles.sheetNav, pressed && styles.pressed]}
|
||||
onPress={modelView ? closePicker : () => setOpenDescriptorId(model.id)}
|
||||
hitSlop={8}
|
||||
>
|
||||
{modelView ? (
|
||||
<X size={18} color={colors.textSecondary} strokeWidth={2.2} />
|
||||
) : (
|
||||
<ChevronLeft size={18} color={colors.textSecondary} strokeWidth={2.2} />
|
||||
)}
|
||||
</Pressable>
|
||||
<Text style={styles.sheetTitle}>
|
||||
{modelView ? 'Select model' : `Select ${activeDescriptor.label.toLowerCase()}`}
|
||||
</Text>
|
||||
<View style={styles.sheetHeaderSide}>
|
||||
{pendingId !== null ? (
|
||||
<ActivityIndicator size="small" color={colors.textSecondary} />
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
{activeDescriptor.valueSource === 'dispatched' ? (
|
||||
<SessionOptionCaption>Sent to the agent — not confirmed</SessionOptionCaption>
|
||||
) : null}
|
||||
{reason ? <SessionOptionCaption>{reason}</SessionOptionCaption> : null}
|
||||
<View style={styles.choiceGroup}>
|
||||
<DescriptorRows
|
||||
descriptor={activeDescriptor}
|
||||
disabled={disabled}
|
||||
grouped
|
||||
onSetOption={(value) => applyOption(activeDescriptor, value)}
|
||||
onInvokeAction={() => invokeAction(activeDescriptor)}
|
||||
/>
|
||||
</View>
|
||||
{modelView && options.length > 0 ? (
|
||||
<View style={styles.optionGroup}>
|
||||
{options.map((descriptor, index) => (
|
||||
<SessionOptionSummaryRow
|
||||
key={descriptor.id}
|
||||
label={descriptor.label}
|
||||
value={mobileSessionOptionSummaryValue(descriptor)}
|
||||
disabled={disabled}
|
||||
divided={index < options.length - 1}
|
||||
onPress={() => setOpenDescriptorId(descriptor.id)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
</BottomDrawer>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
sheet: {
|
||||
paddingBottom: spacing.xs
|
||||
},
|
||||
sheetHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingBottom: spacing.lg
|
||||
},
|
||||
sheetTitle: {
|
||||
flex: 1,
|
||||
color: colors.textPrimary,
|
||||
fontSize: typography.titleSize,
|
||||
fontWeight: '700',
|
||||
textAlign: 'center'
|
||||
},
|
||||
sheetNav: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.bgRaised,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.borderSubtle
|
||||
},
|
||||
sheetHeaderSide: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
choiceGroup: {
|
||||
overflow: 'hidden',
|
||||
borderRadius: radii.card,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.borderSubtle,
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
optionGroup: {
|
||||
overflow: 'hidden',
|
||||
marginTop: spacing.md,
|
||||
borderRadius: radii.card,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.borderSubtle,
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
pressed: {
|
||||
opacity: 0.7
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,345 @@
|
||||
// The pill and choice-row primitives the session-option card is built from, kept
|
||||
// beside it so the card file stays about layout and apply wiring.
|
||||
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native'
|
||||
import { Check, ChevronDown, ChevronRight } from 'lucide-react-native'
|
||||
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
|
||||
import type {
|
||||
SessionOptionDescriptor,
|
||||
SessionOptionValue
|
||||
} from '../../../src/shared/native-chat-session-options'
|
||||
|
||||
/** Muted one-liner above a group — dispatch state, or why a row is locked. */
|
||||
export function SessionOptionCaption({ children }: { children: string }): React.JSX.Element {
|
||||
return <Text style={styles.caption}>{children}</Text>
|
||||
}
|
||||
|
||||
export function Pill({
|
||||
label,
|
||||
accessibleName,
|
||||
disabled,
|
||||
onPress
|
||||
}: {
|
||||
label: string
|
||||
accessibleName: string
|
||||
disabled: boolean
|
||||
onPress: () => void
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityLabel={accessibleName}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ disabled }}
|
||||
style={({ pressed }) => [styles.pill, pressed && !disabled && styles.pressed]}
|
||||
onPress={onPress}
|
||||
disabled={disabled}
|
||||
hitSlop={6}
|
||||
>
|
||||
<Text
|
||||
style={[styles.pillText, disabled && styles.pillTextDisabled]}
|
||||
numberOfLines={1}
|
||||
ellipsizeMode="tail"
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
<ChevronDown size={12} color={disabled ? colors.textMuted : colors.textSecondary} />
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
function ChoiceRow({
|
||||
label,
|
||||
description,
|
||||
selected,
|
||||
disabled,
|
||||
grouped,
|
||||
divided,
|
||||
onPress
|
||||
}: {
|
||||
label: string
|
||||
description?: string
|
||||
selected: boolean
|
||||
disabled: boolean
|
||||
grouped: boolean
|
||||
divided: boolean
|
||||
onPress: () => void
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="radio"
|
||||
accessibilityState={{ checked: selected, disabled }}
|
||||
style={[
|
||||
styles.row,
|
||||
selected && styles.rowSelected,
|
||||
grouped && styles.rowGrouped,
|
||||
divided && styles.rowDivided,
|
||||
disabled && styles.rowDisabled
|
||||
]}
|
||||
onPress={onPress}
|
||||
disabled={disabled}
|
||||
>
|
||||
<View style={[styles.radio, selected && styles.radioOn]}>
|
||||
{selected ? <Check size={12} color={colors.bgBase} strokeWidth={3} /> : null}
|
||||
</View>
|
||||
<View style={styles.rowBody}>
|
||||
<Text style={styles.rowLabel}>{label}</Text>
|
||||
{description ? (
|
||||
<Text style={styles.rowDescription} numberOfLines={2}>
|
||||
{description}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
function ActionRow({
|
||||
label,
|
||||
disabled,
|
||||
grouped,
|
||||
onPress
|
||||
}: {
|
||||
label: string
|
||||
disabled: boolean
|
||||
grouped: boolean
|
||||
onPress: () => void
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ disabled }}
|
||||
style={[styles.row, grouped && styles.rowGrouped, disabled && styles.rowDisabled]}
|
||||
onPress={onPress}
|
||||
disabled={disabled}
|
||||
>
|
||||
<View style={styles.rowBody}>
|
||||
<Text style={styles.rowLabel}>{label}</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
export function SessionOptionSummaryRow({
|
||||
label,
|
||||
value,
|
||||
disabled,
|
||||
divided,
|
||||
onPress
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
disabled: boolean
|
||||
divided: boolean
|
||||
onPress: () => void
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityLabel={`${label}, ${value}`}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ disabled }}
|
||||
style={({ pressed }) => [
|
||||
styles.summaryRow,
|
||||
divided && styles.rowDivided,
|
||||
pressed && !disabled && styles.pressed,
|
||||
disabled && styles.rowDisabled
|
||||
]}
|
||||
onPress={onPress}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Text style={styles.summaryLabel}>{label}</Text>
|
||||
<Text style={styles.summaryValue} numberOfLines={1}>
|
||||
{value}
|
||||
</Text>
|
||||
<ChevronRight size={16} color={colors.textMuted} strokeWidth={2.2} />
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
export function DescriptorRows({
|
||||
descriptor,
|
||||
disabled,
|
||||
grouped = false,
|
||||
onSetOption,
|
||||
onInvokeAction
|
||||
}: {
|
||||
descriptor: SessionOptionDescriptor
|
||||
disabled: boolean
|
||||
grouped?: boolean
|
||||
onSetOption: (value: SessionOptionValue) => void
|
||||
onInvokeAction: () => void
|
||||
}): React.JSX.Element {
|
||||
const locked = disabled || !descriptor.settable
|
||||
// Why: flip-only without a baseline is an action — never claim On/Off.
|
||||
if (descriptor.action?.type === 'toggle-command') {
|
||||
return (
|
||||
<ActionRow
|
||||
label={`Toggle ${descriptor.label.toLowerCase()}`}
|
||||
disabled={locked}
|
||||
grouped={grouped}
|
||||
onPress={onInvokeAction}
|
||||
/>
|
||||
)
|
||||
}
|
||||
// Why: agent-picker opens the TUI; it is not a set of radio choices.
|
||||
if (descriptor.action?.type === 'agent-picker') {
|
||||
return (
|
||||
<ActionRow
|
||||
label="Choose in agent picker…"
|
||||
disabled={locked}
|
||||
grouped={grouped}
|
||||
onPress={onInvokeAction}
|
||||
/>
|
||||
)
|
||||
}
|
||||
// Unknown booleans leave both radios unselected instead of inventing truth.
|
||||
if (descriptor.kind.type === 'boolean') {
|
||||
const current = descriptor.kind.currentValue
|
||||
return (
|
||||
<>
|
||||
{current === undefined ? (
|
||||
<SessionOptionCaption>Current value unknown — pick On or Off</SessionOptionCaption>
|
||||
) : null}
|
||||
<ChoiceRow
|
||||
label="On"
|
||||
selected={current === true}
|
||||
disabled={locked}
|
||||
grouped={grouped}
|
||||
divided={grouped}
|
||||
onPress={() => onSetOption(true)}
|
||||
/>
|
||||
<ChoiceRow
|
||||
label="Off"
|
||||
selected={current === false}
|
||||
disabled={locked}
|
||||
grouped={grouped}
|
||||
divided={false}
|
||||
onPress={() => onSetOption(false)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
const { currentValue, choices } = descriptor.kind
|
||||
return (
|
||||
<>
|
||||
{choices.map((choice, index) => (
|
||||
<ChoiceRow
|
||||
key={choice.value}
|
||||
label={choice.label}
|
||||
description={choice.description}
|
||||
selected={choice.value === currentValue}
|
||||
disabled={locked}
|
||||
grouped={grouped}
|
||||
divided={grouped && index < choices.length - 1}
|
||||
onPress={() => onSetOption(choice.value)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
pill: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
maxWidth: 180,
|
||||
minHeight: 28,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 4,
|
||||
borderRadius: radii.button,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.borderSubtle,
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
pillText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.metaSize,
|
||||
fontWeight: '600',
|
||||
flexShrink: 1
|
||||
},
|
||||
pillTextDisabled: {
|
||||
color: colors.textMuted
|
||||
},
|
||||
pressed: {
|
||||
opacity: 0.7
|
||||
},
|
||||
caption: {
|
||||
color: colors.textMuted,
|
||||
fontSize: typography.metaSize,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingBottom: spacing.xs
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.sm,
|
||||
padding: spacing.sm,
|
||||
minHeight: 44,
|
||||
alignItems: 'center',
|
||||
borderRadius: radii.card,
|
||||
backgroundColor: colors.bgRaised,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
marginBottom: spacing.xs
|
||||
},
|
||||
rowSelected: {
|
||||
borderColor: colors.statusGreen
|
||||
},
|
||||
rowGrouped: {
|
||||
marginBottom: 0,
|
||||
borderWidth: 0,
|
||||
borderRadius: 0,
|
||||
backgroundColor: 'transparent'
|
||||
},
|
||||
rowDivided: {
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: colors.borderSubtle
|
||||
},
|
||||
rowDisabled: {
|
||||
opacity: 0.5
|
||||
},
|
||||
radio: {
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: 9,
|
||||
borderWidth: 1.5,
|
||||
borderColor: colors.textMuted,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
radioOn: {
|
||||
backgroundColor: colors.statusGreen,
|
||||
borderColor: colors.statusGreen
|
||||
},
|
||||
rowBody: {
|
||||
flex: 1,
|
||||
gap: 2
|
||||
},
|
||||
rowLabel: {
|
||||
color: colors.textPrimary,
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '600'
|
||||
},
|
||||
rowDescription: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.metaSize
|
||||
},
|
||||
summaryRow: {
|
||||
minHeight: 48,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm
|
||||
},
|
||||
summaryLabel: {
|
||||
flex: 1,
|
||||
color: colors.textPrimary,
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '600'
|
||||
},
|
||||
summaryValue: {
|
||||
maxWidth: 160,
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.bodySize
|
||||
}
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import { MobileNativeChatView } from './MobileNativeChatView'
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
@@ -58,6 +59,9 @@ vi.mock('./MobileNativeChatComposer', async () => {
|
||||
})
|
||||
|
||||
type Overrides = {
|
||||
messages?: Parameters<typeof MobileNativeChatView>[0]['messages']
|
||||
folded?: Parameters<typeof MobileNativeChatView>[0]['folded']
|
||||
streaming?: string | null
|
||||
sendErrorMessage?: string | null
|
||||
onClearSendError?: () => void
|
||||
inputLockReason?: 'disconnected' | 'waiting' | null
|
||||
@@ -75,7 +79,25 @@ function suppressRendererWarning(): () => void {
|
||||
return () => spy.mockRestore()
|
||||
}
|
||||
|
||||
describe('MobileNativeChatView send-error banner', () => {
|
||||
function assistantTurn(id: string, text: string): NativeChatMessage {
|
||||
return { id, role: 'assistant', blocks: [{ type: 'text', text }], timestamp: 0, source: 'hook' }
|
||||
}
|
||||
|
||||
function chatViewElement(overrides: Overrides): ReturnType<typeof createElement> {
|
||||
return createElement(MobileNativeChatView, {
|
||||
messages: [],
|
||||
folded: [],
|
||||
status: 'ready',
|
||||
streaming: null,
|
||||
onSend: vi.fn().mockResolvedValue(true),
|
||||
pending: [],
|
||||
composerText: '',
|
||||
onComposerTextChange: vi.fn(),
|
||||
...overrides
|
||||
})
|
||||
}
|
||||
|
||||
describe('MobileNativeChatView', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -91,23 +113,25 @@ describe('MobileNativeChatView send-error banner', () => {
|
||||
const restore = suppressRendererWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(MobileNativeChatView, {
|
||||
messages: [],
|
||||
status: 'ready',
|
||||
onSend: overrides.onSend ?? vi.fn().mockResolvedValue(true),
|
||||
pending: [],
|
||||
composerText: '',
|
||||
onComposerTextChange: vi.fn(),
|
||||
...overrides
|
||||
})
|
||||
)
|
||||
renderer = create(chatViewElement(overrides))
|
||||
})
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
}
|
||||
|
||||
async function update(overrides: Overrides = {}): Promise<void> {
|
||||
await act(async () => {
|
||||
renderer?.update(chatViewElement(overrides))
|
||||
})
|
||||
}
|
||||
|
||||
/** Ids of the rows the list is currently rendering. */
|
||||
function listIds(): string[] {
|
||||
const list = renderer!.root.find((node) => node.type === 'FlatList')
|
||||
return (list.props.data as { id: string }[]).map((row) => row.id)
|
||||
}
|
||||
|
||||
function banners(): ReactTestInstance[] {
|
||||
return renderer!.root.findAll((node) => node.props.accessibilityRole === 'alert')
|
||||
}
|
||||
@@ -161,4 +185,16 @@ describe('MobileNativeChatView send-error banner', () => {
|
||||
|
||||
expect(onClearSendError).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
// The gate that decides `streaming` lives in MobileNativeChatOverlay, which
|
||||
// outlives this view; see MobileNativeChatOverlay.test.ts.
|
||||
it('appends the gated streaming bubble after the folded transcript', async () => {
|
||||
const folded = [assistantTurn('a1', 'The tests pass.')]
|
||||
await render({ folded })
|
||||
expect(listIds()).toEqual(['a1'])
|
||||
|
||||
await update({ folded, streaming: 'The tests' })
|
||||
|
||||
expect(listIds()).toEqual(['a1', 'streaming'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,15 +16,14 @@ import { colors } from '../theme/mobile-theme'
|
||||
import { styles } from './mobile-native-chat-view-styles'
|
||||
import {
|
||||
buildMobileNativeChatTransientData,
|
||||
foldMobileNativeChatMessages,
|
||||
mobileNativeChatEmptyState,
|
||||
type MobileNativeChatPendingItem
|
||||
} from './mobile-native-chat-render-data'
|
||||
import { useMobileNativeChatAskDismiss } from './use-mobile-native-chat-ask-dismiss'
|
||||
import { useMobileNativeChatPinchGesture } from './use-mobile-native-chat-pinch-gesture'
|
||||
import { MobileAgentWorkingIndicator } from './MobileAgentWorkingIndicator'
|
||||
import type { PendingNativeChatImage } from './mobile-native-chat-image-attachment'
|
||||
import { MobileNativeChatComposer } from './MobileNativeChatComposer'
|
||||
import type { MobileNativeChatSessionOptionPickersProps } from './MobileNativeChatSessionOptionPickers'
|
||||
import { MobileNativeChatMessage } from './MobileNativeChatMessage'
|
||||
import { MobileNativeChatAsk } from './MobileNativeChatAsk'
|
||||
import type { AskAnswerSelection, AskPrompt } from './mobile-native-chat-ask'
|
||||
@@ -39,7 +38,10 @@ import type { MobileNativeChatStatus } from './use-mobile-native-chat-session'
|
||||
export type MobileNativeChatInputLockReason = 'disconnected' | 'waiting'
|
||||
|
||||
type Props = {
|
||||
/** Raw transcript, only for telling "still loading" from "loaded and empty". */
|
||||
messages: NativeChatMessage[]
|
||||
/** `messages` with noise stripped and tool turns folded in, from the overlay. */
|
||||
folded: NativeChatMessage[]
|
||||
status: MobileNativeChatStatus
|
||||
error?: string
|
||||
/** Resolved agent for this chat; names the empty-state copy (desktop parity). */
|
||||
@@ -47,9 +49,9 @@ type Props = {
|
||||
agentWorking?: boolean
|
||||
/** Interrupt the agent mid-turn (shown as a Stop button on the working bar). */
|
||||
onStop?: () => void
|
||||
/** Live partial assistant text while a turn is still streaming (from the agent
|
||||
* status hook). Shown as an in-progress bubble until the transcript catches up. */
|
||||
streamingText?: string
|
||||
/** Live partial assistant text to show as an in-progress bubble, already gated
|
||||
* by the overlay against the transcript catching up. */
|
||||
streaming: string | null
|
||||
hasMore?: boolean
|
||||
loadingEarlier?: boolean
|
||||
onLoadEarlier?: () => void
|
||||
@@ -80,11 +82,18 @@ type Props = {
|
||||
onClearSendError?: () => void
|
||||
filePaths?: string[]
|
||||
onNeedFiles?: (query: string) => void
|
||||
/** Model/session-option pickers for the composer action row (desktop parity). */
|
||||
sessionOptions?: MobileNativeChatSessionOptionPickersProps | null
|
||||
/** A pending agent question/permission detected from live status, shown as a
|
||||
* native card above the composer; answering sends text to the agent. */
|
||||
/** Structured AskUserQuestion prompt parsed from the transcript (preferred over
|
||||
* the heuristic question card). */
|
||||
ask?: AskPrompt | null
|
||||
/** Stable key for the ask card. Dismissal state lives in the controller (it
|
||||
* must survive this subtree unmounting on a chat↔terminal toggle). */
|
||||
askKey?: string | null
|
||||
/** Hide the answered/dismissed ask until a different question arrives. */
|
||||
onDismissAsk?: () => void
|
||||
/** Deliver the ask answer as per-question selections; the send hook turns them
|
||||
* into selector keystrokes (Claude) or pasted label text (other agents). */
|
||||
onAnswerAsk?: (prompt: AskPrompt, selections: AskAnswerSelection[]) => Promise<boolean>
|
||||
@@ -102,12 +111,13 @@ type Props = {
|
||||
|
||||
export function MobileNativeChatView({
|
||||
messages,
|
||||
folded,
|
||||
status,
|
||||
error,
|
||||
agent,
|
||||
agentWorking,
|
||||
onStop,
|
||||
streamingText,
|
||||
streaming,
|
||||
hasMore,
|
||||
loadingEarlier,
|
||||
onLoadEarlier,
|
||||
@@ -129,7 +139,10 @@ export function MobileNativeChatView({
|
||||
onClearSendError,
|
||||
filePaths,
|
||||
onNeedFiles,
|
||||
sessionOptions,
|
||||
ask,
|
||||
askKey,
|
||||
onDismissAsk,
|
||||
onAnswerAsk,
|
||||
onCancelAsk,
|
||||
question,
|
||||
@@ -142,10 +155,6 @@ export function MobileNativeChatView({
|
||||
const insets = useSafeAreaInsets()
|
||||
const listRef = useRef<FlatList<NativeChatMessage>>(null)
|
||||
const [toolsExpanded, setToolsExpanded] = useState(false)
|
||||
// Dismiss the question card as soon as it's answered; the live status lingers
|
||||
// briefly (the agent emits a post-tool event with the same prompt), so hide it
|
||||
// until a genuinely different question arrives.
|
||||
const { askKey, showAsk, dismissAsk } = useMobileNativeChatAskDismiss(ask)
|
||||
// Lift the composer clear of the keyboard, plus the bottom safe-area so it
|
||||
// never sits under the home indicator / nav bar (mirrors the terminal dock).
|
||||
const bottomPad = keyboardInset > 0 ? keyboardInset + insets.bottom : insets.bottom
|
||||
@@ -165,10 +174,9 @@ export function MobileNativeChatView({
|
||||
// `data` is the list source: folded transcript + synthetic streaming bubble +
|
||||
// route-owned optimistic queued messages. Memoize on the same deps so the
|
||||
// downstream autoscroll effects/`renderItem` keep referential stability.
|
||||
const foldedMessages = useMemo(() => foldMobileNativeChatMessages(messages), [messages])
|
||||
const { data } = useMemo(
|
||||
() => buildMobileNativeChatTransientData({ folded: foldedMessages, streamingText, pending }),
|
||||
[foldedMessages, streamingText, pending]
|
||||
() => buildMobileNativeChatTransientData({ folded, streaming, pending }),
|
||||
[folded, streaming, pending]
|
||||
)
|
||||
|
||||
// Follow the tail as the conversation grows and keep the newest message above
|
||||
@@ -272,6 +280,9 @@ export function MobileNativeChatView({
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={renderItem}
|
||||
contentContainerStyle={styles.listContent}
|
||||
// Let link/file taps land while the composer keyboard is up
|
||||
// instead of being swallowed by the dismiss gesture.
|
||||
keyboardShouldPersistTaps="handled"
|
||||
onScroll={onScroll}
|
||||
scrollEventThrottle={32}
|
||||
onContentSizeChange={() => {
|
||||
@@ -333,22 +344,24 @@ export function MobileNativeChatView({
|
||||
</GestureHandlerRootView>
|
||||
)}
|
||||
{/* Pending agent prompt: a structured AskUserQuestion wins, then a
|
||||
heuristic permission, then a heuristic question. */}
|
||||
{showAsk && ask ? (
|
||||
heuristic permission, then a heuristic question. The controller owns
|
||||
dismissal (it must survive this subtree unmounting on a view toggle);
|
||||
`ask` arrives already nulled while dismissed. */}
|
||||
{ask ? (
|
||||
<MobileNativeChatAsk
|
||||
key={askKey ?? 'ask'}
|
||||
prompt={ask}
|
||||
onAnswer={async (selections) => {
|
||||
const accepted = (await onAnswerAsk?.(ask, selections)) ?? false
|
||||
if (accepted) {
|
||||
dismissAsk()
|
||||
onDismissAsk?.()
|
||||
}
|
||||
return accepted
|
||||
}}
|
||||
onCancel={async () => {
|
||||
const accepted = (await onCancelAsk?.()) ?? false
|
||||
if (accepted) {
|
||||
dismissAsk()
|
||||
onDismissAsk?.()
|
||||
}
|
||||
return accepted
|
||||
}}
|
||||
@@ -410,6 +423,8 @@ export function MobileNativeChatView({
|
||||
value={composerText}
|
||||
onChangeText={onComposerTextChange}
|
||||
onSend={handleSend}
|
||||
agent={agent}
|
||||
sessionOptions={sessionOptions}
|
||||
onAttachImage={onAttachImage}
|
||||
attachments={attachments}
|
||||
onRemoveAttachment={onRemoveAttachment}
|
||||
|
||||
+198
-13
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { openMobileTerminalFileTap } from './mobile-terminal-file-tap-open'
|
||||
import { openMobileFileTap } from './mobile-file-tap-open'
|
||||
|
||||
function ok(result: unknown) {
|
||||
return { ok: true, result, _meta: { runtimeId: 'runtime-1' } }
|
||||
@@ -22,7 +22,7 @@ function activeTerminalState(activated: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
describe('openMobileTerminalFileTap', () => {
|
||||
describe('openMobileFileTap', () => {
|
||||
it('opens absolute terminal artifacts through the grant-backed preview route', async () => {
|
||||
const client = createClient([
|
||||
ok({
|
||||
@@ -42,7 +42,7 @@ describe('openMobileTerminalFileTap', () => {
|
||||
const pushPreviewRoute = vi.fn()
|
||||
const triggerOpenFeedback = vi.fn()
|
||||
|
||||
openMobileTerminalFileTap({
|
||||
openMobileFileTap({
|
||||
client,
|
||||
hostId: 'host-1',
|
||||
worktreeId: 'wt-1',
|
||||
@@ -64,7 +64,12 @@ describe('openMobileTerminalFileTap', () => {
|
||||
|
||||
expect(client.sendRequest).toHaveBeenCalledWith(
|
||||
'files.resolveTerminalPath',
|
||||
{ worktree: 'id:wt-1', pathText: '/tmp/result.json', terminal: 'terminal-1' },
|
||||
{
|
||||
worktree: 'id:wt-1',
|
||||
pathText: '/tmp/result.json',
|
||||
terminal: 'terminal-1',
|
||||
crossWorkspace: true
|
||||
},
|
||||
{ timeoutMs: 10_000 }
|
||||
)
|
||||
expect(pushPreviewRoute).toHaveBeenCalledWith({
|
||||
@@ -106,7 +111,7 @@ describe('openMobileTerminalFileTap', () => {
|
||||
const openedTab = { id: 'tab-2', relativePath: 'src/index.ts' }
|
||||
const switchSessionTab = vi.fn()
|
||||
|
||||
openMobileTerminalFileTap({
|
||||
openMobileFileTap({
|
||||
client,
|
||||
hostId: 'host-1',
|
||||
worktreeId: 'wt-1',
|
||||
@@ -153,7 +158,7 @@ describe('openMobileTerminalFileTap', () => {
|
||||
])
|
||||
const pushPreviewRoute = vi.fn()
|
||||
|
||||
openMobileTerminalFileTap({
|
||||
openMobileFileTap({
|
||||
client,
|
||||
hostId: 'host-1',
|
||||
worktreeId: 'wt-1',
|
||||
@@ -204,7 +209,7 @@ describe('openMobileTerminalFileTap', () => {
|
||||
const pushPreviewRoute = vi.fn()
|
||||
const triggerOpenFeedback = vi.fn()
|
||||
|
||||
openMobileTerminalFileTap({
|
||||
openMobileFileTap({
|
||||
client,
|
||||
hostId: 'host-1',
|
||||
worktreeId: 'wt-1',
|
||||
@@ -258,7 +263,7 @@ describe('openMobileTerminalFileTap', () => {
|
||||
])
|
||||
const openBrowser = vi.fn()
|
||||
|
||||
openMobileTerminalFileTap({
|
||||
openMobileFileTap({
|
||||
client,
|
||||
hostId: 'host-1',
|
||||
worktreeId: 'wt-1',
|
||||
@@ -299,7 +304,7 @@ describe('openMobileTerminalFileTap', () => {
|
||||
ok({ opened: true })
|
||||
])
|
||||
|
||||
openMobileTerminalFileTap({
|
||||
openMobileFileTap({
|
||||
client,
|
||||
hostId: 'host-1',
|
||||
worktreeId: 'wt-1',
|
||||
@@ -322,7 +327,13 @@ describe('openMobileTerminalFileTap', () => {
|
||||
|
||||
expect(client.sendRequest).toHaveBeenCalledWith(
|
||||
'files.resolveTerminalPath',
|
||||
{ worktree: 'id:wt-1', pathText: 'index.ts', terminal: 'term-1', cwd: '/repo/src' },
|
||||
{
|
||||
worktree: 'id:wt-1',
|
||||
pathText: 'index.ts',
|
||||
terminal: 'term-1',
|
||||
cwd: '/repo/src',
|
||||
crossWorkspace: true
|
||||
},
|
||||
{ timeoutMs: 10_000 }
|
||||
)
|
||||
})
|
||||
@@ -346,7 +357,7 @@ describe('openMobileTerminalFileTap', () => {
|
||||
])
|
||||
const openBrowser = vi.fn()
|
||||
|
||||
openMobileTerminalFileTap({
|
||||
openMobileFileTap({
|
||||
client,
|
||||
hostId: 'host-1',
|
||||
worktreeId: 'wt-1',
|
||||
@@ -387,7 +398,7 @@ describe('openMobileTerminalFileTap', () => {
|
||||
let activeTerminalHandle: string | null = 'terminal-1'
|
||||
const pushPreviewRoute = vi.fn()
|
||||
|
||||
openMobileTerminalFileTap({
|
||||
openMobileFileTap({
|
||||
client,
|
||||
hostId: 'host-1',
|
||||
worktreeId: 'wt-1',
|
||||
@@ -431,6 +442,180 @@ describe('openMobileTerminalFileTap', () => {
|
||||
expect(pushPreviewRoute).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports a failed files.open through onOpenFailed', async () => {
|
||||
const client = createClient([
|
||||
ok({
|
||||
worktree: 'wt-1',
|
||||
relativePath: 'src/index.ts',
|
||||
absolutePath: '/repo/src/index.ts',
|
||||
exists: true,
|
||||
isDirectory: false,
|
||||
openTarget: {
|
||||
kind: 'worktree-file',
|
||||
provider: 'local',
|
||||
relativePath: 'src/index.ts',
|
||||
absolutePath: '/repo/src/index.ts'
|
||||
}
|
||||
}),
|
||||
{ ok: false, error: { message: 'nope' } }
|
||||
])
|
||||
const onOpenFailed = vi.fn()
|
||||
|
||||
openMobileFileTap({
|
||||
client,
|
||||
hostId: 'host-1',
|
||||
worktreeId: 'wt-1',
|
||||
pathText: 'src/index.ts',
|
||||
line: null,
|
||||
column: null,
|
||||
pushPreviewRoute: vi.fn(),
|
||||
openBrowser: vi.fn(),
|
||||
triggerOpenFeedback: vi.fn(),
|
||||
fetchSessionTabs: vi.fn(),
|
||||
getSessionTabs: () => [],
|
||||
getActiveSessionTabId: () => null,
|
||||
getActivationState: activeTerminalState,
|
||||
switchSessionTab: vi.fn(),
|
||||
scheduleDelayedAction: vi.fn(),
|
||||
onOpenFailed
|
||||
})
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(onOpenFailed).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('reports an unsupported file when files.open declines it', async () => {
|
||||
const client = createClient([
|
||||
ok({
|
||||
worktree: 'wt-1',
|
||||
relativePath: 'dist/app.zip',
|
||||
absolutePath: '/repo/dist/app.zip',
|
||||
exists: true,
|
||||
isDirectory: false,
|
||||
openTarget: {
|
||||
kind: 'worktree-file',
|
||||
provider: 'local',
|
||||
relativePath: 'dist/app.zip',
|
||||
absolutePath: '/repo/dist/app.zip'
|
||||
}
|
||||
}),
|
||||
ok({ worktree: 'wt-1', relativePath: 'dist/app.zip', kind: 'binary', opened: false })
|
||||
])
|
||||
const onOpenFailed = vi.fn()
|
||||
const scheduleDelayedAction = vi.fn()
|
||||
|
||||
openMobileFileTap({
|
||||
client,
|
||||
hostId: 'host-1',
|
||||
worktreeId: 'wt-1',
|
||||
pathText: 'dist/app.zip',
|
||||
line: null,
|
||||
column: null,
|
||||
pushPreviewRoute: vi.fn(),
|
||||
openBrowser: vi.fn(),
|
||||
triggerOpenFeedback: vi.fn(),
|
||||
fetchSessionTabs: vi.fn(),
|
||||
getSessionTabs: () => [],
|
||||
getActiveSessionTabId: () => null,
|
||||
getActivationState: activeTerminalState,
|
||||
switchSessionTab: vi.fn(),
|
||||
scheduleDelayedAction,
|
||||
onOpenFailed
|
||||
})
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(onOpenFailed).toHaveBeenCalledTimes(1)
|
||||
expect(scheduleDelayedAction).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not report a stale failure after a newer tap supersedes it', async () => {
|
||||
const client = createClient([
|
||||
ok({
|
||||
worktree: 'wt-1',
|
||||
relativePath: null,
|
||||
absolutePath: null,
|
||||
exists: false,
|
||||
isDirectory: false
|
||||
})
|
||||
])
|
||||
const onOpenFailed = vi.fn()
|
||||
|
||||
openMobileFileTap({
|
||||
client,
|
||||
hostId: 'host-1',
|
||||
worktreeId: 'wt-1',
|
||||
pathText: 'gone/missing.ts',
|
||||
line: null,
|
||||
column: null,
|
||||
pushPreviewRoute: vi.fn(),
|
||||
openBrowser: vi.fn(),
|
||||
triggerOpenFeedback: vi.fn(),
|
||||
fetchSessionTabs: vi.fn(),
|
||||
getSessionTabs: () => [],
|
||||
getActiveSessionTabId: () => null,
|
||||
getActivationState: (activated) => ({
|
||||
...activeTerminalState(activated),
|
||||
latestActivationSeq: 2
|
||||
}),
|
||||
switchSessionTab: vi.fn(),
|
||||
scheduleDelayedAction: vi.fn(),
|
||||
onOpenFailed
|
||||
})
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(onOpenFailed).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not report a failure when the user left the source tab mid-resolve', async () => {
|
||||
const client = createClient([
|
||||
ok({
|
||||
worktree: 'wt-1',
|
||||
relativePath: 'src/index.ts',
|
||||
absolutePath: '/repo/src/index.ts',
|
||||
exists: true,
|
||||
isDirectory: false,
|
||||
openTarget: {
|
||||
kind: 'worktree-file',
|
||||
provider: 'local',
|
||||
relativePath: 'src/index.ts',
|
||||
absolutePath: '/repo/src/index.ts'
|
||||
}
|
||||
})
|
||||
])
|
||||
const onOpenFailed = vi.fn()
|
||||
|
||||
openMobileFileTap({
|
||||
client,
|
||||
hostId: 'host-1',
|
||||
worktreeId: 'wt-1',
|
||||
pathText: 'src/index.ts',
|
||||
line: null,
|
||||
column: null,
|
||||
pushPreviewRoute: vi.fn(),
|
||||
openBrowser: vi.fn(),
|
||||
triggerOpenFeedback: vi.fn(),
|
||||
fetchSessionTabs: vi.fn(),
|
||||
getSessionTabs: () => [],
|
||||
getActiveSessionTabId: () => null,
|
||||
getActivationState: (activated) => ({
|
||||
...activeTerminalState(activated),
|
||||
activeTerminalHandle: 'terminal-2'
|
||||
}),
|
||||
switchSessionTab: vi.fn(),
|
||||
scheduleDelayedAction: vi.fn(),
|
||||
onOpenFailed
|
||||
})
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(onOpenFailed).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not activate a worktree file tab after a newer tap supersedes it', async () => {
|
||||
const client = createClient([
|
||||
ok({
|
||||
@@ -452,7 +637,7 @@ describe('openMobileTerminalFileTap', () => {
|
||||
const openedTab = { id: 'tab-2', relativePath: 'src/index.ts' }
|
||||
const switchSessionTab = vi.fn()
|
||||
|
||||
openMobileTerminalFileTap({
|
||||
openMobileFileTap({
|
||||
client,
|
||||
hostId: 'host-1',
|
||||
worktreeId: 'wt-1',
|
||||
+42
-12
@@ -1,4 +1,7 @@
|
||||
import type { RuntimeTerminalPathResolution } from '../../../src/shared/runtime-types'
|
||||
import type {
|
||||
RuntimeFileOpenResult,
|
||||
RuntimeTerminalPathResolution
|
||||
} from '../../../src/shared/runtime-types'
|
||||
import { filesystemPathToFileUri } from '../../../src/shared/file-uri-path'
|
||||
import { createMobileFilePreviewHref } from '../files/mobile-file-preview-route'
|
||||
import { classifyMobileArtifact } from './mobile-artifact-kind'
|
||||
@@ -6,12 +9,12 @@ import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcSuccess } from '../transport/types'
|
||||
import { shouldActivateOpenedMobileSessionTab } from './opened-mobile-session-tab'
|
||||
|
||||
type TerminalFileTapSessionTab = {
|
||||
export type FileTapSessionTab = {
|
||||
id: string
|
||||
relativePath?: string
|
||||
}
|
||||
|
||||
type OpenMobileTerminalFileTapOptions<T extends TerminalFileTapSessionTab> = {
|
||||
export type OpenMobileFileTapOptions<T extends FileTapSessionTab> = {
|
||||
client: Pick<RpcClient, 'sendRequest'>
|
||||
hostId: string
|
||||
worktreeId: string
|
||||
@@ -37,19 +40,34 @@ type OpenMobileTerminalFileTapOptions<T extends TerminalFileTapSessionTab> = {
|
||||
}
|
||||
switchSessionTab: (tab: T) => void
|
||||
scheduleDelayedAction: (callback: () => void, delayMs: number) => unknown
|
||||
/** Invoked when the tap cannot open anything (resolve miss, directory, or a
|
||||
* failed open). Omitted on surfaces that keep the historical silent miss. */
|
||||
onOpenFailed?: () => void
|
||||
}
|
||||
|
||||
export function openMobileTerminalFileTap<T extends TerminalFileTapSessionTab>(
|
||||
options: OpenMobileTerminalFileTapOptions<T>
|
||||
export function openMobileFileTap<T extends FileTapSessionTab>(
|
||||
options: OpenMobileFileTapOptions<T>
|
||||
): void {
|
||||
void openMobileTerminalFileTapAsync(options).catch(() => {
|
||||
// Terminal file taps are best-effort: a failed host resolution should leave
|
||||
// terminal focus/input untouched, matching the existing silent miss behavior.
|
||||
void openMobileFileTapAsync(options).catch(() => {
|
||||
// File taps are best-effort: a failed host resolution should leave terminal
|
||||
// focus/input untouched. Surfaces that want feedback pass onOpenFailed.
|
||||
reportOpenFailure(options)
|
||||
})
|
||||
}
|
||||
|
||||
async function openMobileTerminalFileTapAsync<T extends TerminalFileTapSessionTab>(
|
||||
options: OpenMobileTerminalFileTapOptions<T>
|
||||
function reportOpenFailure<T extends FileTapSessionTab>(
|
||||
options: OpenMobileFileTapOptions<T>
|
||||
): void {
|
||||
if (
|
||||
options.onOpenFailed &&
|
||||
shouldActivateOpenedMobileSessionTab(options.getActivationState(false))
|
||||
) {
|
||||
options.onOpenFailed()
|
||||
}
|
||||
}
|
||||
|
||||
async function openMobileFileTapAsync<T extends FileTapSessionTab>(
|
||||
options: OpenMobileFileTapOptions<T>
|
||||
): Promise<void> {
|
||||
const worktree = `id:${options.worktreeId}`
|
||||
const response = await options.client.sendRequest(
|
||||
@@ -57,6 +75,8 @@ async function openMobileTerminalFileTapAsync<T extends TerminalFileTapSessionTa
|
||||
{
|
||||
worktree,
|
||||
pathText: options.pathText,
|
||||
// Why: opts into sibling-workspace resolutions; this caller honors resolved.worktree.
|
||||
crossWorkspace: true,
|
||||
...(options.terminalHandle && options.terminalHandle.trim().length > 0
|
||||
? { terminal: options.terminalHandle }
|
||||
: {}),
|
||||
@@ -65,12 +85,15 @@ async function openMobileTerminalFileTapAsync<T extends TerminalFileTapSessionTa
|
||||
{ timeoutMs: 10_000 }
|
||||
)
|
||||
if (!response.ok) {
|
||||
reportOpenFailure(options)
|
||||
return
|
||||
}
|
||||
const resolved = (response as RpcSuccess).result as RuntimeTerminalPathResolution
|
||||
if (!resolved.exists || resolved.isDirectory) {
|
||||
reportOpenFailure(options)
|
||||
return
|
||||
}
|
||||
// Not a failure: the user moved off the source tab mid-resolve.
|
||||
if (!shouldActivateOpenedMobileSessionTab(options.getActivationState(false))) {
|
||||
return
|
||||
}
|
||||
@@ -107,6 +130,7 @@ async function openMobileTerminalFileTapAsync<T extends TerminalFileTapSessionTa
|
||||
? resolved.openTarget.relativePath
|
||||
: resolved.relativePath
|
||||
if (!openedPath) {
|
||||
reportOpenFailure(options)
|
||||
return
|
||||
}
|
||||
options.triggerOpenFeedback()
|
||||
@@ -143,13 +167,19 @@ async function openMobileTerminalFileTapAsync<T extends TerminalFileTapSessionTa
|
||||
{ timeoutMs: 15_000 }
|
||||
)
|
||||
if (!openResponse.ok) {
|
||||
reportOpenFailure(options)
|
||||
return
|
||||
}
|
||||
const openResult = (openResponse as RpcSuccess).result as RuntimeFileOpenResult
|
||||
if (!openResult.opened) {
|
||||
reportOpenFailure(options)
|
||||
return
|
||||
}
|
||||
scheduleOpenedWorktreeTabActivation(options, openedPath)
|
||||
}
|
||||
|
||||
function scheduleOpenedWorktreeTabActivation<T extends TerminalFileTapSessionTab>(
|
||||
options: OpenMobileTerminalFileTapOptions<T>,
|
||||
function scheduleOpenedWorktreeTabActivation<T extends FileTapSessionTab>(
|
||||
options: OpenMobileFileTapOptions<T>,
|
||||
openedPath: string
|
||||
): void {
|
||||
let activated = false
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
applyAutocomplete,
|
||||
detectAutocompleteTrigger,
|
||||
rankSlashCommandSuggestions,
|
||||
rankSuggestions
|
||||
} from './mobile-native-chat-autocomplete'
|
||||
|
||||
@@ -57,3 +58,36 @@ describe('rankSuggestions', () => {
|
||||
expect(rankSuggestions(['a', 'b', 'c'], '', 2)).toEqual(['a', 'b'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('rankSlashCommandSuggestions', () => {
|
||||
const commands = [
|
||||
{ name: 'clear', description: 'Clear conversation history' },
|
||||
{ name: 'compact', description: 'Summarize and compact' },
|
||||
{ name: 'mcp', description: 'List MCP tools' }
|
||||
]
|
||||
|
||||
it('shows the whole catalog for a bare slash', () => {
|
||||
expect(rankSlashCommandSuggestions(commands, '').map((c) => c.name)).toEqual([
|
||||
'clear',
|
||||
'compact',
|
||||
'mcp'
|
||||
])
|
||||
})
|
||||
|
||||
it('ranks prefix matches ahead of substring matches', () => {
|
||||
expect(rankSlashCommandSuggestions(commands, 'c').map((c) => c.name)).toEqual([
|
||||
'clear',
|
||||
'compact',
|
||||
'mcp'
|
||||
])
|
||||
expect(rankSlashCommandSuggestions(commands, 'm').map((c) => c.name)).toEqual([
|
||||
'mcp',
|
||||
'compact'
|
||||
])
|
||||
})
|
||||
|
||||
it('is case-insensitive and drops non-matches', () => {
|
||||
expect(rankSlashCommandSuggestions(commands, 'CLE').map((c) => c.name)).toEqual(['clear'])
|
||||
expect(rankSlashCommandSuggestions(commands, 'zzz')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
// `/slash` commands. Detection is pure (text + cursor → active trigger) so it's
|
||||
// unit-testable and the composer stays a thin view over it.
|
||||
|
||||
import type { SlashCommandSuggestion } from '../../../src/shared/native-chat-slash-commands'
|
||||
|
||||
export type AutocompleteKind = 'file' | 'slash'
|
||||
|
||||
export type AutocompleteTrigger = {
|
||||
@@ -91,3 +93,31 @@ export function rankSuggestions(candidates: readonly string[], query: string, li
|
||||
}
|
||||
return [...prefix, ...substring].slice(0, limit)
|
||||
}
|
||||
|
||||
/** Rank an agent's slash commands: prefix matches first, then substring, in
|
||||
* catalog order. A bare `/` shows the whole catalog (desktop parity) — the
|
||||
* suggestion list scrolls, so the cap only guards against absurd catalogs. */
|
||||
export function rankSlashCommandSuggestions(
|
||||
commands: readonly SlashCommandSuggestion[],
|
||||
query: string,
|
||||
limit = 50
|
||||
): SlashCommandSuggestion[] {
|
||||
const q = query.toLowerCase()
|
||||
if (q.length === 0) {
|
||||
return commands.slice(0, limit)
|
||||
}
|
||||
const prefix: SlashCommandSuggestion[] = []
|
||||
const substring: SlashCommandSuggestion[] = []
|
||||
for (const command of commands) {
|
||||
const lower = command.name.toLowerCase()
|
||||
if (lower.startsWith(q)) {
|
||||
prefix.push(command)
|
||||
} else if (lower.includes(q)) {
|
||||
substring.push(command)
|
||||
}
|
||||
if (prefix.length >= limit) {
|
||||
break
|
||||
}
|
||||
}
|
||||
return [...prefix, ...substring].slice(0, limit)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { colors, radii, spacing, typography } from '../theme/mobile-theme'
|
||||
|
||||
export const TEXT_SIZE = 17
|
||||
export const MONO_SIZE = 12
|
||||
export const MAX_TOOL_RESULT_CHARS = 4000
|
||||
|
||||
export const styles = StyleSheet.create({
|
||||
row: {
|
||||
|
||||
@@ -1,94 +1,153 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import {
|
||||
openMobileNativeChatFile,
|
||||
resolveMobileNativeChatWorktreePath
|
||||
} from './mobile-native-chat-open-file'
|
||||
import { openMobileNativeChatFileTap } from './mobile-native-chat-open-file'
|
||||
|
||||
describe('resolveMobileNativeChatWorktreePath', () => {
|
||||
it('resolves an absolute tool path to a worktree-relative open target', async () => {
|
||||
const sendRequest = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
result: {
|
||||
exists: true,
|
||||
isDirectory: false,
|
||||
openTarget: { kind: 'worktree-file', relativePath: 'src/app.ts' }
|
||||
}
|
||||
})
|
||||
await expect(
|
||||
resolveMobileNativeChatWorktreePath({
|
||||
client: { sendRequest } as unknown as RpcClient,
|
||||
worktreeId: 'worktree',
|
||||
pathText: '/repo/src/app.ts',
|
||||
terminal: 'terminal'
|
||||
})
|
||||
).resolves.toBe('src/app.ts')
|
||||
expect(sendRequest).toHaveBeenCalledWith('files.resolveTerminalPath', {
|
||||
worktree: 'id:worktree',
|
||||
pathText: '/repo/src/app.ts',
|
||||
terminal: 'terminal'
|
||||
})
|
||||
function ok(result: unknown) {
|
||||
return { ok: true, result, _meta: { runtimeId: 'runtime-1' } }
|
||||
}
|
||||
|
||||
function activationState(activated: boolean) {
|
||||
return {
|
||||
activated,
|
||||
activationSeq: 1,
|
||||
latestActivationSeq: 1,
|
||||
sourceTerminalHandle: 'terminal-1',
|
||||
activeTerminalHandle: 'terminal-1',
|
||||
activeTabType: 'terminal'
|
||||
}
|
||||
}
|
||||
|
||||
function baseOptions(client: { sendRequest: ReturnType<typeof vi.fn> }) {
|
||||
return {
|
||||
client,
|
||||
hostId: 'host-1',
|
||||
worktreeId: 'wt-1',
|
||||
pushPreviewRoute: vi.fn(),
|
||||
openBrowser: vi.fn(),
|
||||
triggerOpenFeedback: vi.fn(),
|
||||
fetchSessionTabs: vi.fn(),
|
||||
getSessionTabs: () => [],
|
||||
getActiveSessionTabId: () => null,
|
||||
getActivationState: activationState,
|
||||
switchSessionTab: vi.fn(),
|
||||
scheduleDelayedAction: vi.fn(),
|
||||
onOpenFailed: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
function worktreeFileResolution(relativePath: string) {
|
||||
return ok({
|
||||
worktree: 'wt-1',
|
||||
relativePath,
|
||||
absolutePath: `/repo/${relativePath}`,
|
||||
exists: true,
|
||||
isDirectory: false,
|
||||
openTarget: {
|
||||
kind: 'worktree-file',
|
||||
provider: 'local',
|
||||
relativePath,
|
||||
absolutePath: `/repo/${relativePath}`
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
describe('openMobileNativeChatFileTap', () => {
|
||||
it('resolves against the worktree root: no terminal handle and no cwd', async () => {
|
||||
const sendRequest = vi.fn(async () => worktreeFileResolution('src/app.ts'))
|
||||
const options = baseOptions({ sendRequest })
|
||||
|
||||
openMobileNativeChatFileTap({ ...options, pathText: 'src/app.ts' })
|
||||
await Promise.resolve()
|
||||
|
||||
expect(sendRequest).toHaveBeenCalledWith(
|
||||
'files.resolveTerminalPath',
|
||||
{ worktree: 'id:wt-1', pathText: 'src/app.ts', crossWorkspace: true },
|
||||
{ timeoutMs: 10_000 }
|
||||
)
|
||||
})
|
||||
|
||||
it('opens only the resolved worktree-relative target', async () => {
|
||||
const sendRequest = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
result: {
|
||||
worktree: 'sibling-worktree',
|
||||
exists: true,
|
||||
isDirectory: false,
|
||||
openTarget: { kind: 'worktree-file', relativePath: 'src/app.ts' }
|
||||
}
|
||||
it('parses a :line:col citation and opens the mobile preview route', async () => {
|
||||
const sendRequest = vi.fn(async () => worktreeFileResolution('src/app.ts'))
|
||||
const options = baseOptions({ sendRequest })
|
||||
|
||||
openMobileNativeChatFileTap({ ...options, pathText: 'src/app.ts:120:7' })
|
||||
await Promise.resolve()
|
||||
|
||||
expect(sendRequest).toHaveBeenCalledWith(
|
||||
'files.resolveTerminalPath',
|
||||
{ worktree: 'id:wt-1', pathText: 'src/app.ts', crossWorkspace: true },
|
||||
{ timeoutMs: 10_000 }
|
||||
)
|
||||
expect(options.triggerOpenFeedback).toHaveBeenCalledTimes(1)
|
||||
expect(options.pushPreviewRoute).toHaveBeenCalledWith({
|
||||
pathname: '/h/[hostId]/files/preview/[worktreeId]',
|
||||
params: expect.objectContaining({
|
||||
source: 'worktree',
|
||||
relativePath: 'src/app.ts',
|
||||
line: '120',
|
||||
column: '7'
|
||||
})
|
||||
.mockResolvedValueOnce({ ok: true, result: {} })
|
||||
|
||||
await openMobileNativeChatFile({
|
||||
client: { sendRequest } as unknown as RpcClient,
|
||||
worktreeId: 'worktree',
|
||||
pathText: '../repo/src/app.ts',
|
||||
terminal: 'terminal'
|
||||
})
|
||||
|
||||
expect(sendRequest).toHaveBeenLastCalledWith('files.open', {
|
||||
worktree: 'id:sibling-worktree',
|
||||
relativePath: 'src/app.ts'
|
||||
})
|
||||
expect(options.onOpenFailed).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resolves null when the resolve request rejects', async () => {
|
||||
const sendRequest = vi.fn().mockRejectedValue(new Error('Request timed out'))
|
||||
await expect(
|
||||
resolveMobileNativeChatWorktreePath({
|
||||
client: { sendRequest } as unknown as RpcClient,
|
||||
worktreeId: 'worktree',
|
||||
pathText: 'src/app.ts',
|
||||
terminal: null
|
||||
it('surfaces a resolve miss instead of a silent no-op', async () => {
|
||||
const sendRequest = vi.fn(async () =>
|
||||
ok({
|
||||
worktree: 'wt-1',
|
||||
relativePath: null,
|
||||
absolutePath: null,
|
||||
exists: false,
|
||||
isDirectory: false
|
||||
})
|
||||
).resolves.toBeNull()
|
||||
)
|
||||
const options = baseOptions({ sendRequest })
|
||||
|
||||
openMobileNativeChatFileTap({ ...options, pathText: 'gone/missing.ts' })
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(options.onOpenFailed).toHaveBeenCalledTimes(1)
|
||||
expect(options.pushPreviewRoute).not.toHaveBeenCalled()
|
||||
expect(options.triggerOpenFeedback).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not reject when the open request fails', async () => {
|
||||
const sendRequest = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
result: {
|
||||
exists: true,
|
||||
isDirectory: false,
|
||||
openTarget: { kind: 'worktree-file', relativePath: 'src/app.ts' }
|
||||
}
|
||||
})
|
||||
.mockRejectedValueOnce(new Error('connection interrupted'))
|
||||
it('surfaces a rejected resolve request', async () => {
|
||||
const sendRequest = vi.fn(async () => {
|
||||
throw new Error('Request timed out')
|
||||
})
|
||||
const options = baseOptions({ sendRequest })
|
||||
|
||||
await expect(
|
||||
openMobileNativeChatFile({
|
||||
client: { sendRequest } as unknown as RpcClient,
|
||||
worktreeId: 'worktree',
|
||||
pathText: 'src/app.ts',
|
||||
terminal: null
|
||||
})
|
||||
).resolves.toBeUndefined()
|
||||
openMobileNativeChatFileTap({ ...options, pathText: 'src/app.ts' })
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(options.onOpenFailed).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('opens a plain path through files.open with tab activation', async () => {
|
||||
const responses: unknown[] = [worktreeFileResolution('src/app.ts'), ok({ opened: true })]
|
||||
const sendRequest = vi.fn(async () => responses.shift())
|
||||
const openedTab = { id: 'tab-2', relativePath: 'src/app.ts' }
|
||||
const switchSessionTab = vi.fn()
|
||||
const options = {
|
||||
...baseOptions({ sendRequest }),
|
||||
getSessionTabs: () => [openedTab],
|
||||
getActiveSessionTabId: () => 'terminal-tab',
|
||||
switchSessionTab,
|
||||
scheduleDelayedAction: vi.fn((callback: () => void) => callback())
|
||||
}
|
||||
|
||||
openMobileNativeChatFileTap({ ...options, pathText: 'src/app.ts' })
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
expect(sendRequest).toHaveBeenCalledWith(
|
||||
'files.open',
|
||||
{ worktree: 'id:wt-1', relativePath: 'src/app.ts' },
|
||||
{ timeoutMs: 15_000 }
|
||||
)
|
||||
expect(switchSessionTab).toHaveBeenCalledWith(openedTab)
|
||||
expect(options.onOpenFailed).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,67 +1,30 @@
|
||||
import type { RuntimeTerminalPathResolution } from '../../../src/shared/runtime-types'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { splitFilePathLineSuffix } from '../components/markdown-file-path-detection'
|
||||
import {
|
||||
openMobileFileTap,
|
||||
type FileTapSessionTab,
|
||||
type OpenMobileFileTapOptions
|
||||
} from './mobile-file-tap-open'
|
||||
|
||||
type MobileNativeChatWorktreeTarget = {
|
||||
worktreeId: string
|
||||
relativePath: string
|
||||
}
|
||||
export type OpenMobileNativeChatFileTapOptions<T extends FileTapSessionTab> = Omit<
|
||||
OpenMobileFileTapOptions<T>,
|
||||
'terminalHandle' | 'cwd' | 'line' | 'column'
|
||||
>
|
||||
|
||||
async function resolveMobileNativeChatWorktreeTarget(args: {
|
||||
client: RpcClient
|
||||
worktreeId: string
|
||||
pathText: string
|
||||
terminal: string | null
|
||||
}): Promise<MobileNativeChatWorktreeTarget | null> {
|
||||
try {
|
||||
const response = await args.client.sendRequest('files.resolveTerminalPath', {
|
||||
worktree: `id:${args.worktreeId}`,
|
||||
pathText: args.pathText,
|
||||
...(args.terminal ? { terminal: args.terminal } : {})
|
||||
})
|
||||
if (!response.ok) {
|
||||
return null
|
||||
}
|
||||
const resolved = response.result as RuntimeTerminalPathResolution
|
||||
if (!resolved.exists || resolved.isDirectory) {
|
||||
return null
|
||||
}
|
||||
const relativePath =
|
||||
resolved.openTarget?.kind === 'worktree-file'
|
||||
? resolved.openTarget.relativePath
|
||||
: (resolved.relativePath ?? null)
|
||||
return relativePath
|
||||
? { worktreeId: resolved.worktree?.trim() || args.worktreeId, relativePath }
|
||||
: null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveMobileNativeChatWorktreePath(args: {
|
||||
client: RpcClient
|
||||
worktreeId: string
|
||||
pathText: string
|
||||
terminal: string | null
|
||||
}): Promise<string | null> {
|
||||
return (await resolveMobileNativeChatWorktreeTarget(args))?.relativePath ?? null
|
||||
}
|
||||
|
||||
export async function openMobileNativeChatFile(args: {
|
||||
client: RpcClient
|
||||
worktreeId: string
|
||||
pathText: string
|
||||
terminal: string | null
|
||||
}): Promise<void> {
|
||||
const target = await resolveMobileNativeChatWorktreeTarget(args)
|
||||
if (target) {
|
||||
try {
|
||||
await args.client.sendRequest('files.open', {
|
||||
worktree: `id:${target.worktreeId}`,
|
||||
relativePath: target.relativePath
|
||||
})
|
||||
} catch {
|
||||
// Best-effort open; failures surface as a no-op rather than an
|
||||
// unhandled rejection.
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Open a file reference tapped in native chat: same haptic / preview-route /
|
||||
* tab-activation flow as terminal taps, but chat paths are worktree-root
|
||||
* relative (or absolute), so resolution deliberately passes no terminal handle
|
||||
* and no cwd — a terminal's live cwd (e.g. `<worktree>/mobile`) would misplace
|
||||
* them. Agent-style `path:line(:col)` citations carry their location through.
|
||||
*/
|
||||
export function openMobileNativeChatFileTap<T extends FileTapSessionTab>(
|
||||
options: OpenMobileNativeChatFileTapOptions<T>
|
||||
): void {
|
||||
const { path, line, column } = splitFilePathLineSuffix(options.pathText)
|
||||
openMobileFileTap<T>({
|
||||
...options,
|
||||
pathText: path,
|
||||
line,
|
||||
column
|
||||
})
|
||||
}
|
||||
|
||||
@@ -13,6 +13,11 @@ import {
|
||||
markMobileNativeChatInputStale,
|
||||
resetMobileNativeChatStaleInputForTests
|
||||
} from './mobile-native-chat-stale-input'
|
||||
import {
|
||||
acquireMobileNativeChatTerminalWrite,
|
||||
releaseMobileNativeChatTerminalWrite,
|
||||
resetMobileNativeChatTerminalWritesForTests
|
||||
} from './mobile-native-chat-terminal-write-lock'
|
||||
|
||||
describe('sendMobileNativeChatPermissionResponse', () => {
|
||||
it('writes an approval as raw bytes without appending Return', async () => {
|
||||
@@ -64,6 +69,7 @@ describe('useMobileNativeChatPermissionSend', () => {
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
resetMobileNativeChatStaleInputForTests()
|
||||
resetMobileNativeChatTerminalWritesForTests()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -102,4 +108,42 @@ describe('useMobileNativeChatPermissionSend', () => {
|
||||
expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ text: '1', enter: false })
|
||||
expect(isMobileNativeChatInputStale('terminal')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a choice while another composed write holds the terminal, then recovers', async () => {
|
||||
const onSendError = vi.fn()
|
||||
const sendRequest = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
result: { send: { handle: 'terminal', accepted: true, bytesWritten: 1 } }
|
||||
})
|
||||
function Harness(): null {
|
||||
respond = useMobileNativeChatPermissionSend({
|
||||
client: { sendRequest } as unknown as RpcClient,
|
||||
enabled: true,
|
||||
handleRef: { current: 'terminal' },
|
||||
deviceTokenRef: { current: null },
|
||||
onSendError
|
||||
})
|
||||
return null
|
||||
}
|
||||
act(() => {
|
||||
renderer = create(createElement(Harness))
|
||||
})
|
||||
|
||||
// An image paste sequence is mid-flight into the same PTY: the choice
|
||||
// keystroke must not interleave into it.
|
||||
expect(acquireMobileNativeChatTerminalWrite('terminal')).toBe(true)
|
||||
await act(async () => {
|
||||
await expect(respond?.('1')).resolves.toBe(false)
|
||||
})
|
||||
expect(sendRequest).not.toHaveBeenCalled()
|
||||
expect(onSendError).toHaveBeenCalledWith('Response not sent')
|
||||
|
||||
releaseMobileNativeChatTerminalWrite('terminal')
|
||||
await act(async () => {
|
||||
await expect(respond?.('1')).resolves.toBe(true)
|
||||
})
|
||||
// The choice released its own hold on the way out.
|
||||
expect(acquireMobileNativeChatTerminalWrite('terminal')).toBe(true)
|
||||
releaseMobileNativeChatTerminalWrite('terminal')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,6 +4,10 @@ import {
|
||||
sendMobileNativeChatMessageWithOutcome,
|
||||
type MobileNativeChatSendOutcome
|
||||
} from './mobile-native-chat-send'
|
||||
import {
|
||||
acquireMobileNativeChatTerminalWrite,
|
||||
releaseMobileNativeChatTerminalWrite
|
||||
} from './mobile-native-chat-terminal-write-lock'
|
||||
|
||||
export function sendMobileNativeChatPermissionResponse(args: {
|
||||
client: RpcClient
|
||||
@@ -36,15 +40,26 @@ export function useMobileNativeChatPermissionSend(args: {
|
||||
args.onSendError('Response not sent (disconnected)')
|
||||
return false
|
||||
}
|
||||
// A choice keystroke must not interleave into a mid-flight composed write
|
||||
// (image paste, paced answer) on the same PTY.
|
||||
if (!acquireMobileNativeChatTerminalWrite(terminal)) {
|
||||
args.onSendError('Response not sent')
|
||||
return false
|
||||
}
|
||||
// No stale-input heal here (unlike the text/ask sends): a choice is an
|
||||
// `enter: false` key for an active overlay that swallows the clear, so it
|
||||
// would consume the marker still protecting the next real message.
|
||||
const outcome = await sendMobileNativeChatPermissionResponse({
|
||||
client: args.client,
|
||||
terminal,
|
||||
deviceToken: args.deviceTokenRef.current,
|
||||
text
|
||||
})
|
||||
let outcome: MobileNativeChatSendOutcome
|
||||
try {
|
||||
outcome = await sendMobileNativeChatPermissionResponse({
|
||||
client: args.client,
|
||||
terminal,
|
||||
deviceToken: args.deviceTokenRef.current,
|
||||
text
|
||||
})
|
||||
} finally {
|
||||
releaseMobileNativeChatTerminalWrite(terminal)
|
||||
}
|
||||
if (outcome === 'unknown') {
|
||||
// Why: the response may have been delivered (ack lost / path cutover) —
|
||||
// a definite "not sent" would invite a double answer.
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import {
|
||||
buildMobileNativeChatData,
|
||||
buildMobileNativeChatTransientData,
|
||||
foldMobileNativeChatMessages,
|
||||
mobileNativeChatEmptyState
|
||||
} from './mobile-native-chat-render-data'
|
||||
|
||||
@@ -50,13 +51,22 @@ describe('mobileNativeChatEmptyState', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildMobileNativeChatData', () => {
|
||||
/** Mirrors the view: fold the raw transcript, then assemble the list. */
|
||||
function build(
|
||||
messages: NativeChatMessage[],
|
||||
streaming: string | null,
|
||||
pending: Parameters<typeof buildMobileNativeChatTransientData>[0]['pending']
|
||||
): NativeChatMessage[] {
|
||||
return buildMobileNativeChatTransientData({
|
||||
folded: foldMobileNativeChatMessages(messages),
|
||||
streaming,
|
||||
pending
|
||||
}).data
|
||||
}
|
||||
|
||||
describe('buildMobileNativeChatTransientData', () => {
|
||||
it('appends pending optimistic messages at the tail as user turns', () => {
|
||||
const { data } = buildMobileNativeChatData({
|
||||
messages: [assistant('a1', 'hello')],
|
||||
streamingText: undefined,
|
||||
pending: [{ id: 'p1', text: 'queued' }]
|
||||
})
|
||||
const data = build([assistant('a1', 'hello')], null, [{ id: 'p1', text: 'queued' }])
|
||||
const last = data[data.length - 1]
|
||||
expect(last.id).toBe('p1')
|
||||
expect(last.role).toBe('user')
|
||||
@@ -64,10 +74,9 @@ describe('buildMobileNativeChatData', () => {
|
||||
})
|
||||
|
||||
it('renders a pending send with images as text followed by image-ref thumbnails', () => {
|
||||
const { data } = buildMobileNativeChatData({
|
||||
messages: [],
|
||||
pending: [{ id: 'p1', text: 'look', images: ['file:///a.jpg', 'file:///b.jpg'] }]
|
||||
})
|
||||
const data = build([], null, [
|
||||
{ id: 'p1', text: 'look', images: ['file:///a.jpg', 'file:///b.jpg'] }
|
||||
])
|
||||
const last = data[data.length - 1]
|
||||
expect(last.role).toBe('user')
|
||||
expect(last.blocks).toEqual([
|
||||
@@ -78,10 +87,7 @@ describe('buildMobileNativeChatData', () => {
|
||||
})
|
||||
|
||||
it('renders an image-only pending send (no text) as just the thumbnail', () => {
|
||||
const { data } = buildMobileNativeChatData({
|
||||
messages: [],
|
||||
pending: [{ id: 'p1', text: '', images: ['file:///a.jpg'] }]
|
||||
})
|
||||
const data = build([], null, [{ id: 'p1', text: '', images: ['file:///a.jpg'] }])
|
||||
expect(data[data.length - 1].blocks).toEqual([{ type: 'image-ref', url: 'file:///a.jpg' }])
|
||||
})
|
||||
|
||||
@@ -89,14 +95,15 @@ describe('buildMobileNativeChatData', () => {
|
||||
// Claude records an attached image as `[Image: source: /path]` + an
|
||||
// `[Image #1] `-prefixed caption turn; the fold must merge them into one
|
||||
// user turn with an image-ref block instead of showing raw marker text.
|
||||
const { data } = buildMobileNativeChatData({
|
||||
messages: [
|
||||
const data = build(
|
||||
[
|
||||
user('u1', '[Image: source: /tmp/a.png]'),
|
||||
user('u2', '[Image #1] look at this'),
|
||||
assistant('a1', 'nice photo')
|
||||
],
|
||||
pending: []
|
||||
})
|
||||
null,
|
||||
[]
|
||||
)
|
||||
const merged = data.find((message) => message.role === 'user')
|
||||
expect(merged?.blocks).toEqual([
|
||||
{ type: 'image-ref', path: '/tmp/a.png' },
|
||||
@@ -105,49 +112,20 @@ describe('buildMobileNativeChatData', () => {
|
||||
})
|
||||
|
||||
it('renders a lone image marker turn (no caption) as an image-ref block', () => {
|
||||
const { data } = buildMobileNativeChatData({
|
||||
messages: [user('u1', '[Image: source: /tmp/a.png]')],
|
||||
pending: []
|
||||
})
|
||||
const data = build([user('u1', '[Image: source: /tmp/a.png]')], null, [])
|
||||
expect(data[0]?.blocks).toEqual([{ type: 'image-ref', path: '/tmp/a.png' }])
|
||||
})
|
||||
|
||||
it('adds a synthetic streaming bubble while the partial text leads the transcript', () => {
|
||||
const { streaming, data } = buildMobileNativeChatData({
|
||||
messages: [user('u1', 'hi')],
|
||||
streamingText: 'thinking out loud',
|
||||
pending: []
|
||||
})
|
||||
expect(streaming).toBe('thinking out loud')
|
||||
expect(data.some((m) => m.id === 'streaming')).toBe(true)
|
||||
it('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.
|
||||
const data = build([user('u1', 'hi')], 'thinking out loud', [{ id: 'p1', text: 'queued' }])
|
||||
expect(data.map((message) => message.id)).toEqual(['u1', 'streaming', 'p1'])
|
||||
expect(data[1].blocks).toEqual([{ type: 'text', text: 'thinking out loud' }])
|
||||
})
|
||||
|
||||
it('shows a short new streaming reply even after a longer previous turn', () => {
|
||||
// The last folded turn is a long completed reply; a short new stream must not
|
||||
// be suppressed just for being shorter than the prior turn.
|
||||
const { streaming, data } = buildMobileNativeChatData({
|
||||
messages: [assistant('a1', 'This is a long completed previous answer that ran on a while')],
|
||||
streamingText: 'Ok',
|
||||
pending: []
|
||||
})
|
||||
expect(streaming).toBe('Ok')
|
||||
expect(data.some((m) => m.id === 'streaming')).toBe(true)
|
||||
})
|
||||
|
||||
it('drops the streaming bubble once the real assistant turn already contains it', () => {
|
||||
const { streaming, data } = buildMobileNativeChatData({
|
||||
messages: [assistant('a1', 'done answer')],
|
||||
streamingText: 'done',
|
||||
pending: []
|
||||
})
|
||||
expect(streaming).toBeNull()
|
||||
expect(data.some((m) => m.id === 'streaming')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns no streaming bubble for empty/whitespace streaming text', () => {
|
||||
expect(
|
||||
buildMobileNativeChatData({ messages: [], streamingText: ' ', pending: [] }).streaming
|
||||
).toBeNull()
|
||||
expect(buildMobileNativeChatData({ messages: [], pending: [] }).streaming).toBeNull()
|
||||
it('omits the bubble when the gate withheld the streaming text', () => {
|
||||
const data = build([assistant('a1', 'done answer')], null, [])
|
||||
expect(data.some((message) => message.id === 'streaming')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -43,41 +43,25 @@ export type MobileNativeChatPendingItem = {
|
||||
images?: string[]
|
||||
}
|
||||
|
||||
/** Derive the list data from the raw transcript: fold tool turns into the
|
||||
* assistant turn, optionally append a synthetic streaming bubble, then the
|
||||
* route-owned optimistic "queued" messages at the tail. Returns the
|
||||
* intermediate `folded`/`streaming` so the caller can memoize on them. */
|
||||
export function buildMobileNativeChatData({
|
||||
messages,
|
||||
streamingText,
|
||||
pending
|
||||
}: {
|
||||
messages: NativeChatMessage[]
|
||||
streamingText?: string
|
||||
pending: MobileNativeChatPendingItem[]
|
||||
}): { folded: NativeChatMessage[]; streaming: string | null; data: NativeChatMessage[] } {
|
||||
const folded = foldMobileNativeChatMessages(messages)
|
||||
return buildMobileNativeChatTransientData({ folded, streamingText, pending })
|
||||
}
|
||||
|
||||
export function foldMobileNativeChatMessages(messages: NativeChatMessage[]): NativeChatMessage[] {
|
||||
// Normalize first (desktop assembler parity): image marker turns fold into
|
||||
// image-ref blocks instead of rendering as raw `[Image: …]` text.
|
||||
return foldToolMessages(stripNoiseMessages(normalizeImageTranscriptMessages(messages)))
|
||||
}
|
||||
|
||||
/** Assemble the list data the chat renders: the folded transcript, then a
|
||||
* synthetic bubble for the streaming text the gate let through, then the
|
||||
* route-owned optimistic "queued" messages at the tail. */
|
||||
export function buildMobileNativeChatTransientData({
|
||||
folded,
|
||||
streamingText,
|
||||
streaming,
|
||||
pending
|
||||
}: {
|
||||
folded: NativeChatMessage[]
|
||||
streamingText?: string
|
||||
/** Streaming bubble text, already gated by `deriveMobileNativeChatStreaming`. */
|
||||
streaming: string | null
|
||||
pending: MobileNativeChatPendingItem[]
|
||||
}): { folded: NativeChatMessage[]; streaming: string | null; data: NativeChatMessage[] } {
|
||||
// Only show the streaming bubble while its text leads the transcript — once the
|
||||
// real assistant turn lands with the same text, drop the synthetic one.
|
||||
const streaming = deriveStreaming(folded, streamingText)
|
||||
const data: NativeChatMessage[] = [
|
||||
...folded,
|
||||
...(streaming
|
||||
@@ -106,26 +90,3 @@ export function buildMobileNativeChatTransientData({
|
||||
]
|
||||
return { folded, streaming, data }
|
||||
}
|
||||
|
||||
function deriveStreaming(folded: NativeChatMessage[], streamingText?: string): string | null {
|
||||
const text = streamingText?.trim()
|
||||
if (!text) {
|
||||
return null
|
||||
}
|
||||
const last = folded[folded.length - 1]
|
||||
const lastText =
|
||||
last?.role === 'assistant'
|
||||
? last.blocks
|
||||
.filter((b) => b.type === 'text')
|
||||
.map((b) => (b.type === 'text' ? b.text : ''))
|
||||
.join('')
|
||||
.trim()
|
||||
: ''
|
||||
// Hide the synthetic bubble only once the real turn has landed leading with the
|
||||
// streamed text. A bare length compare would suppress a short new reply behind a
|
||||
// longer previous turn; a completed prior turn won't start with the new prefix.
|
||||
if (lastText.startsWith(text)) {
|
||||
return null
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { classifyMobileNativeChatSend } from './mobile-native-chat-send-classification'
|
||||
|
||||
describe('classifyMobileNativeChatSend', () => {
|
||||
it('classifies catalog commands per agent', () => {
|
||||
expect(classifyMobileNativeChatSend('claude', '/clear')).toBe('command')
|
||||
expect(classifyMobileNativeChatSend('claude', '/compact')).toBe('command')
|
||||
expect(classifyMobileNativeChatSend('codex', '/model')).toBe('command')
|
||||
expect(classifyMobileNativeChatSend('codex', '/permissions')).toBe('command')
|
||||
})
|
||||
|
||||
it('treats slash tokens outside the agent catalog as unknown, never chat', () => {
|
||||
// `/model` is not a verified Claude command — it still dispatches to the
|
||||
// TUI, so it must not get a chat bubble, but it can't claim a command ran.
|
||||
expect(classifyMobileNativeChatSend('claude', '/model sonnet')).toBe('unknown-token')
|
||||
expect(classifyMobileNativeChatSend('claude', '/cost')).toBe('unknown-token')
|
||||
expect(classifyMobileNativeChatSend('claude', '/diff')).toBe('unknown-token')
|
||||
})
|
||||
|
||||
it('keeps prose as chat, including leading-whitespace slash text', () => {
|
||||
expect(classifyMobileNativeChatSend('claude', 'hello there')).toBe('chat')
|
||||
expect(classifyMobileNativeChatSend('claude', ' /clear is a command')).toBe('chat')
|
||||
expect(classifyMobileNativeChatSend('claude', '/usr/bin/python is missing')).toBe(
|
||||
'unknown-token'
|
||||
)
|
||||
})
|
||||
|
||||
it('treats $ tokens as skill grammar only for Codex', () => {
|
||||
expect(classifyMobileNativeChatSend('codex', '$deploy now')).toBe('unknown-token')
|
||||
expect(classifyMobileNativeChatSend('claude', '$PATH is empty')).toBe('chat')
|
||||
})
|
||||
|
||||
it('defaults to chat when no agent is resolved', () => {
|
||||
expect(classifyMobileNativeChatSend(null, '/clear')).toBe('chat')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
// Mobile counterpart of desktop's send classification gate
|
||||
// (src/renderer/src/components/native-chat/NativeChatComposer.tsx): slash/skill
|
||||
// sends are TUI control actions, not chat turns — they never echo as a user
|
||||
// bubble, because the transcript will never contain a matching user turn and
|
||||
// the optimistic echo would sit at "Queued" forever.
|
||||
|
||||
import {
|
||||
getNativeChatAgentProfile,
|
||||
getVerifiedNativeChatCommands
|
||||
} from '../../../src/shared/native-chat-agent-profiles'
|
||||
import {
|
||||
classifyNativeChatSend,
|
||||
type NativeChatSendClassification
|
||||
} from '../../../src/shared/native-chat-slash-commands'
|
||||
|
||||
export type { NativeChatSendClassification }
|
||||
|
||||
/** Classify a mobile chat send for the tab's agent. Mobile has no skill picker,
|
||||
* so there is never a picker-origin token that reclassifies a `/token` as chat. */
|
||||
export function classifyMobileNativeChatSend(
|
||||
agent: string | null,
|
||||
text: string
|
||||
): NativeChatSendClassification {
|
||||
if (!agent) {
|
||||
return 'chat'
|
||||
}
|
||||
const profile = getNativeChatAgentProfile(agent)
|
||||
return classifyNativeChatSend(
|
||||
text,
|
||||
getVerifiedNativeChatCommands(agent),
|
||||
null,
|
||||
profile?.skillPrefix ?? null
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// Mobile port of desktop's pill labeling
|
||||
// (src/renderer/src/components/native-chat/native-chat-session-option-labels.ts),
|
||||
// minus i18n — mobile renders plain strings throughout.
|
||||
|
||||
import type {
|
||||
SessionOptionDescriptor,
|
||||
SessionOptionDisabledReason,
|
||||
SessionOptionSelectChoice
|
||||
} from '../../../src/shared/native-chat-session-options'
|
||||
|
||||
export function mobileSessionOptionDisabledReason(
|
||||
reason: SessionOptionDisabledReason | undefined
|
||||
): string | null {
|
||||
// Exhaustive over SessionOptionDisabledReason so new keys are a compile error.
|
||||
switch (reason) {
|
||||
case 'set-when-session-starts':
|
||||
return 'Set when the session starts.'
|
||||
case 'available-after-session-start':
|
||||
return 'Available after the session starts.'
|
||||
case undefined:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function selectedChoiceLabel(descriptor: SessionOptionDescriptor): string | null {
|
||||
if (
|
||||
descriptor.valueSource === 'unknown' ||
|
||||
descriptor.kind.type !== 'select' ||
|
||||
!descriptor.kind.currentValue
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const current = descriptor.kind.currentValue
|
||||
const choice: SessionOptionSelectChoice = descriptor.kind.choices.find(
|
||||
(candidate) => candidate.value === current
|
||||
) ?? { value: current, label: current }
|
||||
return choice.label
|
||||
}
|
||||
|
||||
/** Value-only pill text — the category lives on the sheet title, not the pill. */
|
||||
export function mobileModelPillLabel(descriptor: SessionOptionDescriptor): string {
|
||||
return selectedChoiceLabel(descriptor) ?? 'Model'
|
||||
}
|
||||
|
||||
export function mobileSessionOptionSummaryValue(descriptor: SessionOptionDescriptor): string {
|
||||
if (descriptor.valueSource === 'unknown') {
|
||||
return 'Not set'
|
||||
}
|
||||
if (descriptor.kind.type === 'select') {
|
||||
return selectedChoiceLabel(descriptor) ?? 'Not set'
|
||||
}
|
||||
return descriptor.kind.currentValue === undefined
|
||||
? 'Not set'
|
||||
: descriptor.kind.currentValue
|
||||
? 'On'
|
||||
: 'Off'
|
||||
}
|
||||
|
||||
export function mobileOptionsPillLabel(descriptors: readonly SessionOptionDescriptor[]): string {
|
||||
const labels: string[] = []
|
||||
for (const descriptor of descriptors) {
|
||||
if (descriptor.valueSource === 'unknown') {
|
||||
continue
|
||||
}
|
||||
if (descriptor.kind.type === 'select') {
|
||||
const label = selectedChoiceLabel(descriptor)
|
||||
if (label) {
|
||||
labels.push(label)
|
||||
}
|
||||
} else if (descriptor.kind.currentValue === true) {
|
||||
labels.push(descriptor.id === 'fastMode' ? 'Fast' : descriptor.label)
|
||||
}
|
||||
}
|
||||
if (labels.length > 0) {
|
||||
return labels.join(' · ')
|
||||
}
|
||||
const effort = descriptors.find((descriptor) => descriptor.id === 'effort')
|
||||
return effort ? effort.label : 'Options'
|
||||
}
|
||||
@@ -32,7 +32,8 @@ describe('applyMobileNativeChatStreamFrame', () => {
|
||||
kind: 'messages',
|
||||
messages: [message('a'), message('b')],
|
||||
hasMore: true,
|
||||
beforeOffset: 123
|
||||
beforeOffset: 123,
|
||||
windowReplaced: true
|
||||
})
|
||||
})
|
||||
|
||||
@@ -42,7 +43,12 @@ describe('applyMobileNativeChatStreamFrame', () => {
|
||||
|
||||
const result = applyMobileNativeChatStreamFrame({
|
||||
merger,
|
||||
frame: { type: 'snapshot', messages: [message('b'), message('c'), message('d')] },
|
||||
frame: {
|
||||
type: 'snapshot',
|
||||
messages: [message('b'), message('c'), message('d')],
|
||||
hasMore: true,
|
||||
beforeOffset: 20
|
||||
},
|
||||
limit: 3,
|
||||
replaceSnapshot: false
|
||||
})
|
||||
@@ -50,7 +56,207 @@ describe('applyMobileNativeChatStreamFrame', () => {
|
||||
expect(result).toMatchObject({
|
||||
kind: 'messages',
|
||||
messages: [message('b'), message('c'), message('d')],
|
||||
cursorInvalidated: true
|
||||
cursorInvalidated: true,
|
||||
hasMore: true
|
||||
})
|
||||
})
|
||||
|
||||
it('refreshes paging metadata when a replay still starts at the retained oldest row', () => {
|
||||
const merger = createNativeChatMerger()
|
||||
replaceList(merger, [message('a'), message('b')])
|
||||
|
||||
expect(
|
||||
applyMobileNativeChatStreamFrame({
|
||||
merger,
|
||||
frame: {
|
||||
type: 'snapshot',
|
||||
messages: [message('a'), message('b')],
|
||||
hasMore: false,
|
||||
beforeOffset: 0
|
||||
},
|
||||
limit: 40,
|
||||
replaceSnapshot: false
|
||||
})
|
||||
).toEqual({
|
||||
kind: 'messages',
|
||||
messages: [message('a'), message('b')],
|
||||
hasMore: false,
|
||||
beforeOffset: 0
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps paged-in history when a reconnect replay overlaps it', () => {
|
||||
const merger = createNativeChatMerger()
|
||||
replaceList(merger, ['p1', 'p2', 'a', 'b'].map(message))
|
||||
|
||||
const result = applyMobileNativeChatStreamFrame({
|
||||
merger,
|
||||
// Replayed window: the retained tail plus what arrived while disconnected.
|
||||
frame: { type: 'snapshot', messages: [message('a'), message('b'), message('c')] },
|
||||
limit: 100,
|
||||
replaceSnapshot: false
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: 'messages',
|
||||
messages: ['p1', 'p2', 'a', 'b', 'c'].map(message)
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores replay paging metadata that describes a row other than our oldest', () => {
|
||||
const merger = createNativeChatMerger()
|
||||
replaceList(merger, ['p1', 'p2', 'a', 'b'].map(message))
|
||||
|
||||
// `beforeOffset` here points before 'a', not before 'p1'. Adopting it would
|
||||
// make the next loadEarlier re-fetch 'p1'/'p2' and prepend them twice.
|
||||
expect(
|
||||
applyMobileNativeChatStreamFrame({
|
||||
merger,
|
||||
frame: {
|
||||
type: 'snapshot',
|
||||
messages: [message('a'), message('b')],
|
||||
hasMore: true,
|
||||
beforeOffset: 500
|
||||
},
|
||||
limit: 100,
|
||||
replaceSnapshot: false
|
||||
})
|
||||
).toEqual({ kind: 'messages', messages: ['p1', 'p2', 'a', 'b'].map(message) })
|
||||
})
|
||||
|
||||
it('replaces the window when a reconnect replay is disjoint from history', () => {
|
||||
const merger = createNativeChatMerger()
|
||||
replaceList(merger, [message('old-1'), message('old-2')])
|
||||
|
||||
// A long outage (or compaction while away) cannot be stitched without a gap.
|
||||
const result = applyMobileNativeChatStreamFrame({
|
||||
merger,
|
||||
frame: {
|
||||
type: 'snapshot',
|
||||
messages: [message('fresh-1'), message('fresh-2')],
|
||||
hasMore: true,
|
||||
beforeOffset: 900
|
||||
},
|
||||
limit: 100,
|
||||
replaceSnapshot: false
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: 'messages',
|
||||
messages: [message('fresh-1'), message('fresh-2')],
|
||||
hasMore: true,
|
||||
beforeOffset: 900,
|
||||
windowReplaced: true
|
||||
})
|
||||
})
|
||||
|
||||
it('replaces a partially overlapping replay that no longer extends the retained tail', () => {
|
||||
const merger = createNativeChatMerger()
|
||||
replaceList(merger, ['old-1', 'shared', 'old-tail'].map(message))
|
||||
|
||||
// `hasMore: true` so the authoritative-removal rule can't short-circuit —
|
||||
// the contiguity scan itself must reject the interleaved new message.
|
||||
const replay = [message('shared'), message('compacted-summary'), message('old-tail')]
|
||||
expect(
|
||||
applyMobileNativeChatStreamFrame({
|
||||
merger,
|
||||
frame: { type: 'snapshot', messages: replay, hasMore: true, beforeOffset: 9 },
|
||||
limit: 100,
|
||||
replaceSnapshot: false
|
||||
})
|
||||
).toEqual({
|
||||
kind: 'messages',
|
||||
messages: replay,
|
||||
hasMore: true,
|
||||
beforeOffset: 9,
|
||||
windowReplaced: true
|
||||
})
|
||||
})
|
||||
|
||||
it('replaces a replay that repeats retained ids out of order', () => {
|
||||
const merger = createNativeChatMerger()
|
||||
replaceList(merger, ['a', 'b', 'c'].map(message))
|
||||
|
||||
const replay = [message('a'), message('c'), message('b')]
|
||||
expect(
|
||||
applyMobileNativeChatStreamFrame({
|
||||
merger,
|
||||
frame: { type: 'snapshot', messages: replay, hasMore: true, beforeOffset: 4 },
|
||||
limit: 100,
|
||||
replaceSnapshot: false
|
||||
})
|
||||
).toEqual({
|
||||
kind: 'messages',
|
||||
messages: replay,
|
||||
hasMore: true,
|
||||
beforeOffset: 4,
|
||||
windowReplaced: true
|
||||
})
|
||||
})
|
||||
|
||||
it('replaces a replay that stops short of the retained newest row', () => {
|
||||
const merger = createNativeChatMerger()
|
||||
replaceList(merger, ['a', 'b', 'c'].map(message))
|
||||
|
||||
// Merging would keep 'c', a row the replayed window no longer carries.
|
||||
const replay = [message('a'), message('b')]
|
||||
expect(
|
||||
applyMobileNativeChatStreamFrame({
|
||||
merger,
|
||||
frame: { type: 'snapshot', messages: replay, hasMore: true, beforeOffset: 1 },
|
||||
limit: 100,
|
||||
replaceSnapshot: false
|
||||
})
|
||||
).toEqual({
|
||||
kind: 'messages',
|
||||
messages: replay,
|
||||
hasMore: true,
|
||||
beforeOffset: 1,
|
||||
windowReplaced: true
|
||||
})
|
||||
})
|
||||
|
||||
it('replaces retained history when replay metadata says no earlier rows remain', () => {
|
||||
const merger = createNativeChatMerger()
|
||||
replaceList(merger, ['removed-1', 'removed-2', 'a', 'b'].map(message))
|
||||
|
||||
const replay = [message('a'), message('b')]
|
||||
expect(
|
||||
applyMobileNativeChatStreamFrame({
|
||||
merger,
|
||||
frame: { type: 'snapshot', messages: replay, hasMore: false, beforeOffset: 0 },
|
||||
limit: 100,
|
||||
replaceSnapshot: false
|
||||
})
|
||||
).toEqual({
|
||||
kind: 'messages',
|
||||
messages: replay,
|
||||
hasMore: false,
|
||||
beforeOffset: 0,
|
||||
windowReplaced: true
|
||||
})
|
||||
})
|
||||
|
||||
it('replaces retained history when a single row precedes an authoritative replay', () => {
|
||||
const merger = createNativeChatMerger()
|
||||
replaceList(merger, ['removed-1', 'a', 'b'].map(message))
|
||||
|
||||
// Boundary of the `firstIndex > 0` rule: one dropped row is still a dropped
|
||||
// row, and merging here would strand 'removed-1' the host no longer has.
|
||||
const replay = [message('a'), message('b')]
|
||||
expect(
|
||||
applyMobileNativeChatStreamFrame({
|
||||
merger,
|
||||
frame: { type: 'snapshot', messages: replay, hasMore: false, beforeOffset: 0 },
|
||||
limit: 100,
|
||||
replaceSnapshot: false
|
||||
})
|
||||
).toEqual({
|
||||
kind: 'messages',
|
||||
messages: replay,
|
||||
hasMore: false,
|
||||
beforeOffset: 0,
|
||||
windowReplaced: true
|
||||
})
|
||||
})
|
||||
|
||||
@@ -79,7 +285,39 @@ describe('applyMobileNativeChatStreamFrame', () => {
|
||||
limit: 40,
|
||||
replaceSnapshot: false
|
||||
})
|
||||
).toEqual({ kind: 'messages', messages: [message('new')], hasMore: false })
|
||||
).toEqual({
|
||||
kind: 'messages',
|
||||
messages: [message('new')],
|
||||
hasMore: false,
|
||||
windowReplaced: true
|
||||
})
|
||||
})
|
||||
|
||||
it('still treats the base snapshot as authoritative after a replacement frame', () => {
|
||||
const merger = createNativeChatMerger()
|
||||
replaceList(merger, ['a', 'b'].map(message))
|
||||
|
||||
// A replacement is not this subscription's base snapshot, so the first real
|
||||
// snapshot still replaces — merging would keep 'a', which it does not carry.
|
||||
expect(
|
||||
applyMobileNativeChatStreamFrame({
|
||||
merger,
|
||||
frame: {
|
||||
type: 'snapshot',
|
||||
messages: [message('b'), message('c')],
|
||||
hasMore: true,
|
||||
beforeOffset: 77
|
||||
},
|
||||
limit: 100,
|
||||
replaceSnapshot: true
|
||||
})
|
||||
).toEqual({
|
||||
kind: 'messages',
|
||||
messages: [message('b'), message('c')],
|
||||
hasMore: true,
|
||||
beforeOffset: 77,
|
||||
windowReplaced: true
|
||||
})
|
||||
})
|
||||
|
||||
it('surfaces snapshot errors and ignores unrelated frames', () => {
|
||||
|
||||
@@ -19,10 +19,47 @@ export type AppliedMobileNativeChatFrame =
|
||||
hasMore?: boolean
|
||||
beforeOffset?: number
|
||||
cursorInvalidated?: boolean
|
||||
/** The frame replaced the whole retained window (replacement, first
|
||||
* snapshot, or a replay snapshot disjoint from local history) — the
|
||||
* caller must reset its paging window/cursor to the frame's. */
|
||||
windowReplaced?: boolean
|
||||
}
|
||||
|
||||
function replayRetainedTailStart(
|
||||
merger: NativeChatMerger,
|
||||
messages: readonly NativeChatMessage[],
|
||||
hasMore: boolean | undefined
|
||||
): number | null {
|
||||
const firstIndex = messages[0] ? merger.indexById.get(messages[0].id) : undefined
|
||||
if (firstIndex === undefined) {
|
||||
return null
|
||||
}
|
||||
// `hasMore: false` is authoritative: retained rows before the replay window
|
||||
// were removed while disconnected, even when the newest IDs still match.
|
||||
if (hasMore === false && firstIndex > 0) {
|
||||
return null
|
||||
}
|
||||
let expectedIndex = firstIndex
|
||||
let sawNewMessage = false
|
||||
for (const message of messages) {
|
||||
const existingIndex = merger.indexById.get(message.id)
|
||||
if (existingIndex === undefined) {
|
||||
sawNewMessage = true
|
||||
} else if (sawNewMessage || existingIndex !== expectedIndex) {
|
||||
return null
|
||||
} else {
|
||||
expectedIndex += 1
|
||||
}
|
||||
}
|
||||
return expectedIndex === merger.list.length ? firstIndex : null
|
||||
}
|
||||
|
||||
/** Applies runtime stream frames while preserving the initial-snapshot versus
|
||||
* reconnect-replay distinction owned by the session hook. */
|
||||
* reconnect-replay distinction owned by the session hook. A replay snapshot
|
||||
* that extends a contiguous retained tail merges in by id — paged-in history
|
||||
* survives a socket blip instead of collapsing to the replayed window. A
|
||||
* discontinuous replay (long outage, compaction while away) can't be stitched
|
||||
* without a gap, so it falls back to the fresh authoritative window. */
|
||||
export function applyMobileNativeChatStreamFrame(args: {
|
||||
merger: NativeChatMerger
|
||||
frame: MobileNativeChatStreamFrame
|
||||
@@ -42,22 +79,39 @@ export function applyMobileNativeChatStreamFrame(args: {
|
||||
if (!Array.isArray(frame.messages)) {
|
||||
return { kind: 'ignored' }
|
||||
}
|
||||
if (frame.type === 'replacement' || (frame.type === 'snapshot' && replaceSnapshot)) {
|
||||
const replayStartIndex =
|
||||
frame.type === 'snapshot' && !replaceSnapshot && merger.list.length > 0
|
||||
? replayRetainedTailStart(merger, frame.messages, frame.hasMore)
|
||||
: null
|
||||
if (frame.type === 'replacement' || (frame.type === 'snapshot' && replayStartIndex === null)) {
|
||||
replaceList(merger, frame.messages)
|
||||
return {
|
||||
kind: 'messages',
|
||||
messages: merger.list,
|
||||
hasMore: frame.hasMore,
|
||||
windowReplaced: true,
|
||||
...(frame.beforeOffset == null ? {} : { beforeOffset: frame.beforeOffset })
|
||||
}
|
||||
}
|
||||
const previousFirstId = merger.list[0]?.id
|
||||
const messages = applyAppend(merger, frame.messages, limit)
|
||||
const cursorInvalidated = Boolean(previousFirstId && messages[0]?.id !== previousFirstId)
|
||||
const replayStillStartsAtOldest = frame.type === 'snapshot' && replayStartIndex === 0
|
||||
return {
|
||||
kind: 'messages',
|
||||
messages,
|
||||
// Why: once the bounded live window drops its oldest row, the snapshot's
|
||||
// byte cursor no longer describes the oldest retained message.
|
||||
...(previousFirstId && messages[0]?.id !== previousFirstId ? { cursorInvalidated: true } : {})
|
||||
...(cursorInvalidated ? { cursorInvalidated: true } : {}),
|
||||
// A trimmed replay creates page-able history even if the prior window had
|
||||
// none; otherwise only a replay sharing our oldest row owns its metadata.
|
||||
...(frame.type === 'snapshot' && cursorInvalidated
|
||||
? { hasMore: true }
|
||||
: replayStillStartsAtOldest
|
||||
? {
|
||||
...(frame.hasMore == null ? {} : { hasMore: frame.hasMore }),
|
||||
...(frame.beforeOffset == null ? {} : { beforeOffset: frame.beforeOffset })
|
||||
}
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import {
|
||||
createMobileNativeChatStreamingGate,
|
||||
deriveMobileNativeChatStreaming,
|
||||
type MobileNativeChatStreamingGate
|
||||
} from './mobile-native-chat-streaming-gate'
|
||||
|
||||
function assistant(id: string, text: string): NativeChatMessage {
|
||||
return {
|
||||
id,
|
||||
role: 'assistant',
|
||||
blocks: [{ type: 'text', text }],
|
||||
timestamp: 0,
|
||||
source: 'transcript'
|
||||
}
|
||||
}
|
||||
|
||||
/** Run a sequence of (folded, streamingText) ticks through one gate. */
|
||||
function run(ticks: { folded: NativeChatMessage[]; text?: string; live?: boolean }[]): {
|
||||
gate: MobileNativeChatStreamingGate
|
||||
results: (string | null)[]
|
||||
} {
|
||||
let gate = createMobileNativeChatStreamingGate()
|
||||
const results: (string | null)[] = []
|
||||
for (const tick of ticks) {
|
||||
const step = deriveMobileNativeChatStreaming(gate, tick.folded, tick.text, {
|
||||
streamLive: tick.live
|
||||
})
|
||||
gate = step.gate
|
||||
results.push(step.streaming)
|
||||
}
|
||||
return { gate, results }
|
||||
}
|
||||
|
||||
describe('deriveMobileNativeChatStreaming', () => {
|
||||
it('shows a genuine reply that repeats the previous turn as a prefix', () => {
|
||||
const prior = [assistant('a1', 'The tests pass.')]
|
||||
const { results } = run([
|
||||
{ folded: prior }, // idle tick anchors the pre-stream tail
|
||||
{ folded: prior, text: 'The' },
|
||||
{ folded: prior, text: 'The tests' },
|
||||
{ folded: prior, text: 'The tests pass.' }
|
||||
])
|
||||
expect(results).toEqual([null, 'The', 'The tests', 'The tests pass.'])
|
||||
})
|
||||
|
||||
it('hides the bubble once the real turn lands leading with the streamed text', () => {
|
||||
const prior = [assistant('a1', 'earlier turn')]
|
||||
const landed = [...prior, assistant('a2', 'fresh answer with a tail')]
|
||||
const { results } = run([
|
||||
{ folded: prior },
|
||||
{ folded: prior, text: 'fresh answer' },
|
||||
{ folded: landed, text: 'fresh answer' }
|
||||
])
|
||||
expect(results).toEqual([null, 'fresh answer', null])
|
||||
})
|
||||
|
||||
it('suppresses an identical repeated reply once its own turn lands', () => {
|
||||
const prior = [assistant('a1', 'Done.')]
|
||||
const landed = [...prior, assistant('a2', 'Done.')]
|
||||
const { results } = run([
|
||||
{ folded: prior },
|
||||
{ folded: prior, text: 'Done.' }, // repeated-prefix reply stays visible
|
||||
{ folded: landed, text: 'Done.' } // its own turn landed — hide
|
||||
])
|
||||
expect(results).toEqual([null, 'Done.', null])
|
||||
})
|
||||
|
||||
it('keeps hiding for the rest of a segment after the turn lands', () => {
|
||||
const prior = [assistant('a1', 'earlier')]
|
||||
const landed = [...prior, assistant('a2', 'answer body')]
|
||||
const { results } = run([
|
||||
{ folded: prior },
|
||||
{ folded: prior, text: 'answer' },
|
||||
{ folded: landed, text: 'answer' },
|
||||
{ folded: landed, text: 'answer bo' }
|
||||
])
|
||||
expect(results).toEqual([null, 'answer', null, null])
|
||||
})
|
||||
|
||||
it('keeps the segment baseline through textless ticks while the turn is live', () => {
|
||||
// Chat is hidden mid-stream: the transcript unsubscribes and the status
|
||||
// stops reaching the gate, but the turn has not ended. Coming back, the
|
||||
// stream text returns before the re-read transcript does.
|
||||
const prior = [assistant('a1', 'Done.')]
|
||||
const { results } = run([
|
||||
{ folded: prior },
|
||||
{ folded: prior, text: 'Done.', live: true },
|
||||
{ folded: [], live: true },
|
||||
{ folded: [], text: 'Done.', live: true },
|
||||
{ folded: prior, text: 'Done.', live: true }
|
||||
])
|
||||
expect(results).toEqual([null, 'Done.', null, 'Done.', 'Done.'])
|
||||
})
|
||||
|
||||
it('still hides after a hidden gap once the reply landed as its own turn', () => {
|
||||
const prior = [assistant('a1', 'Done.')]
|
||||
const landed = [...prior, assistant('a2', 'Done.')]
|
||||
const { results } = run([
|
||||
{ folded: prior },
|
||||
{ folded: prior, text: 'Done.', live: true },
|
||||
{ folded: [], live: true },
|
||||
{ folded: landed, text: 'Done.', live: true }
|
||||
])
|
||||
expect(results).toEqual([null, 'Done.', null, null])
|
||||
})
|
||||
|
||||
it('hides a reply whose own turn landed before its status text arrived', () => {
|
||||
// The pane stays `working` past the reply (a subagent or a background task
|
||||
// is still live), and the transcript push beats the throttled status text.
|
||||
// Anchoring on that textless tick would adopt the reply as pre-stream
|
||||
// history and render it a second time as a bubble.
|
||||
const prior = [assistant('a1', 'Done.')]
|
||||
const landed = [...prior, assistant('a2', 'Done.')]
|
||||
const { results } = run([
|
||||
{ folded: prior, live: true },
|
||||
{ folded: landed, live: true },
|
||||
{ folded: landed, text: 'Done.', live: true },
|
||||
{ folded: landed, text: 'Done.', live: true }
|
||||
])
|
||||
expect(results).toEqual([null, null, null, null])
|
||||
})
|
||||
|
||||
it('keeps the pre-stream baseline across a hidden gap taken between turns', () => {
|
||||
// Peeking at the terminal while idle tears the transcript down to empty. An
|
||||
// empty tail is not history: adopting it strands the baseline and swallows
|
||||
// the repeated-prefix reply that arrives next.
|
||||
const prior = [assistant('a1', 'Done.')]
|
||||
const { results } = run([
|
||||
{ folded: prior },
|
||||
{ folded: [] },
|
||||
{ folded: [], live: true },
|
||||
{ folded: prior, text: 'Done.', live: true }
|
||||
])
|
||||
expect(results).toEqual([null, null, null, 'Done.'])
|
||||
})
|
||||
|
||||
it('anchors on the first tail it sees when mounted mid-turn', () => {
|
||||
// Opening a workspace whose agent is already working: that first textless
|
||||
// tick is the only pre-stream history the gate will ever get.
|
||||
const prior = [assistant('a1', 'Done.')]
|
||||
const { results } = run([
|
||||
{ folded: prior, live: true },
|
||||
{ folded: prior, text: 'Done.', live: true }
|
||||
])
|
||||
expect(results).toEqual([null, 'Done.'])
|
||||
})
|
||||
|
||||
it('anchors on a textless tick once the turn ends', () => {
|
||||
const prior = [assistant('a1', 'first answer')]
|
||||
const landed = [...prior, assistant('a2', 'second answer')]
|
||||
const { results } = run([
|
||||
{ folded: prior },
|
||||
{ folded: prior, text: 'second answer', live: true },
|
||||
{ folded: landed },
|
||||
{ folded: landed, text: 'second answer', live: true }
|
||||
])
|
||||
expect(results).toEqual([null, 'second answer', null, 'second answer'])
|
||||
})
|
||||
|
||||
it('does not treat the previous turn as a segment start after re-anchoring', () => {
|
||||
// The textless anchor clears the remembered text too. Keeping it would read
|
||||
// the next turn's opener as a new segment, re-anchor onto the reply that
|
||||
// just landed, and render it a second time as a bubble.
|
||||
const prior = [assistant('a1', 'context')]
|
||||
const firstLanded = [...prior, assistant('a2', 'Alpha done')]
|
||||
const secondLanded = [...firstLanded, assistant('a3', 'Beta reply')]
|
||||
const { results } = run([
|
||||
{ folded: prior },
|
||||
{ folded: prior, text: 'Alpha', live: true },
|
||||
{ folded: firstLanded }, // turn ended — re-anchor onto a2
|
||||
{ folded: secondLanded, text: 'Beta', live: true } // a3 already landed
|
||||
])
|
||||
expect(results).toEqual([null, 'Alpha', null, null])
|
||||
})
|
||||
|
||||
it('re-anchors when a new reply part replaces the stream mid-turn', () => {
|
||||
const prior = [assistant('a1', 'context')]
|
||||
const partOneLanded = [...prior, assistant('a2', 'part one full text')]
|
||||
const { results } = run([
|
||||
{ folded: prior },
|
||||
{ folded: prior, text: 'part one' },
|
||||
{ folded: partOneLanded, text: 'part one' }, // caught up — hide
|
||||
// Part two is not an extension of part one: new segment, new baseline.
|
||||
{ folded: partOneLanded, text: 'part' }
|
||||
])
|
||||
expect(results).toEqual([null, 'part one', null, 'part'])
|
||||
})
|
||||
|
||||
it('falls back to suppress-on-prefix when text arrives on the first tick', () => {
|
||||
// No tail ever observed before the text: a duplicate bubble is worse than
|
||||
// briefly hiding a mount-coincident repeated reply.
|
||||
const landed = [assistant('a1', 'flushed part still streaming in status')]
|
||||
const { results } = run([{ folded: landed, text: 'flushed part' }])
|
||||
expect(results).toEqual([null])
|
||||
})
|
||||
|
||||
it('is idempotent for a repeated tick', () => {
|
||||
const prior = [assistant('a1', 'The tests pass.')]
|
||||
const first = run([{ folded: prior }, { folded: prior, text: 'The tests' }])
|
||||
const again = deriveMobileNativeChatStreaming(first.gate, prior, 'The tests')
|
||||
expect(again.streaming).toBe('The tests')
|
||||
expect(again.gate).toBe(first.gate)
|
||||
})
|
||||
|
||||
it('drops a prior chat baseline when the stream identity changes', () => {
|
||||
// The other chat's tail must not license showing a bubble here — a swapped
|
||||
// scope resets to the mid-stream fallback rather than reusing its baseline.
|
||||
const repeatedId = [assistant('a1', 'new answer landed')]
|
||||
let gate = createMobileNativeChatStreamingGate('tab-a')
|
||||
gate = deriveMobileNativeChatStreaming(gate, repeatedId, undefined, { scopeKey: 'tab-a' }).gate
|
||||
|
||||
const switched = deriveMobileNativeChatStreaming(gate, repeatedId, 'new answer', {
|
||||
scopeKey: 'tab-b'
|
||||
})
|
||||
|
||||
expect(switched.streaming).toBeNull()
|
||||
expect(switched.gate.scopeKey).toBe('tab-b')
|
||||
expect(switched.gate.baselineTailId).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for empty or whitespace streaming text', () => {
|
||||
const prior = [assistant('a1', 'x')]
|
||||
expect(run([{ folded: prior, text: ' ' }]).results).toEqual([null])
|
||||
expect(run([{ folded: prior }]).results).toEqual([null])
|
||||
})
|
||||
|
||||
it('shows the first reply of an empty chat and hides it once the turn lands', () => {
|
||||
const landed = [assistant('a1', 'Hello there')]
|
||||
const { results } = run([
|
||||
{ folded: [] },
|
||||
{ folded: [], text: 'Hello' },
|
||||
{ folded: landed, text: 'Hello' }
|
||||
])
|
||||
expect(results).toEqual([null, 'Hello', null])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
|
||||
/** Decides whether the live streaming preview should render as a synthetic
|
||||
* bubble. Text alone can't tell "the transcript caught up with this stream"
|
||||
* from "a new reply happens to repeat the previous turn's prefix" — the old
|
||||
* prefix test swallowed genuine repeated-prefix replies. The gate keeps the
|
||||
* transcript tail observed when the current stream segment began: the bubble
|
||||
* hides only when the tail MOVED during the segment and leads with the
|
||||
* streamed text (the real turn landed), never for an older identical turn. */
|
||||
export type MobileNativeChatStreamingGate = {
|
||||
/** Chat/session identity this baseline belongs to. */
|
||||
scopeKey: string | null
|
||||
/** Streamed text seen on the previous tick ('' while idle). */
|
||||
prevText: string
|
||||
/** Folded tail message id when the current segment began; null while the
|
||||
* gate has never observed a transcript tail (text arrived on its very first
|
||||
* tick), where the legacy suppress-on-prefix rule applies. */
|
||||
baselineTailId: string | null
|
||||
}
|
||||
|
||||
export function createMobileNativeChatStreamingGate(
|
||||
scopeKey: string | null = null
|
||||
): MobileNativeChatStreamingGate {
|
||||
return { scopeKey, prevText: '', baselineTailId: null }
|
||||
}
|
||||
|
||||
function assistantTailText(tail: NativeChatMessage | undefined): string {
|
||||
if (!tail || tail.role !== 'assistant') {
|
||||
return ''
|
||||
}
|
||||
return tail.blocks
|
||||
.filter((block) => block.type === 'text')
|
||||
.map((block) => (block.type === 'text' ? block.text : ''))
|
||||
.join('')
|
||||
.trim()
|
||||
}
|
||||
|
||||
// Reuses the incoming gate object when nothing moved, so a caller can detect
|
||||
// "no change" by reference (and a render-time state adjustment can settle).
|
||||
function advanceGate(
|
||||
gate: MobileNativeChatStreamingGate,
|
||||
prevText: string,
|
||||
baselineTailId: string | null
|
||||
): MobileNativeChatStreamingGate {
|
||||
return gate.prevText === prevText && gate.baselineTailId === baselineTailId
|
||||
? gate
|
||||
: { ...gate, prevText, baselineTailId }
|
||||
}
|
||||
|
||||
/** Advance the gate one tick and derive the visible streaming text (null hides
|
||||
* the bubble). Pure and idempotent for a repeated (text, tail) pair, so a
|
||||
* re-render without new data cannot flip the decision. */
|
||||
export function deriveMobileNativeChatStreaming(
|
||||
gate: MobileNativeChatStreamingGate,
|
||||
folded: readonly NativeChatMessage[],
|
||||
streamingText: string | undefined,
|
||||
options: {
|
||||
scopeKey?: string | null
|
||||
/** Whether the agent is still mid-turn. A textless tick then means "no
|
||||
* observation this render", not "the stream ended". */
|
||||
streamLive?: boolean
|
||||
} = {}
|
||||
): { gate: MobileNativeChatStreamingGate; streaming: string | null } {
|
||||
const scopeKey = options.scopeKey === undefined ? gate.scopeKey : options.scopeKey
|
||||
const scopedGate =
|
||||
gate.scopeKey === scopeKey ? gate : createMobileNativeChatStreamingGate(scopeKey)
|
||||
const text = streamingText?.trim() ?? ''
|
||||
const tail = folded.at(-1)
|
||||
const tailId = tail?.id ?? null
|
||||
if (!text) {
|
||||
// Only a textless tick that carries a real tail and is outside a live turn
|
||||
// is trustworthy pre-stream history. Mid-turn gaps (a tool call, a throttle
|
||||
// lull, the transcript landing the reply before its status text) would
|
||||
// otherwise adopt that reply as history and render it a second time as a
|
||||
// bubble; a torn-down transcript carries no tail at all. The exception is a
|
||||
// gate that has never anchored — mounted mid-turn, the first real tail it
|
||||
// sees is the best pre-stream history it will ever get.
|
||||
const canAnchor = tailId !== null && (!options.streamLive || scopedGate.baselineTailId === null)
|
||||
return { gate: canAnchor ? advanceGate(scopedGate, '', tailId) : scopedGate, streaming: null }
|
||||
}
|
||||
// A stream that is not an extension of the previous tick is a new segment
|
||||
// (next reply part); re-anchor to the tail that predates it.
|
||||
const segmentStart = scopedGate.prevText !== '' && !text.startsWith(scopedGate.prevText)
|
||||
const baselineTailId = segmentStart ? tailId : scopedGate.baselineTailId
|
||||
const tailLeadsWithStream = assistantTailText(tail).startsWith(text)
|
||||
// A null baseline (text on the very first tick, no tail ever seen) is unequal
|
||||
// to every real tail id, so this degrades to the legacy suppress-on-prefix rule.
|
||||
const caughtUp = tailLeadsWithStream && tailId !== baselineTailId
|
||||
return {
|
||||
gate: advanceGate(scopedGate, text, baselineTailId),
|
||||
streaming: caughtUp ? null : text
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
acquireMobileNativeChatTerminalWrite,
|
||||
releaseMobileNativeChatTerminalWrite,
|
||||
resetMobileNativeChatTerminalWritesForTests
|
||||
} from './mobile-native-chat-terminal-write-lock'
|
||||
|
||||
describe('mobile-native-chat-terminal-write-lock', () => {
|
||||
afterEach(resetMobileNativeChatTerminalWritesForTests)
|
||||
|
||||
it('allows composed writes on different terminals to proceed concurrently', () => {
|
||||
expect(acquireMobileNativeChatTerminalWrite('terminal-a')).toBe(true)
|
||||
|
||||
expect(acquireMobileNativeChatTerminalWrite('terminal-b')).toBe(true)
|
||||
expect(acquireMobileNativeChatTerminalWrite('terminal-a')).toBe(false)
|
||||
|
||||
releaseMobileNativeChatTerminalWrite('terminal-a')
|
||||
releaseMobileNativeChatTerminalWrite('terminal-b')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
// Serializes composed native-chat write sequences (clear/paste/settle/submit,
|
||||
// paced answer keystrokes) per HOST terminal. Two concurrent sequences into one
|
||||
// PTY interleave their bytes; a second sender must be rejected up front, not
|
||||
// woven in. Module scope for the same reason as the stale-input marker: the
|
||||
// terminal outlives any one screen, and independent hooks share the same PTY.
|
||||
const writeInFlightTerminals = new Set<string>()
|
||||
|
||||
/** Claim the terminal for one composed write sequence. False = another
|
||||
* sequence is mid-flight; the caller must reject its send. */
|
||||
export function acquireMobileNativeChatTerminalWrite(terminal: string): boolean {
|
||||
if (writeInFlightTerminals.has(terminal)) {
|
||||
return false
|
||||
}
|
||||
writeInFlightTerminals.add(terminal)
|
||||
return true
|
||||
}
|
||||
|
||||
export function releaseMobileNativeChatTerminalWrite(terminal: string): void {
|
||||
writeInFlightTerminals.delete(terminal)
|
||||
}
|
||||
|
||||
/** Test-only: module scope outlives a single test's hooks. */
|
||||
export function resetMobileNativeChatTerminalWritesForTests(): void {
|
||||
writeInFlightTerminals.clear()
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { briefToolArg, summarizeToolInput, toolFilePath } from './mobile-native-chat-tool-summary'
|
||||
import {
|
||||
briefToolArg,
|
||||
describeToolInput,
|
||||
summarizeToolInput,
|
||||
toolFilePath
|
||||
} from './mobile-native-chat-tool-summary'
|
||||
|
||||
describe('summarizeToolInput', () => {
|
||||
it('passes short strings through, collapsing whitespace', () => {
|
||||
@@ -53,6 +58,13 @@ describe('toolFilePath', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('describeToolInput', () => {
|
||||
it('is re-exported and labels rows with the path or primary argument', () => {
|
||||
expect(describeToolInput({ file_path: 'src/a.ts', offset: 3 })).toBe('src/a.ts')
|
||||
expect(describeToolInput('{"cmd":"git status"}')).toBe('git status')
|
||||
})
|
||||
})
|
||||
|
||||
describe('briefToolArg', () => {
|
||||
it('takes the basename of a forward-slash path', () => {
|
||||
expect(briefToolArg({ path: 'src/session/app.ts' })).toBe('app.ts')
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
export {
|
||||
briefToolArg,
|
||||
countToolCalls,
|
||||
createToolInputDisplay,
|
||||
describeToolInput,
|
||||
formatToolInput,
|
||||
isStructuredToolInput,
|
||||
MAX_TOOL_DETAIL_LENGTH,
|
||||
summarizeToolInput,
|
||||
summarizeToolRun,
|
||||
toolFilePath
|
||||
toolFilePath,
|
||||
truncateToolDetail
|
||||
} from '../../../src/shared/native-chat-tool-summary'
|
||||
|
||||
@@ -1,19 +1,63 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createMobileSessionHref } from './mobile-session-route'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { mobileSessionRouteTarget } from './mobile-session-route'
|
||||
import {
|
||||
hostStackHostRoute,
|
||||
navigateToHostStackRoute,
|
||||
type HostStackNavigationState
|
||||
} from '../navigation/host-stack-navigation'
|
||||
|
||||
const homeSource = readFileSync(new URL('../../app/index.tsx', import.meta.url), 'utf8')
|
||||
|
||||
function navigationHarness(initialState: HostStackNavigationState) {
|
||||
const stateListeners = new Set<() => void>()
|
||||
let state = initialState
|
||||
const navigation = {
|
||||
addListener: vi.fn((_event: 'state', listener: () => void) => {
|
||||
stateListeners.add(listener)
|
||||
return () => stateListeners.delete(listener)
|
||||
}),
|
||||
dispatch: vi.fn(),
|
||||
getState: () => state
|
||||
}
|
||||
return {
|
||||
navigation,
|
||||
setState(nextState: HostStackNavigationState) {
|
||||
state = nextState
|
||||
for (const listener of stateListeners) {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function mountedHostState(hostId: string): HostStackNavigationState {
|
||||
return {
|
||||
index: 1,
|
||||
routes: [
|
||||
{ name: 'index' },
|
||||
{
|
||||
name: 'h',
|
||||
state: {
|
||||
key: '/h',
|
||||
index: 0,
|
||||
routes: [{ key: 'host-index', name: '[hostId]/index', params: { hostId } }]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
describe('mobile session route', () => {
|
||||
it('keeps dynamic route identities raw for Expo Router to encode', () => {
|
||||
it('keeps dynamic route identities raw for the navigator to encode', () => {
|
||||
expect(
|
||||
createMobileSessionHref({
|
||||
mobileSessionRouteTarget({
|
||||
hostId: 'host/one',
|
||||
worktreeId: 'repo::/Users/ada/orca/workspaces/fix #1',
|
||||
name: 'Fix #1'
|
||||
})
|
||||
).toEqual({
|
||||
pathname: '/h/[hostId]/session/[worktreeId]',
|
||||
name: '[hostId]/session/[worktreeId]',
|
||||
params: {
|
||||
hostId: 'host/one',
|
||||
worktreeId: 'repo::/Users/ada/orca/workspaces/fix #1',
|
||||
@@ -22,14 +66,47 @@ describe('mobile session route', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('routes the home Resume card through the typed dynamic href', () => {
|
||||
it('omits an absent workspace name instead of sending an empty param', () => {
|
||||
expect(
|
||||
mobileSessionRouteTarget({ hostId: 'host-1', worktreeId: 'repo::/tmp/wt' }).params
|
||||
).toEqual({ hostId: 'host-1', worktreeId: 'repo::/tmp/wt' })
|
||||
})
|
||||
|
||||
it('mounts the host before replacing it with the session route', () => {
|
||||
const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] })
|
||||
const push = vi.fn()
|
||||
const target = mobileSessionRouteTarget({
|
||||
hostId: 'host/one',
|
||||
worktreeId: 'repo::/Users/ada/orca/workspaces/fix #1',
|
||||
name: 'Fix #1'
|
||||
})
|
||||
|
||||
navigateToHostStackRoute(harness.navigation, { push }, 'host/one', target)
|
||||
|
||||
expect(push).toHaveBeenCalledWith(hostStackHostRoute('host/one'))
|
||||
expect(harness.navigation.dispatch).not.toHaveBeenCalled()
|
||||
|
||||
harness.setState(mountedHostState('host/one'))
|
||||
|
||||
expect(harness.navigation.dispatch).toHaveBeenCalledWith({
|
||||
type: 'REPLACE',
|
||||
target: '/h',
|
||||
source: 'host-index',
|
||||
payload: target
|
||||
})
|
||||
})
|
||||
|
||||
it('routes the home Resume card through the cold-navigator-safe transition', () => {
|
||||
const start = homeSource.indexOf('{/* ─── Resume card ─── */}')
|
||||
const end = homeSource.indexOf('{/* ─── Quick actions ─── */}', start)
|
||||
const resumeCard = homeSource.slice(start, end)
|
||||
|
||||
// Assert the markers first: a renamed banner would otherwise slice garbage and
|
||||
// report a missing call instead of the real cause.
|
||||
expect(start).toBeGreaterThanOrEqual(0)
|
||||
expect(end).toBeGreaterThan(start)
|
||||
expect(resumeCard).toContain('createMobileSessionHref({')
|
||||
expect(resumeCard).not.toContain('encodeURIComponent(resumeWorktree.worktree.worktreeId)')
|
||||
|
||||
const resumeCard = homeSource.slice(start, end)
|
||||
expect(resumeCard).toContain('openMobileSession({')
|
||||
expect(resumeCard).not.toContain('router.push(')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import type { HostStackRouteTarget } from '../navigation/host-stack-navigation'
|
||||
|
||||
export type MobileSessionRouteParams = {
|
||||
hostId: string
|
||||
worktreeId: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
export type MobileSessionHref = {
|
||||
pathname: '/h/[hostId]/session/[worktreeId]'
|
||||
params: MobileSessionRouteParams
|
||||
}
|
||||
|
||||
export function createMobileSessionHref(params: MobileSessionRouteParams): MobileSessionHref {
|
||||
/** Identities stay raw — the navigator owns the params, so pre-encoding a
|
||||
* workspace id would reach the session screen still escaped. */
|
||||
export function mobileSessionRouteTarget({
|
||||
hostId,
|
||||
worktreeId,
|
||||
name
|
||||
}: MobileSessionRouteParams): HostStackRouteTarget {
|
||||
return {
|
||||
pathname: '/h/[hostId]/session/[worktreeId]',
|
||||
params
|
||||
name: '[hostId]/session/[worktreeId]',
|
||||
params: name ? { hostId, worktreeId, name } : { hostId, worktreeId }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useMobileFileTapHandlers } from './use-mobile-file-tap-handlers'
|
||||
|
||||
const push = vi.fn()
|
||||
|
||||
vi.mock('expo-router', () => ({ useRouter: () => ({ push }) }))
|
||||
vi.mock('../platform/haptics', () => ({ triggerSelection: vi.fn() }))
|
||||
|
||||
type Handlers = ReturnType<typeof useMobileFileTapHandlers>
|
||||
|
||||
function ok(result: unknown) {
|
||||
return { ok: true, result, _meta: { runtimeId: 'runtime-1' } }
|
||||
}
|
||||
|
||||
describe('useMobileFileTapHandlers', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
let handlers: Handlers | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
push.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
handlers = null
|
||||
})
|
||||
|
||||
function createOptions(sendRequest: ReturnType<typeof vi.fn>) {
|
||||
return {
|
||||
client: { sendRequest },
|
||||
hostId: 'host-1',
|
||||
worktreeId: 'wt-1',
|
||||
worktreeName: 'Orca',
|
||||
activeHandleRef: { current: 'terminal-1' as string | null },
|
||||
terminalCwdRef: { current: new Map([['terminal-1', '/repo/sub']]) },
|
||||
openBrowser: vi.fn(),
|
||||
fetchSessionTabs: vi.fn(async () => {}),
|
||||
getSessionTabs: () => [],
|
||||
getActiveSessionTabId: () => null,
|
||||
getActiveSessionTabType: () => 'terminal',
|
||||
switchSessionTab: vi.fn(),
|
||||
scheduleDelayedAction: vi.fn(),
|
||||
reportChatTapFailure: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
function Harness({ options }: { options: ReturnType<typeof createOptions> }): null {
|
||||
handlers = useMobileFileTapHandlers(options)
|
||||
return null
|
||||
}
|
||||
|
||||
it('keeps handler identities stable across rerenders', () => {
|
||||
const options = createOptions(vi.fn())
|
||||
act(() => {
|
||||
renderer = create(createElement(Harness, { options }))
|
||||
})
|
||||
const first = handlers
|
||||
act(() => {
|
||||
renderer!.update(createElement(Harness, { options: { ...options } }))
|
||||
})
|
||||
expect(handlers!.handleFileTap).toBe(first!.handleFileTap)
|
||||
expect(handlers!.handleNativeChatFileTap).toBe(first!.handleNativeChatFileTap)
|
||||
})
|
||||
|
||||
it('dispatches through the latest options after a rerender', () => {
|
||||
const firstSendRequest = vi.fn()
|
||||
const firstOptions = createOptions(firstSendRequest)
|
||||
act(() => {
|
||||
renderer = create(createElement(Harness, { options: firstOptions }))
|
||||
})
|
||||
|
||||
const latestSendRequest = vi.fn(async () => ok({ exists: false, isDirectory: false }))
|
||||
act(() => {
|
||||
renderer!.update(
|
||||
createElement(Harness, {
|
||||
options: { ...firstOptions, client: { sendRequest: latestSendRequest } }
|
||||
})
|
||||
)
|
||||
})
|
||||
handlers!.handleFileTap('terminal-1', 'index.ts', null, null)
|
||||
|
||||
expect(firstSendRequest).not.toHaveBeenCalled()
|
||||
expect(latestSendRequest).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('resolves terminal taps with the terminal handle and cwd', async () => {
|
||||
const sendRequest = vi.fn(async () => ok({ exists: false, isDirectory: false }))
|
||||
act(() => {
|
||||
renderer = create(createElement(Harness, { options: createOptions(sendRequest) }))
|
||||
})
|
||||
|
||||
handlers!.handleFileTap('terminal-1', 'index.ts', null, null)
|
||||
await act(async () => {})
|
||||
|
||||
expect(sendRequest).toHaveBeenCalledWith(
|
||||
'files.resolveTerminalPath',
|
||||
{
|
||||
worktree: 'id:wt-1',
|
||||
pathText: 'index.ts',
|
||||
terminal: 'terminal-1',
|
||||
cwd: '/repo/sub',
|
||||
crossWorkspace: true
|
||||
},
|
||||
{ timeoutMs: 10_000 }
|
||||
)
|
||||
})
|
||||
|
||||
it('ignores terminal taps from a non-active handle', () => {
|
||||
const sendRequest = vi.fn()
|
||||
act(() => {
|
||||
renderer = create(createElement(Harness, { options: createOptions(sendRequest) }))
|
||||
})
|
||||
|
||||
handlers!.handleFileTap('terminal-2', 'index.ts', null, null)
|
||||
|
||||
expect(sendRequest).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('resolves chat taps against the worktree root and reports a miss', async () => {
|
||||
const sendRequest = vi.fn(async () => ok({ exists: false, isDirectory: false }))
|
||||
const options = createOptions(sendRequest)
|
||||
act(() => {
|
||||
renderer = create(createElement(Harness, { options }))
|
||||
})
|
||||
|
||||
handlers!.handleNativeChatFileTap('mobile/src/x.ts:12')
|
||||
await act(async () => {})
|
||||
|
||||
expect(sendRequest).toHaveBeenCalledWith(
|
||||
'files.resolveTerminalPath',
|
||||
{ worktree: 'id:wt-1', pathText: 'mobile/src/x.ts', crossWorkspace: true },
|
||||
{ timeoutMs: 10_000 }
|
||||
)
|
||||
expect(options.reportChatTapFailure).toHaveBeenCalledWith("Couldn't open mobile/src/x.ts:12")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,174 @@
|
||||
import { useCallback, useLayoutEffect, useRef, type MutableRefObject } from 'react'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { triggerSelection } from '../platform/haptics'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { openMobileFileTap, type FileTapSessionTab } from './mobile-file-tap-open'
|
||||
import { openMobileNativeChatFileTap } from './mobile-native-chat-open-file'
|
||||
|
||||
type MobileFileTapHandlerOptions<T extends FileTapSessionTab> = {
|
||||
client: Pick<RpcClient, 'sendRequest'> | null
|
||||
hostId: string
|
||||
worktreeId: string
|
||||
worktreeName?: string
|
||||
activeHandleRef: MutableRefObject<string | null>
|
||||
terminalCwdRef: MutableRefObject<Map<string, string>>
|
||||
openBrowser: (url: string) => void
|
||||
fetchSessionTabs: () => Promise<void>
|
||||
getSessionTabs: () => readonly T[]
|
||||
getActiveSessionTabId: () => string | null
|
||||
getActiveSessionTabType: () => string | null
|
||||
switchSessionTab: (tab: T) => void
|
||||
scheduleDelayedAction: (callback: () => void, delayMs: number) => unknown
|
||||
reportChatTapFailure: (message: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Tap-to-open handlers for file references, shared by the terminal (link taps
|
||||
* with the terminal's cwd) and native chat (worktree-root-relative paths, with
|
||||
* failure feedback). Handlers are identity-stable and read the latest options at
|
||||
* dispatch time; the shared activation seq lets a newer tap on either surface
|
||||
* supersede an in-flight one.
|
||||
*/
|
||||
export function useMobileFileTapHandlers<T extends FileTapSessionTab>(
|
||||
options: MobileFileTapHandlerOptions<T>
|
||||
): {
|
||||
handleFileTap: (
|
||||
handle: string,
|
||||
pathText: string,
|
||||
line: number | null,
|
||||
column: number | null
|
||||
) => void
|
||||
handleNativeChatFileTap: (pathText: string) => void
|
||||
} {
|
||||
const {
|
||||
activeHandleRef,
|
||||
client,
|
||||
fetchSessionTabs,
|
||||
getActiveSessionTabId,
|
||||
getActiveSessionTabType,
|
||||
getSessionTabs,
|
||||
hostId,
|
||||
openBrowser,
|
||||
scheduleDelayedAction,
|
||||
reportChatTapFailure,
|
||||
switchSessionTab,
|
||||
terminalCwdRef,
|
||||
worktreeId,
|
||||
worktreeName
|
||||
} = options
|
||||
const router = useRouter()
|
||||
const routerRef = useRef(router)
|
||||
const optionsRef = useRef(options)
|
||||
const activationSeqRef = useRef(0)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
routerRef.current = router
|
||||
optionsRef.current = {
|
||||
activeHandleRef,
|
||||
client,
|
||||
fetchSessionTabs,
|
||||
getActiveSessionTabId,
|
||||
getActiveSessionTabType,
|
||||
getSessionTabs,
|
||||
hostId,
|
||||
openBrowser,
|
||||
scheduleDelayedAction,
|
||||
reportChatTapFailure,
|
||||
switchSessionTab,
|
||||
terminalCwdRef,
|
||||
worktreeId,
|
||||
worktreeName
|
||||
}
|
||||
}, [
|
||||
activeHandleRef,
|
||||
client,
|
||||
fetchSessionTabs,
|
||||
getActiveSessionTabId,
|
||||
getActiveSessionTabType,
|
||||
getSessionTabs,
|
||||
hostId,
|
||||
openBrowser,
|
||||
router,
|
||||
scheduleDelayedAction,
|
||||
reportChatTapFailure,
|
||||
switchSessionTab,
|
||||
terminalCwdRef,
|
||||
worktreeId,
|
||||
worktreeName
|
||||
])
|
||||
|
||||
const handleFileTap = useCallback(
|
||||
(handle: string, pathText: string, line: number | null, column: number | null) => {
|
||||
const current = optionsRef.current
|
||||
if (handle !== current.activeHandleRef.current || !current.client) {
|
||||
return
|
||||
}
|
||||
const activationSeq = ++activationSeqRef.current
|
||||
openMobileFileTap<T>({
|
||||
client: current.client,
|
||||
hostId: current.hostId,
|
||||
worktreeId: current.worktreeId,
|
||||
worktreeName: current.worktreeName,
|
||||
terminalHandle: handle,
|
||||
pathText,
|
||||
cwd: current.terminalCwdRef.current.get(handle) ?? null,
|
||||
line,
|
||||
column,
|
||||
pushPreviewRoute: (href) => routerRef.current.push(href),
|
||||
openBrowser: current.openBrowser,
|
||||
triggerOpenFeedback: triggerSelection,
|
||||
fetchSessionTabs: current.fetchSessionTabs,
|
||||
getSessionTabs: current.getSessionTabs,
|
||||
getActiveSessionTabId: current.getActiveSessionTabId,
|
||||
getActivationState: (activated) => ({
|
||||
activated,
|
||||
activationSeq,
|
||||
latestActivationSeq: activationSeqRef.current,
|
||||
sourceTerminalHandle: handle,
|
||||
activeTerminalHandle: current.activeHandleRef.current,
|
||||
activeTabType: current.getActiveSessionTabType()
|
||||
}),
|
||||
switchSessionTab: current.switchSessionTab,
|
||||
scheduleDelayedAction: current.scheduleDelayedAction
|
||||
})
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const handleNativeChatFileTap = useCallback((pathText: string) => {
|
||||
const current = optionsRef.current
|
||||
// The chat overlay rides on its backing terminal tab; that handle anchors
|
||||
// the activation gate even though resolution ignores the terminal's cwd.
|
||||
const sourceTerminalHandle = current.activeHandleRef.current
|
||||
if (!current.client || !sourceTerminalHandle) {
|
||||
return
|
||||
}
|
||||
const activationSeq = ++activationSeqRef.current
|
||||
openMobileNativeChatFileTap<T>({
|
||||
client: current.client,
|
||||
hostId: current.hostId,
|
||||
worktreeId: current.worktreeId,
|
||||
worktreeName: current.worktreeName,
|
||||
pathText,
|
||||
pushPreviewRoute: (href) => routerRef.current.push(href),
|
||||
openBrowser: current.openBrowser,
|
||||
triggerOpenFeedback: triggerSelection,
|
||||
fetchSessionTabs: current.fetchSessionTabs,
|
||||
getSessionTabs: current.getSessionTabs,
|
||||
getActiveSessionTabId: current.getActiveSessionTabId,
|
||||
getActivationState: (activated) => ({
|
||||
activated,
|
||||
activationSeq,
|
||||
latestActivationSeq: activationSeqRef.current,
|
||||
sourceTerminalHandle,
|
||||
activeTerminalHandle: current.activeHandleRef.current,
|
||||
activeTabType: current.getActiveSessionTabType()
|
||||
}),
|
||||
switchSessionTab: current.switchSessionTab,
|
||||
scheduleDelayedAction: current.scheduleDelayedAction,
|
||||
onOpenFailed: () => current.reportChatTapFailure(`Couldn't open ${pathText}`)
|
||||
})
|
||||
}, [])
|
||||
|
||||
return { handleFileTap, handleNativeChatFileTap }
|
||||
}
|
||||
@@ -11,7 +11,13 @@ import {
|
||||
markMobileNativeChatInputStale,
|
||||
resetMobileNativeChatStaleInputForTests
|
||||
} from './mobile-native-chat-stale-input'
|
||||
import {
|
||||
acquireMobileNativeChatTerminalWrite,
|
||||
releaseMobileNativeChatTerminalWrite,
|
||||
resetMobileNativeChatTerminalWritesForTests
|
||||
} from './mobile-native-chat-terminal-write-lock'
|
||||
import { useMobileNativeChatAnswerSend } from './use-mobile-native-chat-answer-send'
|
||||
import { useNativeChatAcceptedAction } from './use-native-chat-action-outcomes'
|
||||
|
||||
type AnswerSend = ReturnType<typeof useMobileNativeChatAnswerSend>
|
||||
|
||||
@@ -40,17 +46,24 @@ describe('useMobileNativeChatAnswerSend', () => {
|
||||
let mountedClient: RpcClient | null = null
|
||||
let mountedOnSendError: ((message: string) => void) | null = null
|
||||
let mountedAgent: AgentType = 'claude'
|
||||
// The route sends through useNativeChatAcceptedAction, whose accepted callback
|
||||
// retires the shared send-error banner (use-mobile-native-chat-controller.ts).
|
||||
let acceptedAnswerAsk: AnswerSend['answerAsk'] | null = null
|
||||
let onAccepted = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
onAccepted = vi.fn()
|
||||
vi.useFakeTimers()
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
resetMobileNativeChatStaleInputForTests()
|
||||
resetMobileNativeChatTerminalWritesForTests()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
answerSend = null
|
||||
acceptedAnswerAsk = null
|
||||
mountedClient = null
|
||||
mountedOnSendError = null
|
||||
mountedAgent = 'claude'
|
||||
@@ -68,6 +81,7 @@ describe('useMobileNativeChatAnswerSend', () => {
|
||||
streamIdentity: 'host\0worktree\0tab\0session',
|
||||
onSendError: mountedOnSendError!
|
||||
})
|
||||
acceptedAnswerAsk = useNativeChatAcceptedAction(answerSend.answerAsk, onAccepted)
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -396,4 +410,517 @@ describe('useMobileNativeChatAnswerSend', () => {
|
||||
await expect(result).resolves.toBe(false)
|
||||
expect(sendRequest).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('rejects an answer while another composed write holds the terminal', async () => {
|
||||
const onSendError = vi.fn()
|
||||
const sendRequest = vi.fn().mockResolvedValue(acceptedResponse())
|
||||
await mount({ sendRequest } as unknown as RpcClient, onSendError)
|
||||
|
||||
// An image paste sequence is mid-flight into the same PTY.
|
||||
expect(acquireMobileNativeChatTerminalWrite('terminal')).toBe(true)
|
||||
await expect(answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])).resolves.toBe(false)
|
||||
expect(sendRequest).not.toHaveBeenCalled()
|
||||
expect(onSendError).toHaveBeenCalledWith('Answer not sent')
|
||||
|
||||
// Once that sequence releases, answers flow again.
|
||||
releaseMobileNativeChatTerminalWrite('terminal')
|
||||
await expect(answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('answers again on the same handle after an earlier answer already landed', async () => {
|
||||
const onSendError = vi.fn()
|
||||
const sendRequest = vi.fn().mockResolvedValue(acceptedResponse())
|
||||
await mount({ sendRequest } as unknown as RpcClient, onSendError)
|
||||
|
||||
await expect(answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [0] }])).resolves.toBe(true)
|
||||
// A landed answer resolves its turn FALSE — correct for a queued successor,
|
||||
// fatal if the turn outlives the chain. Leaving it parked in the slot fences
|
||||
// every later answer on this handle for the life of the hook, not just an
|
||||
// overlapping one, so the ask card dies after its first use.
|
||||
await expect(answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])).resolves.toBe(true)
|
||||
expect(sendRequest).toHaveBeenCalledTimes(2)
|
||||
expect(onSendError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fences a superseding answer after the cancelled chain moved the selector', async () => {
|
||||
const sendRequest = vi.fn().mockResolvedValue(acceptedResponse())
|
||||
await mount({ sendRequest } as unknown as RpcClient, vi.fn())
|
||||
|
||||
const prompt: AskPrompt = {
|
||||
questions: [
|
||||
{ question: 'q1', multiSelect: false, options: [{ label: 'A' }, { label: 'B' }] },
|
||||
{ question: 'q2', multiSelect: false, options: [{ label: 'C' }, { label: 'D' }] }
|
||||
]
|
||||
}
|
||||
let first: Promise<boolean> | undefined
|
||||
let second: Promise<boolean> | undefined
|
||||
await act(async () => {
|
||||
first = answerSend?.answerAsk(prompt, [{ indices: [0] }, { indices: [0] }])
|
||||
})
|
||||
// The first digit already advanced Claude to q2. Replaying a from-q1 key
|
||||
// plan now would answer the wrong question.
|
||||
await act(async () => {
|
||||
second = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])
|
||||
})
|
||||
await act(async () => vi.runAllTimersAsync())
|
||||
|
||||
await expect(first).resolves.toBe(false)
|
||||
await expect(second).resolves.toBe(false)
|
||||
expect(sendRequest).toHaveBeenCalledTimes(1)
|
||||
// Both chains unwound: the terminal is free for the next composed write.
|
||||
expect(acquireMobileNativeChatTerminalWrite('terminal')).toBe(true)
|
||||
releaseMobileNativeChatTerminalWrite('terminal')
|
||||
})
|
||||
|
||||
it('does not write a successor after the prior in-flight key is accepted', async () => {
|
||||
let resolveFirst: (response: unknown) => void = () => undefined
|
||||
const sendRequest = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveFirst = resolve
|
||||
})
|
||||
)
|
||||
.mockResolvedValue(acceptedResponse())
|
||||
await mount({ sendRequest } as unknown as RpcClient, vi.fn())
|
||||
|
||||
const prompt: AskPrompt = {
|
||||
questions: [
|
||||
{ question: 'q1', multiSelect: false, options: [{ label: 'A' }, { label: 'B' }] },
|
||||
{ question: 'q2', multiSelect: false, options: [{ label: 'C' }, { label: 'D' }] }
|
||||
]
|
||||
}
|
||||
let first: Promise<boolean> | undefined
|
||||
let second: Promise<boolean> | undefined
|
||||
await act(async () => {
|
||||
first = answerSend?.answerAsk(prompt, [{ indices: [0] }, { indices: [0] }])
|
||||
await Promise.resolve()
|
||||
})
|
||||
await act(async () => {
|
||||
second = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
// The successor is queued behind the in-flight key, not racing it.
|
||||
expect(sendRequest).toHaveBeenCalledTimes(1)
|
||||
await act(async () => {
|
||||
resolveFirst(acceptedResponse())
|
||||
await Promise.resolve()
|
||||
})
|
||||
await expect(first).resolves.toBe(false)
|
||||
await expect(second).resolves.toBe(false)
|
||||
expect(sendRequest).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('lets a queued successor continue after the prior key is definitely rejected', async () => {
|
||||
let resolveFirst: (response: unknown) => void = () => undefined
|
||||
const sendRequest = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveFirst = resolve
|
||||
})
|
||||
)
|
||||
.mockResolvedValue(acceptedResponse())
|
||||
await mount({ sendRequest } as unknown as RpcClient, vi.fn())
|
||||
|
||||
let first: Promise<boolean> | undefined
|
||||
let second: Promise<boolean> | undefined
|
||||
await act(async () => {
|
||||
first = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [0] }])
|
||||
await Promise.resolve()
|
||||
})
|
||||
await act(async () => {
|
||||
second = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])
|
||||
await Promise.resolve()
|
||||
})
|
||||
await act(async () => {
|
||||
resolveFirst({
|
||||
...acceptedResponse(),
|
||||
result: { send: { accepted: false } }
|
||||
})
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
// Nothing landed, so the selector never moved — the successor is safe.
|
||||
await expect(first).resolves.toBe(false)
|
||||
await expect(second).resolves.toBe(true)
|
||||
expect(sendRequest).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not write a successor after the prior delivery becomes ambiguous', async () => {
|
||||
let rejectFirst: (error: Error) => void = () => undefined
|
||||
const sendRequest = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((_resolve, reject) => {
|
||||
rejectFirst = reject
|
||||
})
|
||||
)
|
||||
.mockResolvedValue(acceptedResponse())
|
||||
await mount({ sendRequest } as unknown as RpcClient, vi.fn())
|
||||
|
||||
let first: Promise<boolean> | undefined
|
||||
let second: Promise<boolean> | undefined
|
||||
await act(async () => {
|
||||
first = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [0] }])
|
||||
await Promise.resolve()
|
||||
})
|
||||
await act(async () => {
|
||||
second = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])
|
||||
await Promise.resolve()
|
||||
})
|
||||
await act(async () => {
|
||||
rejectFirst(markRpcDeliveryUnknown(new Error('Connection closed')))
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
// The key may have landed; a blind successor could double-step the selector.
|
||||
await expect(first).resolves.toBe(false)
|
||||
await expect(second).resolves.toBe(false)
|
||||
expect(sendRequest).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('tells the user when a queued answer is fenced instead of dropping it silently', async () => {
|
||||
const onSendError = vi.fn()
|
||||
let rejectFirst: (error: Error) => void = () => undefined
|
||||
const sendRequest = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((_resolve, reject) => {
|
||||
rejectFirst = reject
|
||||
})
|
||||
)
|
||||
.mockResolvedValue(acceptedResponse())
|
||||
await mount({ sendRequest } as unknown as RpcClient, onSendError)
|
||||
|
||||
let first: Promise<boolean> | undefined
|
||||
let second: Promise<boolean> | undefined
|
||||
await act(async () => {
|
||||
first = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [0] }])
|
||||
await Promise.resolve()
|
||||
})
|
||||
await act(async () => {
|
||||
second = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])
|
||||
await Promise.resolve()
|
||||
})
|
||||
await act(async () => {
|
||||
rejectFirst(markRpcDeliveryUnknown(new Error('Connection closed')))
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
await expect(first).resolves.toBe(false)
|
||||
await expect(second).resolves.toBe(false)
|
||||
// The card re-enables on a false result, so an unreported fence looks exactly
|
||||
// like a dead button. The superseded chain stays quiet — the newest answer
|
||||
// owns the error surface, and it is the one the user is waiting on.
|
||||
expect(onSendError).toHaveBeenCalledTimes(1)
|
||||
expect(onSendError).toHaveBeenCalledWith('Answer not sent — check chat before retrying')
|
||||
})
|
||||
|
||||
it('tells the user when a dropped lease fences a queued answer whose predecessor landed', async () => {
|
||||
const onSendError = vi.fn()
|
||||
const settle: Array<(response: unknown) => void> = []
|
||||
const sendRequest = vi.fn().mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
settle.push(resolve)
|
||||
})
|
||||
)
|
||||
await mount({ sendRequest } as unknown as RpcClient, onSendError)
|
||||
|
||||
let first: Promise<boolean> | undefined
|
||||
let second: Promise<boolean> | undefined
|
||||
await act(async () => {
|
||||
first = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])
|
||||
await Promise.resolve()
|
||||
})
|
||||
await act(async () => {
|
||||
second = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [0] }])
|
||||
await Promise.resolve()
|
||||
})
|
||||
// A transport blip, not a user cancel: unlike Stop and ask-cancel, the lost
|
||||
// input lease writes no Escape, so the card stays up with Submit re-enabled.
|
||||
await setEnabled(false)
|
||||
await act(async () => {
|
||||
settle[0]!(acceptedResponse())
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(sendRequest).toHaveBeenCalledTimes(1)
|
||||
await expect(first).resolves.toBe(false)
|
||||
await expect(second).resolves.toBe(false)
|
||||
// The first option key LANDED, so the live selector already moved. Reporting
|
||||
// nothing invites a retry that double-steps it.
|
||||
expect(onSendError).toHaveBeenCalledTimes(1)
|
||||
expect(onSendError).toHaveBeenCalledWith('Answer not sent — check chat before retrying')
|
||||
})
|
||||
|
||||
it('keeps the terminal locked while a queued successor writes', async () => {
|
||||
const settle: Array<(response: unknown) => void> = []
|
||||
const sendRequest = vi.fn().mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
settle.push(resolve)
|
||||
})
|
||||
)
|
||||
await mount({ sendRequest } as unknown as RpcClient, vi.fn())
|
||||
|
||||
let first: Promise<boolean> | undefined
|
||||
let second: Promise<boolean> | undefined
|
||||
await act(async () => {
|
||||
first = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [0] }])
|
||||
await Promise.resolve()
|
||||
})
|
||||
await act(async () => {
|
||||
second = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(sendRequest).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Nothing landed, so the successor is cleared to write.
|
||||
await act(async () => {
|
||||
settle[0]!({ ...acceptedResponse(), result: { send: { accepted: false } } })
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(sendRequest).toHaveBeenCalledTimes(2)
|
||||
// The successor's key is on the wire. The superseded chain unwinding behind it
|
||||
// must NOT free the terminal, or an image paste interleaves into this sequence.
|
||||
expect(acquireMobileNativeChatTerminalWrite('terminal')).toBe(false)
|
||||
|
||||
await act(async () => {
|
||||
settle[1]!(acceptedResponse())
|
||||
await Promise.resolve()
|
||||
})
|
||||
await expect(first).resolves.toBe(false)
|
||||
await expect(second).resolves.toBe(true)
|
||||
expect(acquireMobileNativeChatTerminalWrite('terminal')).toBe(true)
|
||||
releaseMobileNativeChatTerminalWrite('terminal')
|
||||
})
|
||||
|
||||
it('does not retire the fence banner when the superseded answer lands', async () => {
|
||||
const onSendError = vi.fn()
|
||||
const settle: Array<(response: unknown) => void> = []
|
||||
const sendRequest = vi.fn().mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
settle.push(resolve)
|
||||
})
|
||||
)
|
||||
await mount({ sendRequest } as unknown as RpcClient, onSendError)
|
||||
|
||||
let first: Promise<boolean> | undefined
|
||||
let second: Promise<boolean> | undefined
|
||||
await act(async () => {
|
||||
first = acceptedAnswerAsk?.(TABS_OR_SPACES, [{ indices: [0] }])
|
||||
await Promise.resolve()
|
||||
})
|
||||
await act(async () => {
|
||||
second = acceptedAnswerAsk?.(TABS_OR_SPACES, [{ indices: [1] }])
|
||||
await Promise.resolve()
|
||||
})
|
||||
// The healthy path: the first answer LANDS, which is also the case that fences
|
||||
// hardest — its key moved the live selector.
|
||||
await act(async () => {
|
||||
settle[0]!(acceptedResponse())
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
await expect(first).resolves.toBe(false)
|
||||
await expect(second).resolves.toBe(false)
|
||||
expect(onSendError).toHaveBeenCalledWith('Answer not sent — check chat before retrying')
|
||||
// A superseded chain reporting success would clear the banner it just raised —
|
||||
// the accepted hook runs after the fence, so the user would see nothing at all.
|
||||
expect(onAccepted).not.toHaveBeenCalled()
|
||||
expect(sendRequest).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not retire the fence banner when a superseded pasted answer lands', async () => {
|
||||
const onSendError = vi.fn()
|
||||
const settle: Array<(response: unknown) => void> = []
|
||||
const sendRequest = vi.fn().mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
settle.push(resolve)
|
||||
})
|
||||
)
|
||||
await mount({ sendRequest } as unknown as RpcClient, onSendError, 'grok')
|
||||
|
||||
let first: Promise<boolean> | undefined
|
||||
let second: Promise<boolean> | undefined
|
||||
await act(async () => {
|
||||
first = acceptedAnswerAsk?.(TABS_OR_SPACES, [{ indices: [0] }])
|
||||
await Promise.resolve()
|
||||
})
|
||||
await act(async () => {
|
||||
second = acceptedAnswerAsk?.(TABS_OR_SPACES, [{ indices: [1] }])
|
||||
await Promise.resolve()
|
||||
})
|
||||
await act(async () => {
|
||||
settle[0]!(acceptedResponse())
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
// The pasted shape commits with Enter, so a superseded chain is doubly unsafe
|
||||
// to report as accepted — the answer it committed is not the one on screen.
|
||||
await expect(first).resolves.toBe(false)
|
||||
await expect(second).resolves.toBe(false)
|
||||
expect(onAccepted).not.toHaveBeenCalled()
|
||||
expect(onSendError).toHaveBeenCalledWith('Answer not sent — check chat before retrying')
|
||||
})
|
||||
|
||||
it('reports a landed answer that Stop cancelled with no successor waiting', async () => {
|
||||
const onSendError = vi.fn()
|
||||
const settle: Array<(response: unknown) => void> = []
|
||||
const sendRequest = vi.fn().mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
settle.push(resolve)
|
||||
})
|
||||
)
|
||||
await mount({ sendRequest } as unknown as RpcClient, onSendError)
|
||||
|
||||
let first: Promise<boolean> | undefined
|
||||
await act(async () => {
|
||||
first = acceptedAnswerAsk?.(TABS_OR_SPACES, [{ indices: [1] }])
|
||||
await Promise.resolve()
|
||||
})
|
||||
// Stop, an ask cancel, and a dropped input lease all bump the generation with
|
||||
// NO successor chain behind them.
|
||||
await act(async () => {
|
||||
answerSend?.cancelPending()
|
||||
settle[0]!(acceptedResponse())
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
// The key is on the PTY and nobody else owns the surface. Calling that a
|
||||
// non-send leaves the card up and silent (fail() never runs on an accepted
|
||||
// write), and the retry double-steps the selector this key already moved.
|
||||
await expect(first).resolves.toBe(true)
|
||||
expect(onAccepted).toHaveBeenCalledTimes(1)
|
||||
expect(onSendError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports a landed pasted answer that Stop cancelled with no successor', async () => {
|
||||
const onSendError = vi.fn()
|
||||
const settle: Array<(response: unknown) => void> = []
|
||||
const sendRequest = vi.fn().mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
settle.push(resolve)
|
||||
})
|
||||
)
|
||||
await mount({ sendRequest } as unknown as RpcClient, onSendError, 'grok')
|
||||
|
||||
let first: Promise<boolean> | undefined
|
||||
await act(async () => {
|
||||
first = acceptedAnswerAsk?.(TABS_OR_SPACES, [{ indices: [1] }])
|
||||
await Promise.resolve()
|
||||
})
|
||||
await act(async () => {
|
||||
answerSend?.cancelPending()
|
||||
settle[0]!(acceptedResponse())
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
// The pasted shape already committed with Enter, so suppressing the success
|
||||
// strands an answered card the user is invited to submit a second time.
|
||||
await expect(first).resolves.toBe(true)
|
||||
expect(onAccepted).toHaveBeenCalledTimes(1)
|
||||
expect(onSendError).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fences a third answer behind an already-fenced successor, reporting once', async () => {
|
||||
const onSendError = vi.fn()
|
||||
const settle: Array<(response: unknown) => void> = []
|
||||
const sendRequest = vi.fn().mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
settle.push(resolve)
|
||||
})
|
||||
)
|
||||
await mount({ sendRequest } as unknown as RpcClient, onSendError)
|
||||
|
||||
let first: Promise<boolean> | undefined
|
||||
let second: Promise<boolean> | undefined
|
||||
let third: Promise<boolean> | undefined
|
||||
await act(async () => {
|
||||
first = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [0] }])
|
||||
await Promise.resolve()
|
||||
})
|
||||
await act(async () => {
|
||||
second = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])
|
||||
await Promise.resolve()
|
||||
})
|
||||
await act(async () => {
|
||||
third = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [0] }])
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(sendRequest).toHaveBeenCalledTimes(1)
|
||||
|
||||
await act(async () => {
|
||||
settle[0]!(acceptedResponse())
|
||||
await Promise.resolve()
|
||||
})
|
||||
await expect(first).resolves.toBe(false)
|
||||
await expect(second).resolves.toBe(false)
|
||||
await expect(third).resolves.toBe(false)
|
||||
// The middle chain sent nothing, so only the verdict it INHERITED can stop the
|
||||
// third from replaying a from-scratch plan onto the advanced selector.
|
||||
expect(sendRequest).toHaveBeenCalledTimes(1)
|
||||
// Only the newest chain owns the error surface.
|
||||
expect(onSendError).toHaveBeenCalledTimes(1)
|
||||
expect(onSendError).toHaveBeenCalledWith('Answer not sent — check chat before retrying')
|
||||
})
|
||||
|
||||
it('queues a late third answer behind the successor already on the wire', async () => {
|
||||
const settle: Array<(response: unknown) => void> = []
|
||||
const sendRequest = vi.fn().mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
settle.push(resolve)
|
||||
})
|
||||
)
|
||||
await mount({ sendRequest } as unknown as RpcClient, vi.fn())
|
||||
|
||||
let first: Promise<boolean> | undefined
|
||||
let second: Promise<boolean> | undefined
|
||||
let third: Promise<boolean> | undefined
|
||||
await act(async () => {
|
||||
first = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [0] }])
|
||||
await Promise.resolve()
|
||||
})
|
||||
await act(async () => {
|
||||
second = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])
|
||||
await Promise.resolve()
|
||||
})
|
||||
// Nothing landed, so the successor is cleared and puts its own key on the wire.
|
||||
await act(async () => {
|
||||
settle[0]!({ ...acceptedResponse(), result: { send: { accepted: false } } })
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(sendRequest).toHaveBeenCalledTimes(2)
|
||||
|
||||
await act(async () => {
|
||||
third = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [0] }])
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
// The first chain unwound while the second was mid-write: it must not have
|
||||
// dropped the second's turn, or this one writes into the same PTY concurrently.
|
||||
expect(sendRequest).toHaveBeenCalledTimes(2)
|
||||
|
||||
await act(async () => {
|
||||
settle[1]!(acceptedResponse())
|
||||
await Promise.resolve()
|
||||
})
|
||||
await expect(first).resolves.toBe(false)
|
||||
await expect(second).resolves.toBe(false)
|
||||
await expect(third).resolves.toBe(false)
|
||||
expect(sendRequest).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,6 +14,10 @@ import {
|
||||
sendMobileNativeChatMessageWithOutcome
|
||||
} from './mobile-native-chat-send'
|
||||
import { healMobileNativeChatStaleInput } from './mobile-native-chat-stale-input'
|
||||
import {
|
||||
acquireMobileNativeChatTerminalWrite,
|
||||
releaseMobileNativeChatTerminalWrite
|
||||
} from './mobile-native-chat-terminal-write-lock'
|
||||
import {
|
||||
resolveNativeChatTranscriptAgent,
|
||||
shouldStepNativeChatAskAnswer
|
||||
@@ -72,6 +76,12 @@ export function useMobileNativeChatAnswerSend(args: {
|
||||
const generationRef = useRef(0)
|
||||
const activeRouteRef = useRef({ client, enabled, sessionId, streamIdentity })
|
||||
activeRouteRef.current = { client, enabled, sessionId, streamIdentity }
|
||||
// Per-terminal count of this hook's chains sharing one write-lock hold: a
|
||||
// superseding answer inherits the cancelled chain's hold (it re-enters before
|
||||
// the old chain unwinds), and only the last chain out releases the lock.
|
||||
const writeHoldsRef = useRef(new Map<string, number>())
|
||||
// Successors wait for the prior RPC and inherit any delivery ambiguity.
|
||||
const writeTurnsRef = useRef(new Map<string, Promise<boolean>>())
|
||||
const delaysRef = useRef<
|
||||
Set<{ timer: ReturnType<typeof setTimeout>; resolve: (completed: boolean) => void }>
|
||||
>(new Set())
|
||||
@@ -103,125 +113,191 @@ export function useMobileNativeChatAnswerSend(args: {
|
||||
if (!hasAskAnswer(prompt, selections)) {
|
||||
return false
|
||||
}
|
||||
// One composed write sequence per terminal: an answer landing mid-flight
|
||||
// in an image paste (or vice versa) would interleave bytes into the PTY.
|
||||
// A superseding answer shares the cancelled chain's hold on this terminal
|
||||
// (that chain has not unwound to its release yet).
|
||||
const holds = writeHoldsRef.current
|
||||
const heldCount = holds.get(handle) ?? 0
|
||||
if (heldCount === 0 && !acquireMobileNativeChatTerminalWrite(handle)) {
|
||||
onSendError('Answer not sent')
|
||||
return false
|
||||
}
|
||||
holds.set(handle, heldCount + 1)
|
||||
const previousTurn = writeTurnsRef.current.get(handle) ?? Promise.resolve(true)
|
||||
let finishTurn: (safeToContinue: boolean) => void = () => undefined
|
||||
const turn = new Promise<boolean>((resolve) => {
|
||||
finishTurn = resolve
|
||||
})
|
||||
writeTurnsRef.current.set(handle, turn)
|
||||
// A new answer supersedes any still-pending keystroke writes.
|
||||
cancelPending()
|
||||
const generation = generationRef.current
|
||||
let sawUnknownOutcome = false
|
||||
let sawAcceptedGroup = false
|
||||
// One budget for the whole answer instead of a fresh timeout per keystroke
|
||||
// group, which let an N-group selector hold the card for N × the send timeout.
|
||||
// It bounds transport time only: each deliberate pacing wait is credited back
|
||||
// below, so a long multi-question answer still gets a full budget to write in.
|
||||
let deadline = openMobileNativeChatSendBudget()
|
||||
const sendTerminal = async (body: string, enter: boolean): Promise<boolean> => {
|
||||
const activeRoute = activeRouteRef.current
|
||||
if (
|
||||
!activeRoute.enabled ||
|
||||
activeRoute.client !== client ||
|
||||
activeRoute.sessionId !== sessionId ||
|
||||
activeRoute.streamIdentity !== streamIdentity ||
|
||||
handleRef.current !== handle
|
||||
) {
|
||||
return false
|
||||
}
|
||||
const outcome = await sendMobileNativeChatMessageWithOutcome({
|
||||
client,
|
||||
terminal: handle,
|
||||
text: body,
|
||||
enter,
|
||||
deadline,
|
||||
...(deviceTokenRef.current
|
||||
? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } }
|
||||
: {})
|
||||
})
|
||||
if (outcome === 'unknown') {
|
||||
sawUnknownOutcome = true
|
||||
}
|
||||
if (outcome === 'accepted') {
|
||||
sawAcceptedGroup = true
|
||||
}
|
||||
return outcome === 'accepted'
|
||||
}
|
||||
const wait = (ms: number): Promise<boolean> =>
|
||||
new Promise((resolve) => {
|
||||
const delay = {
|
||||
timer: setTimeout(() => {
|
||||
delaysRef.current.delete(delay)
|
||||
resolve(generationRef.current === generation)
|
||||
}, ms),
|
||||
resolve
|
||||
}
|
||||
delaysRef.current.add(delay)
|
||||
})
|
||||
const fail = (): false => {
|
||||
if (generationRef.current === generation) {
|
||||
// Why: keystrokes that may have landed (ack lost / path cutover) must
|
||||
// not read as a definite failure — a blind resend could double-step
|
||||
// the selector. An earlier group that WAS accepted is the same hazard
|
||||
// in definite form: a multi-question answer whose shared budget ran out
|
||||
// mid-sequence left the remote selector half-stepped, and telling the
|
||||
// user nothing was sent invites a retry on top of the advanced state.
|
||||
onSendError(
|
||||
sawAcceptedGroup
|
||||
? 'Answer partly sent — check chat before retrying'
|
||||
: sawUnknownOutcome
|
||||
? 'Answer unconfirmed — check chat before retrying'
|
||||
: 'Answer not sent'
|
||||
)
|
||||
}
|
||||
return false
|
||||
}
|
||||
// Grok commits pasted labels; Claude and Codex need their selector-specific
|
||||
// keystrokes paced so each step renders before the next lands.
|
||||
if (!shouldStepNativeChatAskAnswer(agentRef.current)) {
|
||||
// This shape pastes the label into the composer and commits it, so an
|
||||
// orphaned image paste would be submitted along with the answer (#10228).
|
||||
// The selector shapes below deliberately skip the heal: their keys are
|
||||
// `enter: false` for an active overlay, and a single-select answer is a
|
||||
// bare option digit that cannot submit the line at all, so clearing there
|
||||
// would consume the marker still protecting the next real message.
|
||||
// Desktop splits it identically — use-native-chat-interactive-send.ts
|
||||
// routes only the pasted-label shape through the clearing sender.
|
||||
if (
|
||||
!(await healMobileNativeChatStaleInput({
|
||||
client,
|
||||
terminal: handle,
|
||||
deviceToken: deviceTokenRef.current,
|
||||
deadline
|
||||
}))
|
||||
) {
|
||||
if (generationRef.current === generation) {
|
||||
onSendError('Answer not sent')
|
||||
let predecessorSafe = true
|
||||
try {
|
||||
predecessorSafe = await previousTurn
|
||||
if (!predecessorSafe) {
|
||||
// Fenced. Report it: the card re-enables on a false result, so silence
|
||||
// here is indistinguishable from a dead button. "Check chat" rather than
|
||||
// a bare "not sent" because the PREVIOUS answer's keys may have landed.
|
||||
// Gate on the turn slot, not the generation: a dropped input lease bumps
|
||||
// the generation without writing the Escape that Stop and ask-cancel do,
|
||||
// so the card is still up and silence there strands an advanced selector.
|
||||
if (writeTurnsRef.current.get(handle) === turn) {
|
||||
onSendError('Answer not sent — check chat before retrying')
|
||||
}
|
||||
return false
|
||||
}
|
||||
// Superseded by a newer answer, which owns the error surface from here.
|
||||
if (generationRef.current !== generation) {
|
||||
return false
|
||||
}
|
||||
return (await sendTerminal(formatAskAnswer(prompt, selections), true)) || fail()
|
||||
}
|
||||
const groups =
|
||||
resolveNativeChatTranscriptAgent(agentRef.current) === 'codex'
|
||||
? buildCodexAskAnswerKeys(prompt, selections)
|
||||
: buildAskAnswerKeys(prompt, selections)
|
||||
for (let index = 0; index < groups.length; index += 1) {
|
||||
if (generationRef.current !== generation) {
|
||||
return false
|
||||
}
|
||||
const group = groups[index]!
|
||||
const body = 'raw' in group ? group.raw : sanitizeAskFreeText(group.text)
|
||||
if (!(await sendTerminal(body, false))) {
|
||||
return fail()
|
||||
}
|
||||
if (index < groups.length - 1) {
|
||||
if (!(await wait(MOBILE_NATIVE_CHAT_QUESTION_STEP_MS))) {
|
||||
// One budget for the whole answer instead of a fresh timeout per keystroke
|
||||
// group, which let an N-group selector hold the card for N × the send timeout.
|
||||
// It bounds transport time only: each deliberate pacing wait is credited back
|
||||
// below, so a long multi-question answer still gets a full budget to write in.
|
||||
let deadline = openMobileNativeChatSendBudget()
|
||||
const sendTerminal = async (body: string, enter: boolean): Promise<boolean> => {
|
||||
const activeRoute = activeRouteRef.current
|
||||
if (
|
||||
!activeRoute.enabled ||
|
||||
activeRoute.client !== client ||
|
||||
activeRoute.sessionId !== sessionId ||
|
||||
activeRoute.streamIdentity !== streamIdentity ||
|
||||
handleRef.current !== handle
|
||||
) {
|
||||
return false
|
||||
}
|
||||
// Pacing is deliberate, not transport latency — don't charge it to the budget.
|
||||
deadline += MOBILE_NATIVE_CHAT_QUESTION_STEP_MS
|
||||
const outcome = await sendMobileNativeChatMessageWithOutcome({
|
||||
client,
|
||||
terminal: handle,
|
||||
text: body,
|
||||
enter,
|
||||
deadline,
|
||||
...(deviceTokenRef.current
|
||||
? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } }
|
||||
: {})
|
||||
})
|
||||
if (outcome === 'unknown') {
|
||||
sawUnknownOutcome = true
|
||||
}
|
||||
if (outcome === 'accepted') {
|
||||
sawAcceptedGroup = true
|
||||
}
|
||||
return outcome === 'accepted'
|
||||
}
|
||||
const wait = (ms: number): Promise<boolean> => {
|
||||
// Already superseded: don't hold the successor for a full pacing step
|
||||
// waiting on a timer whose only job is to report the cancellation.
|
||||
if (generationRef.current !== generation) {
|
||||
return Promise.resolve(false)
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const delay = {
|
||||
timer: setTimeout(() => {
|
||||
delaysRef.current.delete(delay)
|
||||
resolve(generationRef.current === generation)
|
||||
}, ms),
|
||||
resolve
|
||||
}
|
||||
delaysRef.current.add(delay)
|
||||
})
|
||||
}
|
||||
const fail = (): false => {
|
||||
if (generationRef.current === generation) {
|
||||
// Why: keystrokes that may have landed (ack lost / path cutover) must
|
||||
// not read as a definite failure — a blind resend could double-step
|
||||
// the selector. An earlier group that WAS accepted is the same hazard
|
||||
// in definite form: a multi-question answer whose shared budget ran out
|
||||
// mid-sequence left the remote selector half-stepped, and telling the
|
||||
// user nothing was sent invites a retry on top of the advanced state.
|
||||
onSendError(
|
||||
sawAcceptedGroup
|
||||
? 'Answer partly sent — check chat before retrying'
|
||||
: sawUnknownOutcome
|
||||
? 'Answer unconfirmed — check chat before retrying'
|
||||
: 'Answer not sent'
|
||||
)
|
||||
}
|
||||
return false
|
||||
}
|
||||
// Grok commits pasted labels; Claude and Codex need their selector-specific
|
||||
// keystrokes paced so each step renders before the next lands.
|
||||
if (!shouldStepNativeChatAskAnswer(agentRef.current)) {
|
||||
// This shape pastes the label into the composer and commits it, so an
|
||||
// orphaned image paste would be submitted along with the answer (#10228).
|
||||
// The selector shapes below deliberately skip the heal: their keys are
|
||||
// `enter: false` for an active overlay, and a single-select answer is a
|
||||
// bare option digit that cannot submit the line at all, so clearing there
|
||||
// would consume the marker still protecting the next real message.
|
||||
// Desktop splits it identically — use-native-chat-interactive-send.ts
|
||||
// routes only the pasted-label shape through the clearing sender.
|
||||
if (
|
||||
!(await healMobileNativeChatStaleInput({
|
||||
client,
|
||||
terminal: handle,
|
||||
deviceToken: deviceTokenRef.current,
|
||||
deadline
|
||||
}))
|
||||
) {
|
||||
if (generationRef.current === generation) {
|
||||
onSendError('Answer not sent')
|
||||
}
|
||||
return false
|
||||
}
|
||||
if (generationRef.current !== generation) {
|
||||
return false
|
||||
}
|
||||
// A chain a successor took over from must not report success either: an
|
||||
// accepted answer retires the shared send-error banner, wiping the
|
||||
// successor's fence. Test the turn slot, not the generation counter —
|
||||
// Stop, ask-cancel and a dropped lease all bump the generation with no
|
||||
// successor, and there a landed answer IS a success.
|
||||
const sent = (await sendTerminal(formatAskAnswer(prompt, selections), true)) || fail()
|
||||
return sent && writeTurnsRef.current.get(handle) === turn
|
||||
}
|
||||
const groups =
|
||||
resolveNativeChatTranscriptAgent(agentRef.current) === 'codex'
|
||||
? buildCodexAskAnswerKeys(prompt, selections)
|
||||
: buildAskAnswerKeys(prompt, selections)
|
||||
for (let index = 0; index < groups.length; index += 1) {
|
||||
if (generationRef.current !== generation) {
|
||||
return false
|
||||
}
|
||||
const group = groups[index]!
|
||||
const body = 'raw' in group ? group.raw : sanitizeAskFreeText(group.text)
|
||||
if (!(await sendTerminal(body, false))) {
|
||||
return fail()
|
||||
}
|
||||
if (index < groups.length - 1) {
|
||||
if (!(await wait(MOBILE_NATIVE_CHAT_QUESTION_STEP_MS))) {
|
||||
return false
|
||||
}
|
||||
// Pacing is deliberate, not transport latency — don't charge it to the budget.
|
||||
deadline += MOBILE_NATIVE_CHAT_QUESTION_STEP_MS
|
||||
}
|
||||
}
|
||||
// Taken over on the last key: same as above, the successor owns the surface.
|
||||
return groups.length > 0 && writeTurnsRef.current.get(handle) === turn
|
||||
} finally {
|
||||
// Any accepted key changed the live selector, so a queued replacement
|
||||
// cannot safely apply its from-scratch key plan to that new position.
|
||||
finishTurn(predecessorSafe && !sawUnknownOutcome && !sawAcceptedGroup)
|
||||
if (writeTurnsRef.current.get(handle) === turn) {
|
||||
writeTurnsRef.current.delete(handle)
|
||||
}
|
||||
// Last chain out releases; a superseded chain unwinding late must not
|
||||
// free the lock out from under the successor sharing its hold.
|
||||
const remaining = (holds.get(handle) ?? 1) - 1
|
||||
if (remaining <= 0) {
|
||||
holds.delete(handle)
|
||||
releaseMobileNativeChatTerminalWrite(handle)
|
||||
} else {
|
||||
holds.set(handle, remaining)
|
||||
}
|
||||
}
|
||||
return groups.length > 0
|
||||
},
|
||||
[
|
||||
agentRef,
|
||||
|
||||
@@ -7,9 +7,11 @@ import { useMobileNativeChatAskDismiss } from './use-mobile-native-chat-ask-dism
|
||||
describe('useMobileNativeChatAskDismiss', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
let state: ReturnType<typeof useMobileNativeChatAskDismiss> | null = null
|
||||
let renders = 0
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
renders = 0
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -18,11 +20,51 @@ describe('useMobileNativeChatAskDismiss', () => {
|
||||
state = null
|
||||
})
|
||||
|
||||
function Harness({ prompt }: { prompt: AskPrompt }): null {
|
||||
state = useMobileNativeChatAskDismiss(prompt)
|
||||
function Harness({
|
||||
prompt,
|
||||
detectedPrompt = prompt,
|
||||
scopeKey = 'tab-1',
|
||||
sessionKey = 'session-1',
|
||||
observing = true
|
||||
}: {
|
||||
prompt: AskPrompt | null
|
||||
detectedPrompt?: AskPrompt | null
|
||||
scopeKey?: string | null
|
||||
sessionKey?: string | null
|
||||
observing?: boolean
|
||||
}): null {
|
||||
renders += 1
|
||||
state = useMobileNativeChatAskDismiss({
|
||||
ask: prompt,
|
||||
detectedAsk: detectedPrompt,
|
||||
scopeKey,
|
||||
sessionKey,
|
||||
observing
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
async function mount(props: Parameters<typeof Harness>[0]): Promise<void> {
|
||||
const original = console.error
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
original(...args)
|
||||
})
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Harness, props))
|
||||
})
|
||||
} finally {
|
||||
consoleSpy.mockRestore()
|
||||
}
|
||||
}
|
||||
|
||||
async function update(props: Parameters<typeof Harness>[0]): Promise<void> {
|
||||
await act(async () => renderer?.update(createElement(Harness, props)))
|
||||
}
|
||||
|
||||
const first: AskPrompt = {
|
||||
questions: [
|
||||
{ question: 'same first', multiSelect: false, options: [] },
|
||||
@@ -37,24 +79,129 @@ describe('useMobileNativeChatAskDismiss', () => {
|
||||
}
|
||||
|
||||
it('shows a structurally different replacement without an intervening null', async () => {
|
||||
const original = console.error
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
original(...args)
|
||||
})
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Harness, { prompt: first }))
|
||||
})
|
||||
} finally {
|
||||
consoleSpy.mockRestore()
|
||||
}
|
||||
await mount({ prompt: first })
|
||||
act(() => state?.dismissAsk())
|
||||
expect(state?.showAsk).toBe(false)
|
||||
|
||||
await act(async () => renderer?.update(createElement(Harness, { prompt: replacement })))
|
||||
await update({ prompt: replacement })
|
||||
expect(state?.showAsk).toBe(true)
|
||||
|
||||
await update({ prompt: first })
|
||||
expect(state?.showAsk).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps a dismissal while status gating hides a still-detected prompt', async () => {
|
||||
await mount({ prompt: first })
|
||||
const acceptedDismiss = state!.dismissAsk
|
||||
|
||||
await update({ prompt: null, detectedPrompt: first })
|
||||
act(() => acceptedDismiss())
|
||||
await update({ prompt: first })
|
||||
|
||||
expect(state?.showAsk).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores an answer that settles after its prompt cleared', async () => {
|
||||
await mount({ prompt: first })
|
||||
const lateDismiss = state!.dismissAsk
|
||||
|
||||
await update({ prompt: null })
|
||||
act(() => lateDismiss())
|
||||
await update({ prompt: first })
|
||||
|
||||
expect(state?.showAsk).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps a dismissal taken on-chat across a chat→terminal→chat toggle', async () => {
|
||||
// The common case: answer the card, then toggle to the terminal. The prompt
|
||||
// derives to null while hidden, and that null must not retire the dismissal.
|
||||
await mount({ prompt: first })
|
||||
act(() => state?.dismissAsk())
|
||||
expect(state?.showAsk).toBe(false)
|
||||
|
||||
await update({ prompt: null, observing: false })
|
||||
await update({ prompt: first, observing: true })
|
||||
|
||||
expect(state?.showAsk).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps a dismissal across a chat→terminal→chat toggle', async () => {
|
||||
// While the chat surface is hidden the prompt derives to null; that null
|
||||
// proves nothing about the agent and must not reset the dismissal.
|
||||
await mount({ prompt: first })
|
||||
const acceptedDismiss = state!.dismissAsk
|
||||
|
||||
await update({ prompt: null, observing: false })
|
||||
act(() => acceptedDismiss())
|
||||
await update({ prompt: first, observing: true })
|
||||
|
||||
expect(state?.showAsk).toBe(false)
|
||||
})
|
||||
|
||||
it('forgets the dismissal once the prompt clears while observable', async () => {
|
||||
await mount({ prompt: first })
|
||||
act(() => state?.dismissAsk())
|
||||
|
||||
// Agent moved on: the prompt cleared with chat visible.
|
||||
await update({ prompt: null, observing: true })
|
||||
await update({ prompt: first, observing: true })
|
||||
|
||||
expect(state?.showAsk).toBe(true)
|
||||
})
|
||||
|
||||
it('scopes the dismissal to the tab that showed the card', async () => {
|
||||
await mount({ prompt: first, scopeKey: 'tab-1' })
|
||||
act(() => state?.dismissAsk())
|
||||
|
||||
// The same question on another tab is a different pending prompt.
|
||||
await update({ prompt: first, scopeKey: 'tab-2' })
|
||||
expect(state?.showAsk).toBe(true)
|
||||
act(() => state?.dismissAsk())
|
||||
expect(state?.showAsk).toBe(false)
|
||||
|
||||
// Dismissing tab 2 must not overwrite tab 1's dismissal.
|
||||
await update({ prompt: first, scopeKey: 'tab-1' })
|
||||
expect(state?.showAsk).toBe(false)
|
||||
|
||||
await update({ prompt: first, scopeKey: 'tab-2' })
|
||||
expect(state?.showAsk).toBe(false)
|
||||
})
|
||||
|
||||
it('shows an identical prompt after the tab starts a new provider session', async () => {
|
||||
await mount({ prompt: first, sessionKey: 'session-1' })
|
||||
act(() => state?.dismissAsk())
|
||||
|
||||
await update({ prompt: first, sessionKey: 'session-2' })
|
||||
|
||||
expect(state?.showAsk).toBe(true)
|
||||
})
|
||||
|
||||
it('reports nothing to show without a prompt', async () => {
|
||||
await mount({ prompt: null })
|
||||
expect(state?.showAsk).toBe(false)
|
||||
})
|
||||
|
||||
it('does not re-render a scope with nothing dismissed when the prompt changes', async () => {
|
||||
await mount({ prompt: first })
|
||||
const afterMount = renders
|
||||
|
||||
await update({ prompt: replacement })
|
||||
|
||||
// One render for the prop change and no more: the reset effect must hand back
|
||||
// the same Map when this scope has no dismissal, or every observed prompt
|
||||
// change commits a fresh one. The hook sits on the session route, so that
|
||||
// wasted commit re-renders the whole session surface.
|
||||
expect(renders).toBe(afterMount + 1)
|
||||
})
|
||||
|
||||
it('records a settled answer against its originating tab', async () => {
|
||||
await mount({ prompt: first, scopeKey: 'tab-1' })
|
||||
const tabOneDismiss = state!.dismissAsk
|
||||
|
||||
await update({ prompt: replacement, scopeKey: 'tab-2' })
|
||||
act(() => tabOneDismiss())
|
||||
await update({ prompt: first, scopeKey: 'tab-1' })
|
||||
|
||||
expect(state?.showAsk).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,26 +1,79 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { AskPrompt } from './mobile-native-chat-ask'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { nativeChatAskDismissKey, type AskPrompt } from './mobile-native-chat-ask'
|
||||
|
||||
type AskDismissal = { sessionKey: string | null; askKey: string }
|
||||
type DetectedAsk = { sessionKey: string | null; askKey: string | null }
|
||||
|
||||
/** Track the answered-ask key so the lingering live status doesn't re-show the
|
||||
* same card. The agent emits a post-tool event with the same prompt right after
|
||||
* an answer, so the card is hidden until a genuinely different question arrives. */
|
||||
export function useMobileNativeChatAskDismiss(ask?: AskPrompt | null): {
|
||||
* an answer, so the card is hidden until a genuinely different question arrives.
|
||||
*
|
||||
* Owned by the controller, not the chat subtree: the overlay unmounts on a
|
||||
* chat↔terminal view toggle, and a dismissal must survive that round-trip. */
|
||||
export function useMobileNativeChatAskDismiss(args: {
|
||||
ask: AskPrompt | null
|
||||
/** Ungated prompt payload. A working/done status hides the card but does not
|
||||
* prove the sticky prompt itself cleared. Required, and never defaulted to
|
||||
* `ask`: reading the gated prompt as the detected one is the resurfacing bug
|
||||
* this hook exists to close. */
|
||||
detectedAsk: AskPrompt | null
|
||||
/** Tab scope retains dismissals across tab switches. */
|
||||
scopeKey: string | null
|
||||
/** Provider-session identity distinguishes restarts without growing the tab map. */
|
||||
sessionKey: string | null
|
||||
/** True while the chat surface can actually observe the prompt. A null ask it
|
||||
* cannot see — off-chat, or before a re-subscribed transcript lands — proves
|
||||
* nothing and must not reset the dismissal; that reset resurfaced the card. */
|
||||
observing: boolean
|
||||
}): {
|
||||
askKey: string | null
|
||||
showAsk: boolean
|
||||
dismissAsk: () => void
|
||||
} {
|
||||
const askKey = ask ? JSON.stringify(ask.questions) : null
|
||||
const [dismissedAskKey, setDismissedAskKey] = useState<string | null>(null)
|
||||
// Once the prompt clears (agent moved on), forget the dismissal so a later
|
||||
// question — even an identical one — shows again instead of staying hidden.
|
||||
const askPresent = ask != null
|
||||
const { ask, detectedAsk, scopeKey, sessionKey, observing } = args
|
||||
const askKey = useMemo(() => nativeChatAskDismissKey(ask), [ask])
|
||||
const detectedAskKey = useMemo(() => nativeChatAskDismissKey(detectedAsk), [detectedAsk])
|
||||
const detectedByScopeRef = useRef(new Map<string | null, DetectedAsk>())
|
||||
const [dismissedByScope, setDismissedByScope] = useState<Map<string | null, AskDismissal>>(
|
||||
() => new Map()
|
||||
)
|
||||
useEffect(() => {
|
||||
if (!askPresent) {
|
||||
setDismissedAskKey(null)
|
||||
if (observing) {
|
||||
detectedByScopeRef.current.set(scopeKey, { sessionKey, askKey: detectedAskKey })
|
||||
}
|
||||
}, [askPresent])
|
||||
const showAsk = askPresent && askKey !== dismissedAskKey
|
||||
const dismissAsk = (): void => setDismissedAskKey(askKey)
|
||||
}, [observing, detectedAskKey, scopeKey, sessionKey])
|
||||
// A cleared or genuinely different detected prompt retires the old dismissal.
|
||||
useEffect(() => {
|
||||
if (observing) {
|
||||
setDismissedByScope((previous) => {
|
||||
const dismissed = previous.get(scopeKey)
|
||||
if (
|
||||
dismissed === undefined ||
|
||||
(dismissed.sessionKey === sessionKey && dismissed.askKey === detectedAskKey)
|
||||
) {
|
||||
return previous
|
||||
}
|
||||
const next = new Map(previous)
|
||||
next.delete(scopeKey)
|
||||
return next
|
||||
})
|
||||
}
|
||||
}, [observing, detectedAskKey, scopeKey, sessionKey])
|
||||
const dismissed = dismissedByScope.get(scopeKey)
|
||||
const showAsk =
|
||||
askKey !== null && !(dismissed?.sessionKey === sessionKey && dismissed.askKey === askKey)
|
||||
const dismissAsk = (): void => {
|
||||
const detected = detectedByScopeRef.current.get(scopeKey)
|
||||
if (askKey !== null && detected?.sessionKey === sessionKey && detected.askKey === askKey) {
|
||||
setDismissedByScope((previous) => {
|
||||
const current = previous.get(scopeKey)
|
||||
if (current?.sessionKey === sessionKey && current.askKey === askKey) {
|
||||
return previous
|
||||
}
|
||||
return new Map(previous).set(scopeKey, { sessionKey, askKey })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { askKey, showAsk, dismissAsk }
|
||||
}
|
||||
|
||||
@@ -15,6 +15,12 @@ const holdUnconfirmedSend = vi.fn()
|
||||
const viewMode = { isTabChatView: (_tabId: string) => true }
|
||||
const sessionState = { messages: [] as unknown[], status: 'ready', transcriptLoading: false }
|
||||
const draftsArgs: Record<string, unknown>[] = []
|
||||
const promptsState = {
|
||||
permission: null as unknown,
|
||||
question: null as unknown,
|
||||
detectedAsk: null as unknown,
|
||||
ask: null as unknown
|
||||
}
|
||||
|
||||
// The controller composes many session hooks; each is mocked to a minimal shape
|
||||
// so this test isolates the send seam (outcome -> drafts accounting).
|
||||
@@ -45,10 +51,14 @@ vi.mock('./use-mobile-native-chat-drafts', () => ({
|
||||
}
|
||||
}))
|
||||
vi.mock('./use-mobile-native-chat-prompts', () => ({
|
||||
useMobileNativeChatPrompts: () => ({ permission: null, question: null, ask: null })
|
||||
useMobileNativeChatPrompts: () => promptsState
|
||||
}))
|
||||
const answerSendArgs: { streamIdentity?: string }[] = []
|
||||
vi.mock('./use-mobile-native-chat-answer-send', () => ({
|
||||
useMobileNativeChatAnswerSend: () => ({ answerAsk: vi.fn(), cancelPending: vi.fn() })
|
||||
useMobileNativeChatAnswerSend: (args: { streamIdentity?: string }) => {
|
||||
answerSendArgs.push(args)
|
||||
return { answerAsk: vi.fn(), cancelPending: vi.fn() }
|
||||
}
|
||||
}))
|
||||
vi.mock('./mobile-native-chat-permission-send', () => ({
|
||||
useMobileNativeChatPermissionSend: () => vi.fn()
|
||||
@@ -76,6 +86,7 @@ import {
|
||||
useMobileNativeChatController,
|
||||
type MobileNativeChatController
|
||||
} from './use-mobile-native-chat-controller'
|
||||
import type { MobileNativeChatStatus } from './use-mobile-native-chat-session'
|
||||
|
||||
const sendWithOutcome = vi.mocked(sendMobileNativeChatMessageWithOutcome)
|
||||
|
||||
@@ -421,3 +432,380 @@ describe('useMobileNativeChatController launch-draft wiring', () => {
|
||||
expect(draftsArgs.at(-1)).toMatchObject({ launchDraft: null, chatActive: true })
|
||||
})
|
||||
})
|
||||
|
||||
describe('useMobileNativeChatController ask dismissal across a transcript reload', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
let controller: MobileNativeChatController | null = null
|
||||
const clientStub = { sendRequest: vi.fn() }
|
||||
const PROMPT = { questions: [{ question: 'Which path?', multiSelect: false, options: [] }] }
|
||||
|
||||
const chatTab = { type: 'terminal', id: 'tab-1', launchAgent: 'claude' }
|
||||
/** Mutable so a test can move the user to another tab — or restart the agent
|
||||
* into a new provider session on the same tab — mid-render. */
|
||||
const activeTab = { id: 'tab-1', sessionId: 'session-1' }
|
||||
|
||||
function Harness(): null {
|
||||
controller = useMobileNativeChatController({
|
||||
client: clientStub as unknown as RpcClient,
|
||||
connState: 'connected',
|
||||
hostId: 'h',
|
||||
worktreeId: 'w',
|
||||
activeSessionTab: {
|
||||
...chatTab,
|
||||
id: activeTab.id,
|
||||
agentStatus: { agentType: 'claude', providerSession: { id: activeTab.sessionId } }
|
||||
} as never,
|
||||
activeSessionTabId: activeTab.id,
|
||||
activeHandleRef: { current: 'term-1' },
|
||||
deviceTokenRef: { current: null },
|
||||
nativeChatTranscriptIsLocalReadable: true,
|
||||
nativeChatInputLeaseReady: true,
|
||||
onSendError: vi.fn(),
|
||||
onSendResolved: vi.fn()
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
/** Re-render under the current viewMode/prompts/session stand-ins. */
|
||||
function step(): void {
|
||||
act(() => {
|
||||
renderer?.update(createElement(Harness))
|
||||
})
|
||||
}
|
||||
|
||||
/** Drive the transcript stand-in the way the real hook couples its fields, so
|
||||
* these tests can only express states the session hook can actually reach:
|
||||
* `transcriptLoading` is exactly `status === 'loading'`, and `messages` is
|
||||
* withheld until a read lands. `rows` is what the last landed read left
|
||||
* behind — 0 means no read has ever landed for this identity. */
|
||||
function setTranscript(status: MobileNativeChatStatus, rows = 1): void {
|
||||
sessionState.status = status
|
||||
sessionState.transcriptLoading = status === 'loading'
|
||||
sessionState.messages =
|
||||
status === 'ready' || status === 'error' ? Array.from({ length: rows }, () => ({})) : []
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
viewMode.isTabChatView = () => true
|
||||
setTranscript('ready')
|
||||
promptsState.ask = PROMPT
|
||||
promptsState.detectedAsk = PROMPT
|
||||
const original = console.error
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation((...a) => {
|
||||
if (typeof a[0] === 'string' && a[0].includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
original(...a)
|
||||
})
|
||||
try {
|
||||
act(() => {
|
||||
renderer = create(createElement(Harness))
|
||||
})
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
controller = null
|
||||
promptsState.ask = null
|
||||
promptsState.detectedAsk = null
|
||||
setTranscript('ready', 0)
|
||||
viewMode.isTabChatView = () => true
|
||||
activeTab.id = 'tab-1'
|
||||
activeTab.sessionId = 'session-1'
|
||||
})
|
||||
|
||||
it('scopes the dismissal to the tab it was taken on', () => {
|
||||
act(() => controller?.dismissNativeChatAsk())
|
||||
expect(controller?.nativeChatAsk).toBeNull()
|
||||
|
||||
// Another tab's agent is parked on a byte-identical question; it is a
|
||||
// different pending prompt and tab 1's answer must not hide it.
|
||||
activeTab.id = 'tab-2'
|
||||
step()
|
||||
|
||||
expect(controller?.nativeChatAsk).not.toBeNull()
|
||||
})
|
||||
|
||||
it('shows a restarted session’s identical first question on the same tab', () => {
|
||||
// A restart, `/clear`, or resume swaps the provider session inside one tab,
|
||||
// and the next session's first question is often byte-identical — same repo,
|
||||
// same prompt, same template. Keyed by tab alone the old answer hides it, so
|
||||
// the live card never renders and the turn sits blocked with nothing to act on.
|
||||
act(() => controller?.dismissNativeChatAsk())
|
||||
expect(controller?.nativeChatAsk).toBeNull()
|
||||
|
||||
// The restart re-subscribes the transcript, so the read is in flight for a beat.
|
||||
activeTab.sessionId = 'session-2'
|
||||
setTranscript('loading')
|
||||
promptsState.ask = null
|
||||
promptsState.detectedAsk = null
|
||||
step()
|
||||
|
||||
setTranscript('ready')
|
||||
promptsState.ask = PROMPT
|
||||
promptsState.detectedAsk = PROMPT
|
||||
step()
|
||||
|
||||
expect(controller?.nativeChatAsk).not.toBeNull()
|
||||
})
|
||||
|
||||
it('retires the dismissal only on the ungated prompt, not the gated one', () => {
|
||||
// The paused gate hides the card whenever the agent is not waiting, but a
|
||||
// hidden card is no evidence the sticky status prompt cleared. Feeding the
|
||||
// gated `ask` in as the detected prompt would read that gap as "prompt gone",
|
||||
// retire the dismissal, and bring the answered card back when it reopens.
|
||||
act(() => controller?.dismissNativeChatAsk())
|
||||
expect(controller?.nativeChatAsk).toBeNull()
|
||||
|
||||
promptsState.ask = null
|
||||
step()
|
||||
|
||||
promptsState.ask = PROMPT
|
||||
step()
|
||||
|
||||
expect(controller?.nativeChatAsk).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the dismissal while the re-subscribed transcript is still empty', () => {
|
||||
// Toggling views re-subscribes the transcript, so a transcript-derived ask
|
||||
// reads as null for a beat with chat already visible. Believing that null
|
||||
// retires the dismissal and the answered card comes back (#12497).
|
||||
expect(controller?.nativeChatAsk).not.toBeNull()
|
||||
act(() => controller?.dismissNativeChatAsk())
|
||||
expect(controller?.nativeChatAsk).toBeNull()
|
||||
|
||||
// Chat -> terminal.
|
||||
viewMode.isTabChatView = () => false
|
||||
promptsState.ask = null
|
||||
promptsState.detectedAsk = null
|
||||
step()
|
||||
|
||||
// Terminal -> chat: observable again, but the transcript read is in flight.
|
||||
viewMode.isTabChatView = () => true
|
||||
setTranscript('loading')
|
||||
step()
|
||||
|
||||
// The read lands and re-derives the same still-pending ask.
|
||||
setTranscript('ready')
|
||||
promptsState.ask = PROMPT
|
||||
promptsState.detectedAsk = PROMPT
|
||||
step()
|
||||
|
||||
expect(controller?.nativeChatAsk).toBeNull()
|
||||
})
|
||||
|
||||
it('accepts an answer taken while the first transcript read is still in flight', () => {
|
||||
// A status-derived ask renders before any transcript lands, so the load window
|
||||
// must stay observable whenever a prompt is actually on screen — otherwise the
|
||||
// dismissal is silently dropped and the answered card never goes away.
|
||||
act(() => renderer?.unmount())
|
||||
setTranscript('loading')
|
||||
act(() => {
|
||||
renderer = create(createElement(Harness))
|
||||
})
|
||||
|
||||
expect(controller?.nativeChatAsk).not.toBeNull()
|
||||
act(() => controller?.dismissNativeChatAsk())
|
||||
|
||||
expect(controller?.nativeChatAsk).toBeNull()
|
||||
})
|
||||
|
||||
it('still retires the dismissal once a settled transcript reports no prompt', () => {
|
||||
// The load-window guard must not swallow the genuine reset: a prompt that
|
||||
// clears with the read settled means the agent moved on.
|
||||
act(() => controller?.dismissNativeChatAsk())
|
||||
expect(controller?.nativeChatAsk).toBeNull()
|
||||
|
||||
promptsState.ask = null
|
||||
promptsState.detectedAsk = null
|
||||
step()
|
||||
|
||||
promptsState.ask = PROMPT
|
||||
promptsState.detectedAsk = PROMPT
|
||||
step()
|
||||
|
||||
expect(controller?.nativeChatAsk).not.toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the dismissal while a dropped client empties the transcript', () => {
|
||||
// `transcriptLoading` is only true for an in-flight read. A dropped client
|
||||
// parks the session at 'idle', where the hook withholds `messages` with that
|
||||
// flag already false — so the derived prompt reads null for a reason that
|
||||
// says nothing about the agent, and the reset effect retired a live dismissal.
|
||||
act(() => controller?.dismissNativeChatAsk())
|
||||
expect(controller?.nativeChatAsk).toBeNull()
|
||||
|
||||
setTranscript('idle')
|
||||
promptsState.ask = null
|
||||
promptsState.detectedAsk = null
|
||||
step()
|
||||
|
||||
// Reconnect: the read lands and the same question is still pending.
|
||||
setTranscript('ready')
|
||||
promptsState.ask = PROMPT
|
||||
promptsState.detectedAsk = PROMPT
|
||||
step()
|
||||
|
||||
expect(controller?.nativeChatAsk).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the dismissal when the first read of a transcript errors', () => {
|
||||
// The host forwards an initial-drain failure as an error frame carrying an
|
||||
// EMPTY list, and that frame is not terminal — a real snapshot follows once
|
||||
// the read recovers. Judging the prompt from that never-populated list
|
||||
// retires the dismissal, and the recovered read brings the answered card
|
||||
// back over the composer.
|
||||
act(() => controller?.dismissNativeChatAsk())
|
||||
expect(controller?.nativeChatAsk).toBeNull()
|
||||
|
||||
setTranscript('error', 0)
|
||||
promptsState.ask = null
|
||||
promptsState.detectedAsk = null
|
||||
step()
|
||||
|
||||
// The read recovers with the same question still pending.
|
||||
setTranscript('ready')
|
||||
promptsState.ask = PROMPT
|
||||
promptsState.detectedAsk = PROMPT
|
||||
step()
|
||||
|
||||
expect(controller?.nativeChatAsk).toBeNull()
|
||||
})
|
||||
|
||||
it('retires the dismissal when a failed read still reports the last transcript', () => {
|
||||
// A read error that lands on top of rows from an earlier read leaves those
|
||||
// rows in `messages`, so a prompt that clears under it is real evidence —
|
||||
// unlike the never-read empty list of 'idle'/'waiting-session'. Treating
|
||||
// every error as unobservable would freeze the dismissal and hide the next
|
||||
// identical question for good.
|
||||
act(() => controller?.dismissNativeChatAsk())
|
||||
expect(controller?.nativeChatAsk).toBeNull()
|
||||
|
||||
setTranscript('error')
|
||||
promptsState.ask = null
|
||||
promptsState.detectedAsk = null
|
||||
step()
|
||||
|
||||
promptsState.ask = PROMPT
|
||||
promptsState.detectedAsk = PROMPT
|
||||
step()
|
||||
|
||||
expect(controller?.nativeChatAsk).not.toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the dismissal while the tab has no provider session yet', () => {
|
||||
// 'waiting-session' withholds `messages` the same way, also with
|
||||
// transcriptLoading false. The tab still shows chat (resolveMobileNativeChat
|
||||
// resolves from `launchAgent` alone), so a live dismissal is on screen for it.
|
||||
act(() => controller?.dismissNativeChatAsk())
|
||||
expect(controller?.nativeChatAsk).toBeNull()
|
||||
|
||||
setTranscript('waiting-session')
|
||||
promptsState.ask = null
|
||||
promptsState.detectedAsk = null
|
||||
step()
|
||||
|
||||
setTranscript('ready')
|
||||
promptsState.ask = PROMPT
|
||||
promptsState.detectedAsk = PROMPT
|
||||
step()
|
||||
|
||||
expect(controller?.nativeChatAsk).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('useMobileNativeChatController streaming scope', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
let controller: MobileNativeChatController | null = null
|
||||
const clientStub = { sendRequest: vi.fn() }
|
||||
|
||||
const workingTab = {
|
||||
type: 'terminal',
|
||||
id: 'tab-1',
|
||||
terminal: 'term-1',
|
||||
launchAgent: 'claude',
|
||||
agentStatus: {
|
||||
state: 'working',
|
||||
agentType: 'claude',
|
||||
providerSession: { id: 'session-1' }
|
||||
},
|
||||
isActive: true
|
||||
}
|
||||
|
||||
function Harness(): null {
|
||||
controller = useMobileNativeChatController({
|
||||
client: clientStub as unknown as RpcClient,
|
||||
connState: 'connected',
|
||||
hostId: 'h',
|
||||
worktreeId: 'w',
|
||||
activeSessionTab: workingTab as never,
|
||||
activeSessionTabId: 'tab-1',
|
||||
activeHandleRef: { current: 'term-1' },
|
||||
deviceTokenRef: { current: null },
|
||||
nativeChatTranscriptIsLocalReadable: true,
|
||||
nativeChatInputLeaseReady: true,
|
||||
onSendError: vi.fn(),
|
||||
onSendResolved: vi.fn()
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
viewMode.isTabChatView = () => true
|
||||
const original = console.error
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation((...a) => {
|
||||
if (typeof a[0] === 'string' && a[0].includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
original(...a)
|
||||
})
|
||||
try {
|
||||
act(() => {
|
||||
renderer = create(createElement(Harness))
|
||||
})
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
controller = null
|
||||
viewMode.isTabChatView = () => true
|
||||
})
|
||||
|
||||
it('holds the stream scope and liveness while the user peeks at the terminal', () => {
|
||||
expect(controller?.showNativeChat).toBe(true)
|
||||
const scopeKey = controller?.nativeChatStreamScopeKey
|
||||
expect(scopeKey).toContain('session-1')
|
||||
expect(controller?.nativeChatStreamLive).toBe(true)
|
||||
|
||||
viewMode.isTabChatView = () => false
|
||||
act(() => renderer?.update(createElement(Harness)))
|
||||
|
||||
expect(controller?.showNativeChat).toBe(false)
|
||||
expect(controller?.nativeChatAgentWorking).toBe(false)
|
||||
expect(controller?.nativeChatStreamScopeKey).toBe(scopeKey)
|
||||
expect(controller?.nativeChatStreamLive).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps the delayed-send route guard view-gated, unlike the stream scope', () => {
|
||||
const before = answerSendArgs.at(-1)?.streamIdentity
|
||||
expect(before).toBe(controller?.nativeChatStreamScopeKey)
|
||||
|
||||
viewMode.isTabChatView = () => false
|
||||
act(() => renderer?.update(createElement(Harness)))
|
||||
|
||||
const after = answerSendArgs.at(-1)?.streamIdentity
|
||||
expect(after).not.toBe(before)
|
||||
expect(after).not.toContain('session-1')
|
||||
expect(controller?.nativeChatStreamScopeKey).toBe(before)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
useCallback,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
type Dispatch,
|
||||
type MutableRefObject,
|
||||
@@ -16,10 +17,10 @@ import {
|
||||
import { type MobileNativeChatTab, resolveMobileNativeChat } from './mobile-native-chat-eligibility'
|
||||
import { detectAgentPermission } from './mobile-native-chat-permission'
|
||||
import { parseAgentQuestion } from './mobile-native-chat-question'
|
||||
import { openMobileNativeChatFile } from './mobile-native-chat-open-file'
|
||||
import { useMobileNativeChatPermissionSend } from './mobile-native-chat-permission-send'
|
||||
import 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,
|
||||
@@ -27,7 +28,10 @@ import {
|
||||
} 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'
|
||||
@@ -49,10 +53,19 @@ export type MobileNativeChatController = {
|
||||
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>
|
||||
handleNativeChatOpenFile: (relativePath: string) => void
|
||||
/** 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[]
|
||||
@@ -76,6 +89,9 @@ export type MobileNativeChatController = {
|
||||
/** 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
|
||||
@@ -119,12 +135,20 @@ export function useMobileNativeChatController(args: {
|
||||
: null
|
||||
const showNativeChat = activeChatResolution != null
|
||||
const showNativeChatRef = useRef(showNativeChat)
|
||||
showNativeChatRef.current = showNativeChat
|
||||
const activeChatAgentRef = useRef<string | null>(activeChatResolution?.agent ?? null)
|
||||
activeChatAgentRef.current = activeChatResolution?.agent ?? null
|
||||
const activeChatAgent = activeChatResolution?.agent ?? null
|
||||
const activeChatAgentRef = useRef<string | null>(activeChatAgent)
|
||||
useLayoutEffect(() => {
|
||||
showNativeChatRef.current = showNativeChat
|
||||
activeChatAgentRef.current = activeChatAgent
|
||||
}, [activeChatAgent, showNativeChat])
|
||||
|
||||
const activeChatSessionId = activeChatResolution?.sessionId ?? null
|
||||
const streamIdentity = `${hostId}\0${worktreeId}\0${activeSessionTabId ?? ''}\0${activeChatSessionId ?? ''}\0${activeHandleRef.current ?? ''}`
|
||||
const routeKey = `${hostId}\0${worktreeId}\0${activeSessionTabId ?? ''}`
|
||||
const streamIdentity = `${routeKey}\0${activeChatSessionId ?? ''}\0${activeHandleRef.current ?? ''}`
|
||||
// Same chat, but keyed off the tab rather than the view-gated resolution:
|
||||
// `streamIdentity` goes session-less the moment the user peeks at the terminal,
|
||||
// and a scope that flips on a view toggle throws the gate's baseline away.
|
||||
const streamScopeKey = `${routeKey}\0${activeSessionTab?.agentStatus?.providerSession?.id ?? ''}\0${activeHandleRef.current ?? ''}`
|
||||
|
||||
const nativeChatSession = useMobileNativeChatSession({
|
||||
client,
|
||||
@@ -160,6 +184,9 @@ export function useMobileNativeChatController(args: {
|
||||
|
||||
const nativeChatStatus = activeChatResolution ? activeSessionTab?.agentStatus : null
|
||||
const nativeChatAgentWorking = nativeChatStatus?.state === 'working'
|
||||
// Deliberately not gated on the chat view being visible: the streaming gate
|
||||
// has to tell "hidden mid-turn" from "the turn ended".
|
||||
const nativeChatStreamLive = activeSessionTab?.agentStatus?.state === 'working'
|
||||
// Throttle the streaming bubble: OpenCode emits a status frame per streamed
|
||||
// part, and each one re-renders and re-parses the whole accumulated markdown.
|
||||
const nativeChatStreamingText = useThrottledLatestValue(
|
||||
@@ -169,27 +196,30 @@ export function useMobileNativeChatController(args: {
|
||||
const {
|
||||
permission: nativeChatPermission,
|
||||
question: nativeChatQuestion,
|
||||
ask: nativeChatAsk
|
||||
detectedAsk: nativeChatDetectedAsk,
|
||||
ask: nativeChatAskPrompt
|
||||
} = useMobileNativeChatPrompts({
|
||||
enabled: activeChatResolution != null,
|
||||
status: nativeChatStatus,
|
||||
messages: nativeChatSession.messages
|
||||
})
|
||||
|
||||
const handleNativeChatOpenFile = useCallback(
|
||||
(pathText: string) => {
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
void openMobileNativeChatFile({
|
||||
client,
|
||||
worktreeId,
|
||||
pathText,
|
||||
terminal: activeHandleRef.current
|
||||
})
|
||||
},
|
||||
[activeHandleRef, client, worktreeId]
|
||||
)
|
||||
// A never-read transcript cannot prove that a dismissed prompt cleared.
|
||||
const nativeChatTranscriptSettled =
|
||||
nativeChatSession.status === 'ready' ||
|
||||
(nativeChatSession.status === 'error' && nativeChatSession.messages.length > 0)
|
||||
const nativeChatAskObservable =
|
||||
showNativeChat && (nativeChatDetectedAsk != null || nativeChatTranscriptSettled)
|
||||
const {
|
||||
askKey: nativeChatAskKey,
|
||||
showAsk: showNativeChatAsk,
|
||||
dismissAsk: dismissNativeChatAsk
|
||||
} = useMobileNativeChatAskDismiss({
|
||||
ask: nativeChatAskPrompt,
|
||||
detectedAsk: nativeChatDetectedAsk,
|
||||
scopeKey: activeSessionTabId,
|
||||
sessionKey: activeChatSessionId,
|
||||
observing: nativeChatAskObservable
|
||||
})
|
||||
|
||||
// Every chat write gates on both: the lease proves the input floor is ours, and
|
||||
// `connState` collapses a render before the lease does on disconnect.
|
||||
@@ -239,15 +269,23 @@ export function useMobileNativeChatController(args: {
|
||||
worktreeId
|
||||
})
|
||||
|
||||
// Why: the send seam reports outgoing catalog commands to session-option
|
||||
// tracking, but the options hook needs the seam's dispatcher — a ref breaks
|
||||
// the cycle without re-creating the send callbacks per snapshot.
|
||||
const recordSessionOptionCommandRef = useRef<(command: string) => void>(() => {})
|
||||
|
||||
const {
|
||||
send: handleNativeChatSend,
|
||||
sendWithOutcome: handleNativeChatSendWithOutcome,
|
||||
answerQuestion: handleNativeChatQuestionAnswer
|
||||
answerQuestion: handleNativeChatQuestionAnswer,
|
||||
dispatchCommand: handleNativeChatDispatchCommand
|
||||
} = useMobileNativeChatMessageSend({
|
||||
client,
|
||||
enabled: inputSendable,
|
||||
handleRef: activeHandleRef,
|
||||
deviceTokenRef,
|
||||
agentRef: activeChatAgentRef,
|
||||
commandSendRef: recordSessionOptionCommandRef,
|
||||
captureSendOrigin,
|
||||
readSeededLaunchDraftSeed,
|
||||
clearDraftForSend,
|
||||
@@ -256,6 +294,25 @@ export function useMobileNativeChatController(args: {
|
||||
holdUnconfirmedSend,
|
||||
onSendError
|
||||
})
|
||||
|
||||
// A Codex-style model change happens in the agent's own TUI picker — bring
|
||||
// the terminal view forward so the dispatched `/model` selector is visible.
|
||||
const handleAgentPicker = useCallback(() => {
|
||||
if (activeSessionTabId && isTabChatView(activeSessionTabId)) {
|
||||
toggleTabChatView(activeSessionTabId)
|
||||
}
|
||||
}, [activeSessionTabId, isTabChatView, toggleTabChatView])
|
||||
|
||||
const sessionOptions = useMobileNativeChatSessionOptions({
|
||||
agent: activeChatResolution?.agent ?? null,
|
||||
scopeKey: mobileNativeChatScopeKey(hostId, worktreeId, activeSessionTabId),
|
||||
reportedModel: activeSessionTab?.agentStatus?.model ?? null,
|
||||
dispatchCommand: handleNativeChatDispatchCommand,
|
||||
onAgentPicker: handleAgentPicker
|
||||
})
|
||||
useLayoutEffect(() => {
|
||||
recordSessionOptionCommandRef.current = sessionOptions.recordCommand
|
||||
}, [sessionOptions.recordCommand])
|
||||
// Card actions retire the route's held failure banner too, not just sends.
|
||||
const answerAsk = useNativeChatAcceptedAction(handleNativeChatAnswerAsk, onSendResolved)
|
||||
const cancelAsk = useNativeChatAcceptedAction(handleNativeChatCancelAsk, onSendResolved)
|
||||
@@ -273,10 +330,13 @@ export function useMobileNativeChatController(args: {
|
||||
nativeChatSession,
|
||||
nativeChatAgentWorking,
|
||||
nativeChatStreamingText,
|
||||
nativeChatStreamLive,
|
||||
nativeChatStreamScopeKey: streamScopeKey,
|
||||
nativeChatPermission,
|
||||
nativeChatQuestion,
|
||||
nativeChatAsk,
|
||||
handleNativeChatOpenFile,
|
||||
nativeChatAsk: showNativeChatAsk ? nativeChatAskPrompt : null,
|
||||
nativeChatAskKey,
|
||||
dismissNativeChatAsk,
|
||||
handleNativeChatAnswerAsk: answerAsk,
|
||||
handleNativeChatCancelAsk: cancelAsk,
|
||||
handleNativeChatRespondPermission: respond,
|
||||
@@ -286,6 +346,10 @@ export function useMobileNativeChatController(args: {
|
||||
handleNativeChatQuestionAnswer,
|
||||
handleNativeChatSend,
|
||||
handleNativeChatSendWithOutcome,
|
||||
readSeededLaunchDraft
|
||||
readSeededLaunchDraft,
|
||||
nativeChatSessionOptions:
|
||||
sessionOptions.snapshot.length > 0
|
||||
? { controller: sessionOptions, isWorking: nativeChatAgentWorking }
|
||||
: null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcResponse, RpcSuccess } from '../transport/types'
|
||||
import { resetMobileNativeChatStaleInputForTests } from './mobile-native-chat-stale-input'
|
||||
import { resetMobileNativeChatTerminalWritesForTests } from './mobile-native-chat-terminal-write-lock'
|
||||
import { useMobileNativeChatImageAttachments } from './use-mobile-native-chat-image-attachments'
|
||||
|
||||
// Fully stub the picker so the real expo/react-native chain never loads under
|
||||
@@ -88,9 +89,10 @@ describe('useMobileNativeChatImageAttachments', () => {
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
pick.mockReset()
|
||||
// Stale markers live at module scope now (they outlive the screen), so they
|
||||
// also outlive a test.
|
||||
// Stale markers and write locks live at module scope (they outlive the
|
||||
// screen), so they also outlive a test.
|
||||
resetMobileNativeChatStaleInputForTests()
|
||||
resetMobileNativeChatTerminalWritesForTests()
|
||||
})
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
|
||||
@@ -26,6 +26,10 @@ import {
|
||||
isMobileNativeChatInputStale,
|
||||
markMobileNativeChatInputStale
|
||||
} from './mobile-native-chat-stale-input'
|
||||
import {
|
||||
acquireMobileNativeChatTerminalWrite,
|
||||
releaseMobileNativeChatTerminalWrite
|
||||
} from './mobile-native-chat-terminal-write-lock'
|
||||
|
||||
type CurrentRef<T> = { readonly current: T }
|
||||
type ShowToast = (message: string, durationMs?: number) => void
|
||||
@@ -127,8 +131,6 @@ export function useMobileNativeChatImageAttachments({
|
||||
// checked 'connected' at entry, so only a ref can see a mid-upload disconnect.
|
||||
const connStateRef = useRef(connState)
|
||||
connStateRef.current = connState
|
||||
// Serialize clear/paste/submit ownership per terminal while allowing other tabs to send.
|
||||
const sendInFlightTerminalsRef = useRef(new Set<string>())
|
||||
|
||||
const attachments = (scopeKey ? attachmentsByScope[scopeKey] : undefined) ?? NO_ATTACHMENTS
|
||||
|
||||
@@ -219,15 +221,15 @@ export function useMobileNativeChatImageAttachments({
|
||||
|
||||
const sendNativeChat = useCallback(
|
||||
async (text: string): Promise<boolean> => {
|
||||
// Serialize clear/paste/submit ownership per terminal while allowing other
|
||||
// tabs to send. Shared with the prompt-card writes (answer/permission), so
|
||||
// a card tap can't interleave into a mid-flight paste sequence either.
|
||||
const operationTerminal = activeHandleRef.current
|
||||
if (operationTerminal && sendInFlightTerminalsRef.current.has(operationTerminal)) {
|
||||
if (operationTerminal && !acquireMobileNativeChatTerminalWrite(operationTerminal)) {
|
||||
onError?.()
|
||||
onSendError('Message not sent')
|
||||
return false
|
||||
}
|
||||
if (operationTerminal) {
|
||||
sendInFlightTerminalsRef.current.add(operationTerminal)
|
||||
}
|
||||
// One budget for the whole user action. The paste loop, the settle, and the
|
||||
// text body that follows are a single send from the composer's point of view;
|
||||
// opening a budget per leg let `sending` run to twice the stated ceiling.
|
||||
@@ -347,7 +349,7 @@ export function useMobileNativeChatImageAttachments({
|
||||
}
|
||||
} finally {
|
||||
if (operationTerminal) {
|
||||
sendInFlightTerminalsRef.current.delete(operationTerminal)
|
||||
releaseMobileNativeChatTerminalWrite(operationTerminal)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -19,6 +19,11 @@ vi.mock('./mobile-native-chat-stale-input', () => ({
|
||||
}))
|
||||
|
||||
import { useMobileNativeChatMessageSend } from './use-mobile-native-chat-message-send'
|
||||
import {
|
||||
acquireMobileNativeChatTerminalWrite,
|
||||
releaseMobileNativeChatTerminalWrite,
|
||||
resetMobileNativeChatTerminalWritesForTests
|
||||
} from './mobile-native-chat-terminal-write-lock'
|
||||
import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear'
|
||||
|
||||
type Send = ReturnType<typeof useMobileNativeChatMessageSend>
|
||||
@@ -28,23 +33,33 @@ const DRAFT = 'Linked Linear issue: ABC-123\nhttps://linear.app/x/issue/ABC-123'
|
||||
describe('useMobileNativeChatMessageSend', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
let api: Send | null = null
|
||||
const acceptSend = vi.fn()
|
||||
const holdUnconfirmedSend = vi.fn()
|
||||
const onCommandSend = vi.fn()
|
||||
const commandSendRef = { current: onCommandSend }
|
||||
const agentRef = { current: null as string | null }
|
||||
let onSendError = vi.fn()
|
||||
|
||||
const mount = (
|
||||
readSeededLaunchDraftSeed: () => { text: string; createdAt: number | null } | null
|
||||
readSeededLaunchDraftSeed: () => { text: string; createdAt: number | null } | null,
|
||||
agent: string | null = 'claude'
|
||||
): void => {
|
||||
agentRef.current = agent
|
||||
function Probe(): null {
|
||||
api = useMobileNativeChatMessageSend({
|
||||
client: { sendRequest: vi.fn() } as never,
|
||||
enabled: true,
|
||||
handleRef: { current: 'term' },
|
||||
deviceTokenRef: { current: 'device' },
|
||||
agentRef,
|
||||
commandSendRef,
|
||||
captureSendOrigin: () => ({ draftKey: 'k', pendingKey: 'p' }) as never,
|
||||
readSeededLaunchDraftSeed,
|
||||
clearDraftForSend: () => {},
|
||||
restoreRejectedDraft: () => {},
|
||||
acceptSend: () => {},
|
||||
holdUnconfirmedSend: () => {},
|
||||
onSendError: () => {}
|
||||
acceptSend,
|
||||
holdUnconfirmedSend,
|
||||
onSendError
|
||||
})
|
||||
return null
|
||||
}
|
||||
@@ -70,6 +85,12 @@ describe('useMobileNativeChatMessageSend', () => {
|
||||
sendWithOutcome.mockResolvedValue('accepted')
|
||||
clearInputWrite.mockReset()
|
||||
clearInputWrite.mockResolvedValue(true)
|
||||
acceptSend.mockReset()
|
||||
holdUnconfirmedSend.mockReset()
|
||||
onCommandSend.mockReset()
|
||||
commandSendRef.current = onCommandSend
|
||||
onSendError = vi.fn()
|
||||
resetMobileNativeChatTerminalWritesForTests()
|
||||
})
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
@@ -173,4 +194,138 @@ describe('useMobileNativeChatMessageSend', () => {
|
||||
})
|
||||
expect(sentArgs().resolvedLaunchDraft).toBeUndefined()
|
||||
})
|
||||
|
||||
it('creates an optimistic echo for an ordinary chat send', async () => {
|
||||
mount(() => null)
|
||||
await act(async () => {
|
||||
await api!.send('hello')
|
||||
})
|
||||
expect(acceptSend).toHaveBeenCalledTimes(1)
|
||||
expect(onCommandSend).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// The STA-3332 "Queued forever" regression: command sends dispatch into the
|
||||
// agent's TUI and never echo as user turns, so they must not create a pending
|
||||
// bubble that no transcript match can ever retire.
|
||||
it('never creates an optimistic echo for a catalog command send', async () => {
|
||||
mount(() => null)
|
||||
await act(async () => {
|
||||
await api!.send('/clear')
|
||||
})
|
||||
expect(acceptSend).not.toHaveBeenCalled()
|
||||
expect(onCommandSend).toHaveBeenCalledWith('/clear')
|
||||
})
|
||||
|
||||
it('never creates an optimistic echo for an unknown slash token', async () => {
|
||||
// `/model` is not in Claude's autocomplete catalog, but the session-option
|
||||
// recorder still recognizes it without claiming a generic command ran.
|
||||
mount(() => null)
|
||||
await act(async () => {
|
||||
await api!.send('/model sonnet')
|
||||
})
|
||||
expect(acceptSend).not.toHaveBeenCalled()
|
||||
expect(onCommandSend).toHaveBeenCalledWith('/model sonnet')
|
||||
})
|
||||
|
||||
it('classifies per agent: /model is a catalog command for Codex', async () => {
|
||||
mount(() => null, 'codex')
|
||||
await act(async () => {
|
||||
await api!.send('/model')
|
||||
})
|
||||
expect(acceptSend).not.toHaveBeenCalled()
|
||||
expect(onCommandSend).toHaveBeenCalledWith('/model')
|
||||
})
|
||||
|
||||
it('holds only chat sends for transcript confirmation on a lost ack', async () => {
|
||||
sendWithOutcome.mockResolvedValue('unknown')
|
||||
mount(() => null)
|
||||
await act(async () => {
|
||||
await api!.send('/clear')
|
||||
})
|
||||
expect(holdUnconfirmedSend).not.toHaveBeenCalled()
|
||||
await act(async () => {
|
||||
await api!.send('hello')
|
||||
})
|
||||
expect(holdUnconfirmedSend).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('dispatchCommand surfaces the outcome without echo or composer sync', async () => {
|
||||
mount(() => ({ text: DRAFT, createdAt: 1 }))
|
||||
let outcome: string | undefined
|
||||
await act(async () => {
|
||||
outcome = await api!.dispatchCommand('/model sonnet')
|
||||
})
|
||||
expect(outcome).toBe('accepted')
|
||||
expect(acceptSend).not.toHaveBeenCalled()
|
||||
expect(onCommandSend).not.toHaveBeenCalled()
|
||||
expect(sentArgs().resolvedLaunchDraft).toBeUndefined()
|
||||
})
|
||||
|
||||
it('binds classification to the agent that started the send', async () => {
|
||||
let resolveSend!: (outcome: MobileNativeChatSendOutcome) => void
|
||||
sendWithOutcome.mockReturnValue(
|
||||
new Promise<MobileNativeChatSendOutcome>((resolve) => {
|
||||
resolveSend = resolve
|
||||
})
|
||||
)
|
||||
mount(() => null, 'claude')
|
||||
let sending!: Promise<boolean>
|
||||
act(() => {
|
||||
sending = api!.send('$skill')
|
||||
})
|
||||
agentRef.current = 'codex'
|
||||
await act(async () => {
|
||||
resolveSend('accepted')
|
||||
await sending
|
||||
})
|
||||
expect(acceptSend).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('records a command against the tab that started the send', async () => {
|
||||
let resolveSend!: (outcome: MobileNativeChatSendOutcome) => void
|
||||
sendWithOutcome.mockReturnValue(
|
||||
new Promise<MobileNativeChatSendOutcome>((resolve) => {
|
||||
resolveSend = resolve
|
||||
})
|
||||
)
|
||||
const originalRecorder = vi.fn()
|
||||
const nextRecorder = vi.fn()
|
||||
commandSendRef.current = originalRecorder
|
||||
mount(() => null)
|
||||
commandSendRef.current = originalRecorder
|
||||
let sending!: Promise<boolean>
|
||||
act(() => {
|
||||
sending = api!.send('/clear')
|
||||
})
|
||||
commandSendRef.current = nextRecorder
|
||||
await act(async () => {
|
||||
resolveSend('accepted')
|
||||
await sending
|
||||
})
|
||||
expect(originalRecorder).toHaveBeenCalledWith('/clear')
|
||||
expect(nextRecorder).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a question answer while another composed write holds the terminal', async () => {
|
||||
mount(() => null)
|
||||
// An image paste sequence is mid-flight into the same PTY.
|
||||
expect(acquireMobileNativeChatTerminalWrite('term')).toBe(true)
|
||||
|
||||
let result: boolean | undefined
|
||||
await act(async () => {
|
||||
result = await api!.answerQuestion('1')
|
||||
})
|
||||
expect(result).toBe(false)
|
||||
expect(sendWithOutcome).not.toHaveBeenCalled()
|
||||
expect(onSendError).toHaveBeenCalledWith('Answer not sent')
|
||||
|
||||
releaseMobileNativeChatTerminalWrite('term')
|
||||
await act(async () => {
|
||||
result = await api!.answerQuestion('1')
|
||||
})
|
||||
expect(result).toBe(true)
|
||||
// The answer released its own hold on the way out.
|
||||
expect(acquireMobileNativeChatTerminalWrite('term')).toBe(true)
|
||||
releaseMobileNativeChatTerminalWrite('term')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,6 +7,11 @@ import {
|
||||
type MobileNativeChatSendOutcome
|
||||
} from './mobile-native-chat-send'
|
||||
import { healMobileNativeChatStaleInput } from './mobile-native-chat-stale-input'
|
||||
import { classifyMobileNativeChatSend } from './mobile-native-chat-send-classification'
|
||||
import {
|
||||
acquireMobileNativeChatTerminalWrite,
|
||||
releaseMobileNativeChatTerminalWrite
|
||||
} from './mobile-native-chat-terminal-write-lock'
|
||||
import type { MobileNativeChatSendOrigin } from './use-mobile-native-chat-drafts'
|
||||
import type { MobileNativeChatLaunchDraftSeed } from './use-mobile-native-chat-launch-draft-seed'
|
||||
import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear'
|
||||
@@ -25,6 +30,9 @@ export type MobileNativeChatMessageSend = {
|
||||
) => Promise<MobileNativeChatSendOutcome>
|
||||
/** Answer to an agent question — never touches the composer draft. */
|
||||
answerQuestion: (text: string) => Promise<boolean>
|
||||
/** Session-option command dispatch (e.g. `/model sonnet`) — never touches the
|
||||
* composer draft; callers need the outcome to track dispatched state. */
|
||||
dispatchCommand: (text: string) => Promise<MobileNativeChatSendOutcome>
|
||||
}
|
||||
|
||||
/** The native-chat send seam: one write path shared by composer sends, image
|
||||
@@ -34,6 +42,11 @@ export function useMobileNativeChatMessageSend(args: {
|
||||
enabled: boolean
|
||||
handleRef: MutableRefObject<string | null>
|
||||
deviceTokenRef: MutableRefObject<string | null>
|
||||
/** Active tab's agent — classification is per-agent (command catalogs differ). */
|
||||
agentRef: MutableRefObject<string | null>
|
||||
/** Captured when a control send starts so a later tab switch cannot record its
|
||||
* session-option effects against the newly active tab. */
|
||||
commandSendRef: MutableRefObject<(command: string) => void>
|
||||
captureSendOrigin: (text: string) => MobileNativeChatSendOrigin | null
|
||||
/** Launch-context text Orca parked on the agent's TUI input line, or null. Read
|
||||
* at send time so the pre-clear can be sized to every line it occupies. */
|
||||
@@ -53,6 +66,8 @@ export function useMobileNativeChatMessageSend(args: {
|
||||
enabled,
|
||||
handleRef,
|
||||
deviceTokenRef,
|
||||
agentRef,
|
||||
commandSendRef,
|
||||
captureSendOrigin,
|
||||
readSeededLaunchDraftSeed,
|
||||
clearDraftForSend,
|
||||
@@ -67,10 +82,13 @@ export function useMobileNativeChatMessageSend(args: {
|
||||
text: string,
|
||||
images: string[] | undefined,
|
||||
syncComposer: boolean,
|
||||
recordControlSend: boolean,
|
||||
sharedDeadline?: number
|
||||
): Promise<MobileNativeChatSendOutcome> => {
|
||||
const handle = handleRef.current
|
||||
const origin = captureSendOrigin(text)
|
||||
const agent = agentRef.current
|
||||
const recordCommand = commandSendRef.current
|
||||
// Why: the lease collapses one render after `connState`, so a question-card
|
||||
// answer (which reaches this send directly) would otherwise burn the whole
|
||||
// 15s heal+send budget waiting on a socket that is already gone.
|
||||
@@ -161,12 +179,19 @@ export function useMobileNativeChatMessageSend(args: {
|
||||
? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } }
|
||||
: {})
|
||||
})
|
||||
// Why (desktop parity): a slash/skill send dispatches into the agent's own
|
||||
// TUI, not the conversation — the transcript never echoes it as a user
|
||||
// turn, so an optimistic bubble would sit at "Queued" forever and the
|
||||
// unconfirmed hold could never observe a landing.
|
||||
const classification = classifyMobileNativeChatSend(agent, text)
|
||||
if (outcome === 'unknown') {
|
||||
// Why: an ack-lost send usually WAS delivered (issue seen on cellular
|
||||
// relay) — verify via the transcript echo instead of a false "not sent".
|
||||
holdUnconfirmedSend(origin, text, () =>
|
||||
onSendError('Delivery unconfirmed — check chat before retrying')
|
||||
)
|
||||
if (classification === 'chat') {
|
||||
// Why: an ack-lost send usually WAS delivered (issue seen on cellular
|
||||
// relay) — verify via the transcript echo instead of a false "not sent".
|
||||
holdUnconfirmedSend(origin, text, () =>
|
||||
onSendError('Delivery unconfirmed — check chat before retrying')
|
||||
)
|
||||
}
|
||||
return 'unknown'
|
||||
}
|
||||
if (outcome === 'rejected') {
|
||||
@@ -176,16 +201,24 @@ export function useMobileNativeChatMessageSend(args: {
|
||||
onSendError('Message not sent')
|
||||
return 'rejected'
|
||||
}
|
||||
// `images` are local preview URIs for the optimistic echo only — the actual
|
||||
// image bytes already rode along as a bracketed paste before this text send.
|
||||
acceptSend(origin, text, images)
|
||||
if (classification === 'chat') {
|
||||
// `images` are local preview URIs for the optimistic echo only — the actual
|
||||
// image bytes already rode along as a bracketed paste before this text send.
|
||||
acceptSend(origin, text, images)
|
||||
} else if (recordControlSend) {
|
||||
// The session-option catalog can recognize controls omitted from the
|
||||
// autocomplete catalog (for example Claude `/model` and `/fast`).
|
||||
recordCommand(text.trim())
|
||||
}
|
||||
return 'accepted'
|
||||
},
|
||||
[
|
||||
acceptSend,
|
||||
agentRef,
|
||||
captureSendOrigin,
|
||||
clearDraftForSend,
|
||||
client,
|
||||
commandSendRef,
|
||||
deviceTokenRef,
|
||||
enabled,
|
||||
handleRef,
|
||||
@@ -198,7 +231,7 @@ export function useMobileNativeChatMessageSend(args: {
|
||||
|
||||
const sendWithOutcome = useCallback(
|
||||
(text: string, images?: string[], deadline?: number) =>
|
||||
sendMessage(text, images, true, deadline),
|
||||
sendMessage(text, images, true, true, deadline),
|
||||
[sendMessage]
|
||||
)
|
||||
|
||||
@@ -210,12 +243,47 @@ export function useMobileNativeChatMessageSend(args: {
|
||||
[sendWithOutcome]
|
||||
)
|
||||
|
||||
// A question answer is not composer text, so it never syncs the draft.
|
||||
// A question answer is not composer text, so it never syncs the draft. It
|
||||
// reaches this send directly (not through the image hook's locked path), so
|
||||
// it takes the per-terminal write lock itself: an answer landing mid-flight
|
||||
// in an image paste sequence would interleave bytes into the PTY.
|
||||
const answerQuestion = useCallback(
|
||||
async (text: string): Promise<boolean> =>
|
||||
(await sendMessage(text, undefined, false)) !== 'rejected',
|
||||
[sendMessage]
|
||||
async (text: string): Promise<boolean> => {
|
||||
const terminal = handleRef.current
|
||||
if (terminal && !acquireMobileNativeChatTerminalWrite(terminal)) {
|
||||
onSendError('Answer not sent')
|
||||
return false
|
||||
}
|
||||
try {
|
||||
return (await sendMessage(text, undefined, false, true)) !== 'rejected'
|
||||
} finally {
|
||||
if (terminal) {
|
||||
releaseMobileNativeChatTerminalWrite(terminal)
|
||||
}
|
||||
}
|
||||
},
|
||||
[handleRef, onSendError, sendMessage]
|
||||
)
|
||||
|
||||
return { send, sendWithOutcome, answerQuestion }
|
||||
// A session-option apply writes to the same input line as a send, and the host
|
||||
// spaces a send's body and its Enter ~500ms apart — so without this lock an
|
||||
// apply lands between them and is submitted as part of the user's prompt.
|
||||
const dispatchCommand = useCallback(
|
||||
async (text: string): Promise<MobileNativeChatSendOutcome> => {
|
||||
const terminal = handleRef.current
|
||||
if (terminal && !acquireMobileNativeChatTerminalWrite(terminal)) {
|
||||
return 'rejected'
|
||||
}
|
||||
try {
|
||||
return await sendMessage(text, undefined, false, false)
|
||||
} finally {
|
||||
if (terminal) {
|
||||
releaseMobileNativeChatTerminalWrite(terminal)
|
||||
}
|
||||
}
|
||||
},
|
||||
[handleRef, sendMessage]
|
||||
)
|
||||
|
||||
return { send, sendWithOutcome, answerQuestion, dispatchCommand }
|
||||
}
|
||||
|
||||
@@ -2,26 +2,38 @@ import { createElement } from 'react'
|
||||
import TestRenderer from 'react-test-renderer'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AgentStatusEntry } from '../../../src/shared/agent-status-types'
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import { useMobileNativeChatPrompts } from './use-mobile-native-chat-prompts'
|
||||
|
||||
const APPROVAL = JSON.stringify({
|
||||
approval: { tool: 'Bash', summary: 'pnpm build > build.log 2>&1' }
|
||||
})
|
||||
|
||||
function permissionFor(status: Partial<AgentStatusEntry> | null): unknown {
|
||||
let captured: unknown
|
||||
const ASK = JSON.stringify({
|
||||
questions: [{ question: 'Which path?', options: ['fast', 'safe'] }]
|
||||
})
|
||||
|
||||
function promptsFor(
|
||||
status: Partial<AgentStatusEntry> | null,
|
||||
messages: NativeChatMessage[] = []
|
||||
): ReturnType<typeof useMobileNativeChatPrompts> {
|
||||
let captured: ReturnType<typeof useMobileNativeChatPrompts> | undefined
|
||||
function Probe(): null {
|
||||
captured = useMobileNativeChatPrompts({
|
||||
enabled: true,
|
||||
status: status as AgentStatusEntry | null,
|
||||
messages: []
|
||||
}).permission
|
||||
messages
|
||||
})
|
||||
return null
|
||||
}
|
||||
TestRenderer.act(() => {
|
||||
TestRenderer.create(createElement(Probe))
|
||||
})
|
||||
return captured
|
||||
return captured!
|
||||
}
|
||||
|
||||
function permissionFor(status: Partial<AgentStatusEntry> | null): unknown {
|
||||
return promptsFor(status).permission
|
||||
}
|
||||
|
||||
describe('useMobileNativeChatPrompts approval-envelope state gate', () => {
|
||||
@@ -60,3 +72,74 @@ describe('useMobileNativeChatPrompts approval-envelope state gate', () => {
|
||||
expect(permission?.options.map((o) => o.label)).toEqual(['Yes', 'No'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('useMobileNativeChatPrompts ask state gate', () => {
|
||||
const askMessages: NativeChatMessage[] = [
|
||||
{
|
||||
id: 'm1',
|
||||
role: 'assistant',
|
||||
blocks: [
|
||||
{
|
||||
type: 'tool-call',
|
||||
name: 'AskUserQuestion',
|
||||
input: { questions: [{ question: 'Which path?', options: ['fast', 'safe'] }] }
|
||||
}
|
||||
],
|
||||
timestamp: 0,
|
||||
source: 'transcript'
|
||||
}
|
||||
]
|
||||
|
||||
it('renders the ask card only while the agent is waiting or blocked', () => {
|
||||
expect(promptsFor({ state: 'waiting', interactivePrompt: ASK }).ask).toMatchObject({
|
||||
questions: [{ question: 'Which path?' }]
|
||||
})
|
||||
expect(promptsFor({ state: 'blocked', interactivePrompt: ASK }).ask).not.toBeNull()
|
||||
})
|
||||
|
||||
it('renders no ask card from a sticky prompt while the agent is working or done', () => {
|
||||
// The prompt payload outlives its answer — same paused gate as permission.
|
||||
const working = promptsFor({ state: 'working', interactivePrompt: ASK })
|
||||
expect(working.ask).toBeNull()
|
||||
expect(working.detectedAsk).not.toBeNull()
|
||||
|
||||
const done = promptsFor({ state: 'done', interactivePrompt: ASK })
|
||||
expect(done.ask).toBeNull()
|
||||
expect(done.detectedAsk).not.toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the transcript-derived pending ask outside the paused gate', () => {
|
||||
// A hook row idle past AGENT_STATUS_STALE_AFTER_MS projects to `done` with no
|
||||
// interactivePrompt, so gating this too would make a still-pending question
|
||||
// unanswerable from mobile. `extractPendingAsk` clears on the tool result.
|
||||
expect(promptsFor({ state: 'waiting' }, askMessages).ask).not.toBeNull()
|
||||
expect(promptsFor({ state: 'done' }, askMessages).ask).not.toBeNull()
|
||||
expect(promptsFor({ state: 'working' }, askMessages).ask).not.toBeNull()
|
||||
expect(promptsFor(null, askMessages).ask).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
|
||||
// both sources still describe the answered question. The paused gate only
|
||||
// holds because a status prompt suppresses the transcript fallback outright.
|
||||
const working = promptsFor({ state: 'working', interactivePrompt: ASK }, askMessages)
|
||||
expect(working.ask).toBeNull()
|
||||
expect(working.detectedAsk).not.toBeNull()
|
||||
})
|
||||
|
||||
it('still refuses an unpaused sticky status prompt that the transcript does not back', () => {
|
||||
const answered: NativeChatMessage[] = [
|
||||
...askMessages,
|
||||
{
|
||||
id: 'm2',
|
||||
role: 'tool',
|
||||
blocks: [{ type: 'tool-result', output: 'fast' }],
|
||||
timestamp: 1,
|
||||
source: 'transcript'
|
||||
}
|
||||
]
|
||||
expect(promptsFor({ state: 'done', interactivePrompt: ASK }, answered).ask).toBeNull()
|
||||
expect(promptsFor({ state: 'done' }, answered).ask).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,6 +8,7 @@ import { parseAgentQuestion } from './mobile-native-chat-question'
|
||||
export type MobileNativeChatPrompts = {
|
||||
permission: ReturnType<typeof detectAgentPermission>
|
||||
question: ReturnType<typeof parseAgentQuestion>
|
||||
detectedAsk: ReturnType<typeof parseAskFromStatus>
|
||||
ask: ReturnType<typeof parseAskFromStatus>
|
||||
}
|
||||
|
||||
@@ -42,9 +43,17 @@ export function useMobileNativeChatPrompts(args: {
|
||||
[askFromStatus, messages]
|
||||
)
|
||||
|
||||
const detectedAsk = askFromStatus ?? askFromMessages
|
||||
|
||||
return {
|
||||
permission,
|
||||
question,
|
||||
ask: enabled ? (askFromStatus ?? askFromMessages) : null
|
||||
detectedAsk: enabled ? detectedAsk : null,
|
||||
// Only the status payload needs the paused gate the approval envelope uses:
|
||||
// it outlives its answer, so a working/done agent must not surface one. The
|
||||
// transcript fallback clears itself when the tool result lands, and it is the
|
||||
// only source left once the hook row goes stale and projects to `done` with
|
||||
// no interactivePrompt — gating it too strands a genuinely pending question.
|
||||
ask: enabled ? ((blocked ? askFromStatus : null) ?? askFromMessages) : null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send'
|
||||
import {
|
||||
clearMobileSessionOptionRecordsForTests,
|
||||
useMobileNativeChatSessionOptions,
|
||||
type MobileNativeChatSessionOptionsController
|
||||
} from './use-mobile-native-chat-session-options'
|
||||
|
||||
type HookArgs = Parameters<typeof useMobileNativeChatSessionOptions>[0]
|
||||
|
||||
describe('useMobileNativeChatSessionOptions', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
let api: MobileNativeChatSessionOptionsController | null = null
|
||||
let hookArgs: HookArgs
|
||||
const dispatchCommand = vi.fn<(command: string) => Promise<MobileNativeChatSendOutcome>>()
|
||||
const onAgentPicker = vi.fn()
|
||||
|
||||
function Probe(): null {
|
||||
api = useMobileNativeChatSessionOptions(hookArgs)
|
||||
return null
|
||||
}
|
||||
|
||||
const mount = (overrides: Partial<HookArgs> = {}): void => {
|
||||
hookArgs = {
|
||||
agent: 'claude',
|
||||
scopeKey: 'host\0worktree\0tab',
|
||||
reportedModel: null,
|
||||
dispatchCommand,
|
||||
onAgentPicker,
|
||||
...overrides
|
||||
}
|
||||
act(() => {
|
||||
renderer = create(createElement(Probe))
|
||||
})
|
||||
}
|
||||
|
||||
const update = (overrides: Partial<HookArgs>): void => {
|
||||
hookArgs = { ...hookArgs, ...overrides }
|
||||
act(() => {
|
||||
renderer!.update(createElement(Probe))
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
clearMobileSessionOptionRecordsForTests()
|
||||
dispatchCommand.mockReset()
|
||||
dispatchCommand.mockResolvedValue('accepted')
|
||||
onAgentPicker.mockReset()
|
||||
})
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
renderer?.unmount()
|
||||
})
|
||||
renderer = null
|
||||
api = null
|
||||
})
|
||||
|
||||
it('serves the shared catalog snapshot for the agent', () => {
|
||||
mount()
|
||||
expect(api!.snapshot[0]).toMatchObject({ id: 'model', category: 'model' })
|
||||
expect(api!.snapshot[0]!.kind).toMatchObject({ type: 'select' })
|
||||
})
|
||||
|
||||
it('returns an empty snapshot for agents without a catalog', () => {
|
||||
mount({ agent: 'amp' })
|
||||
expect(api!.snapshot).toEqual([])
|
||||
})
|
||||
|
||||
it('does not expose catalog-backed agents outside the Claude and Codex scope', () => {
|
||||
mount({ agent: 'gemini' })
|
||||
expect(api!.snapshot).toEqual([])
|
||||
})
|
||||
|
||||
it('applies a model pick through the catalog modelApply command', async () => {
|
||||
mount()
|
||||
let applied: boolean | undefined
|
||||
await act(async () => {
|
||||
applied = await api!.setOption('model', 'opus')
|
||||
})
|
||||
expect(applied).toBe(true)
|
||||
expect(dispatchCommand).toHaveBeenCalledWith('/model opus')
|
||||
const model = api!.snapshot[0]!
|
||||
expect(model).toMatchObject({ valueSource: 'dispatched' })
|
||||
expect(model.kind).toMatchObject({ currentValue: 'opus' })
|
||||
})
|
||||
|
||||
it('keeps tracked truth when the dispatch is rejected', async () => {
|
||||
dispatchCommand.mockResolvedValue('rejected')
|
||||
mount()
|
||||
let applied: boolean | undefined
|
||||
await act(async () => {
|
||||
applied = await api!.setOption('model', 'opus')
|
||||
})
|
||||
expect(applied).toBe(false)
|
||||
expect(api!.snapshot[0]).toMatchObject({ valueSource: 'unknown' })
|
||||
})
|
||||
|
||||
it('routes Codex model changes through the agent picker action', async () => {
|
||||
mount({ agent: 'codex' })
|
||||
expect(api!.snapshot[0]).toMatchObject({ action: { type: 'agent-picker' } })
|
||||
await act(async () => {
|
||||
await api!.invokeAction('model')
|
||||
})
|
||||
expect(dispatchCommand).toHaveBeenCalledWith('/model')
|
||||
expect(onAgentPicker).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('seeds the current model from a hook-reported provider model', () => {
|
||||
mount({ reportedModel: 'claude-sonnet-5' })
|
||||
const model = api!.snapshot[0]!
|
||||
expect(model).toMatchObject({ valueSource: 'reported' })
|
||||
expect(model.kind).toMatchObject({ currentValue: 'sonnet' })
|
||||
})
|
||||
|
||||
it('tracks typed commands via recordCommand', () => {
|
||||
mount()
|
||||
act(() => {
|
||||
api!.recordCommand('/model haiku')
|
||||
})
|
||||
expect(api!.snapshot[0]!.kind).toMatchObject({ currentValue: 'haiku' })
|
||||
expect(api!.snapshot[0]).toMatchObject({ valueSource: 'dispatched' })
|
||||
})
|
||||
|
||||
it('applies an option under the tracked model and scopes it to that model', async () => {
|
||||
mount({ reportedModel: 'claude-sonnet-5' })
|
||||
await act(async () => {
|
||||
await api!.setOption('effort', 'low')
|
||||
})
|
||||
expect(dispatchCommand).toHaveBeenCalledWith('/effort low')
|
||||
const effort = api!.snapshot.find((descriptor) => descriptor.id === 'effort')
|
||||
expect(effort).toMatchObject({ valueSource: 'dispatched' })
|
||||
expect(effort!.kind).toMatchObject({ currentValue: 'low' })
|
||||
})
|
||||
|
||||
it('does not file an option under a model that changed mid-dispatch', async () => {
|
||||
const resolvers: ((outcome: MobileNativeChatSendOutcome) => void)[] = []
|
||||
dispatchCommand.mockImplementation(
|
||||
() => new Promise<MobileNativeChatSendOutcome>((resolve) => resolvers.push(resolve))
|
||||
)
|
||||
mount({ reportedModel: 'claude-sonnet-5' })
|
||||
let applied!: Promise<boolean>
|
||||
await act(async () => {
|
||||
applied = api!.setOption('effort', 'low')
|
||||
await Promise.resolve()
|
||||
})
|
||||
// A report lands while `/effort low` is still in flight and moves the model.
|
||||
update({ reportedModel: 'claude-opus-5' })
|
||||
await act(async () => {
|
||||
resolvers[0]!('accepted')
|
||||
await applied
|
||||
})
|
||||
// The effort must not be recorded against Opus — it was sent for Sonnet.
|
||||
const effort = api!.snapshot.find((descriptor) => descriptor.id === 'effort')
|
||||
expect(effort?.kind).not.toMatchObject({ currentValue: 'low' })
|
||||
})
|
||||
|
||||
it('does not revive a stale session-start report over a newer local pick', async () => {
|
||||
mount({ reportedModel: 'claude-sonnet-5' })
|
||||
await act(async () => {
|
||||
await api!.setOption('model', 'opus')
|
||||
})
|
||||
expect(api!.snapshot[0]!.kind).toMatchObject({ currentValue: 'opus' })
|
||||
// Leaving the tab and returning re-delivers the SAME session-start report,
|
||||
// which cannot have observed the `/model opus` sent after it.
|
||||
update({ scopeKey: 'host\0worktree\0other' })
|
||||
update({ scopeKey: 'host\0worktree\0tab' })
|
||||
expect(api!.snapshot[0]).toMatchObject({ valueSource: 'dispatched' })
|
||||
expect(api!.snapshot[0]!.kind).toMatchObject({ currentValue: 'opus' })
|
||||
})
|
||||
|
||||
it('still lets a genuinely new report supersede a local pick', async () => {
|
||||
mount({ reportedModel: 'claude-sonnet-5' })
|
||||
await act(async () => {
|
||||
await api!.setOption('model', 'opus')
|
||||
})
|
||||
update({ reportedModel: 'claude-haiku-4-5' })
|
||||
expect(api!.snapshot[0]).toMatchObject({ valueSource: 'reported' })
|
||||
expect(api!.snapshot[0]!.kind).toMatchObject({ currentValue: 'haiku' })
|
||||
})
|
||||
|
||||
it('keeps the live tab’s tracked model when other tabs overflow the record cap', async () => {
|
||||
mount()
|
||||
await act(async () => {
|
||||
await api!.setOption('model', 'opus')
|
||||
})
|
||||
// Far more scopes than the cap, revisiting the live tab in between the way a
|
||||
// chat↔terminal flip does — insertion-order eviction would shed it.
|
||||
for (let index = 0; index < 40; index += 1) {
|
||||
update({ scopeKey: `host\0worktree\0overflow-${index}` })
|
||||
update({ scopeKey: 'host\0worktree\0tab' })
|
||||
}
|
||||
expect(api!.snapshot[0]).toMatchObject({ valueSource: 'dispatched' })
|
||||
expect(api!.snapshot[0]!.kind).toMatchObject({ currentValue: 'opus' })
|
||||
})
|
||||
|
||||
it('keeps the latest queued operation pending until it settles', async () => {
|
||||
const resolvers: ((outcome: MobileNativeChatSendOutcome) => void)[] = []
|
||||
dispatchCommand.mockImplementation(
|
||||
() =>
|
||||
new Promise<MobileNativeChatSendOutcome>((resolve) => {
|
||||
resolvers.push(resolve)
|
||||
})
|
||||
)
|
||||
mount({ reportedModel: 'claude-sonnet-5' })
|
||||
let first!: Promise<boolean>
|
||||
let second!: Promise<boolean>
|
||||
await act(async () => {
|
||||
first = api!.setOption('effort', 'low')
|
||||
second = api!.setOption('model', 'opus')
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(api!.pendingId).toBe('model')
|
||||
await act(async () => {
|
||||
resolvers[0]!('accepted')
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(dispatchCommand).toHaveBeenCalledTimes(2)
|
||||
expect(api!.pendingId).toBe('model')
|
||||
await act(async () => {
|
||||
resolvers[1]!('accepted')
|
||||
await Promise.all([first, second])
|
||||
})
|
||||
expect(api!.pendingId).toBeNull()
|
||||
})
|
||||
|
||||
it('does not dispatch a queued option into a newly active tab', async () => {
|
||||
let resolveFirst!: (outcome: MobileNativeChatSendOutcome) => void
|
||||
dispatchCommand.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<MobileNativeChatSendOutcome>((resolve) => {
|
||||
resolveFirst = resolve
|
||||
})
|
||||
)
|
||||
mount({ reportedModel: 'claude-sonnet-5' })
|
||||
let first!: Promise<boolean>
|
||||
let queued!: Promise<boolean>
|
||||
await act(async () => {
|
||||
first = api!.setOption('effort', 'low')
|
||||
queued = api!.setOption('model', 'opus')
|
||||
await Promise.resolve()
|
||||
})
|
||||
update({ scopeKey: 'host\0worktree\0other-tab' })
|
||||
await act(async () => {
|
||||
resolveFirst('accepted')
|
||||
await first
|
||||
})
|
||||
await expect(queued).resolves.toBe(false)
|
||||
expect(dispatchCommand).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not dispatch queued work after unmount', async () => {
|
||||
let resolveFirst!: (outcome: MobileNativeChatSendOutcome) => void
|
||||
dispatchCommand.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<MobileNativeChatSendOutcome>((resolve) => {
|
||||
resolveFirst = resolve
|
||||
})
|
||||
)
|
||||
mount({ reportedModel: 'claude-sonnet-5' })
|
||||
let first!: Promise<boolean>
|
||||
let queued!: Promise<boolean>
|
||||
await act(async () => {
|
||||
first = api!.setOption('effort', 'low')
|
||||
queued = api!.setOption('model', 'opus')
|
||||
await Promise.resolve()
|
||||
})
|
||||
act(() => {
|
||||
renderer!.unmount()
|
||||
})
|
||||
renderer = null
|
||||
resolveFirst('accepted')
|
||||
await expect(first).resolves.toBe(true)
|
||||
await expect(queued).resolves.toBe(false)
|
||||
expect(dispatchCommand).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,342 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
getAgentSessionOptionCatalog,
|
||||
type AgentSessionOptionCatalog,
|
||||
type CatalogModel
|
||||
} from '../../../src/shared/agent-session-option-catalog'
|
||||
import type {
|
||||
SessionOptionDescriptor,
|
||||
SessionOptionValue
|
||||
} from '../../../src/shared/native-chat-session-options'
|
||||
import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send'
|
||||
import {
|
||||
buildNativeChatSessionOptionCommand,
|
||||
recordNativeChatSessionOptionCommand
|
||||
} from '../../../src/shared/native-chat-session-option-commands'
|
||||
import {
|
||||
buildNativeChatSessionOptionSnapshot,
|
||||
withTrackedNativeChatModel
|
||||
} from '../../../src/shared/native-chat-session-option-snapshot'
|
||||
import {
|
||||
applyNativeChatReportedSessionOptions,
|
||||
clearNativeChatSessionModel,
|
||||
createNativeChatSessionOptionRecord,
|
||||
getTrackedSessionOption,
|
||||
isFlipOnlyMidSession,
|
||||
matchNativeChatCatalogModelId,
|
||||
setTrackedSessionOption,
|
||||
type NativeChatSessionOptionRecord
|
||||
} from '../../../src/shared/native-chat-session-option-state'
|
||||
|
||||
export type MobileNativeChatSessionOptionsController = {
|
||||
/** Model descriptor first, then the current model's options; empty when the
|
||||
* agent has no catalog. */
|
||||
snapshot: SessionOptionDescriptor[]
|
||||
/** Descriptor id with a dispatch in flight; the UI disables rows meanwhile. */
|
||||
pendingId: string | null
|
||||
setOption: (id: string, value: SessionOptionValue) => Promise<boolean>
|
||||
invokeAction: (id: string) => Promise<boolean>
|
||||
/** Track a slash command the user typed themselves (e.g. `/model sonnet`). */
|
||||
recordCommand: (command: string) => void
|
||||
}
|
||||
|
||||
type PendingOperation = { id: string; token: number }
|
||||
|
||||
// Why: per-tab records survive chat↔terminal flips and remounts, like desktop's
|
||||
// scope cache. Bounded so long sessions across many tabs can't grow unbounded.
|
||||
const MOBILE_SESSION_OPTION_RECORD_CAP = 32
|
||||
const recordsByScope = new Map<string, NativeChatSessionOptionRecord>()
|
||||
// The catalog model id last taken from a hook report, per scope. Mobile cannot
|
||||
// read the agent's screen, so a repeat of the same report is not new evidence.
|
||||
const appliedReportByScope = new Map<string, string>()
|
||||
|
||||
function getScopedRecord(scopeKey: string, agent: string): NativeChatSessionOptionRecord {
|
||||
const existing = recordsByScope.get(scopeKey)
|
||||
const record =
|
||||
existing && existing.agent === agent ? existing : createNativeChatSessionOptionRecord(agent)
|
||||
if (record !== existing) {
|
||||
appliedReportByScope.delete(scopeKey)
|
||||
}
|
||||
// Why: delete-then-set on every read makes the touched scope most-recent, so
|
||||
// eviction only sheds the oldest UNTOUCHED tab. Insertion order alone would let
|
||||
// a long-lived active tab be the oldest key and lose its tracked model.
|
||||
recordsByScope.delete(scopeKey)
|
||||
recordsByScope.set(scopeKey, record)
|
||||
while (recordsByScope.size > MOBILE_SESSION_OPTION_RECORD_CAP) {
|
||||
const oldest = recordsByScope.keys().next().value
|
||||
if (oldest === undefined) {
|
||||
break
|
||||
}
|
||||
recordsByScope.delete(oldest)
|
||||
appliedReportByScope.delete(oldest)
|
||||
}
|
||||
return record
|
||||
}
|
||||
|
||||
export function clearMobileSessionOptionRecordsForTests(): void {
|
||||
recordsByScope.clear()
|
||||
appliedReportByScope.clear()
|
||||
}
|
||||
|
||||
const EMPTY_SNAPSHOT: SessionOptionDescriptor[] = []
|
||||
|
||||
/** The model list every consumer must see: the catalog's, plus the tracked model
|
||||
* when the catalog no longer lists it. Desktop reconciles identically. */
|
||||
function activeModels(
|
||||
catalog: AgentSessionOptionCatalog,
|
||||
record: NativeChatSessionOptionRecord
|
||||
): CatalogModel[] {
|
||||
return withTrackedNativeChatModel(catalog, catalog.models, record)
|
||||
}
|
||||
|
||||
export function useMobileNativeChatSessionOptions(args: {
|
||||
agent: string | null
|
||||
/** Stable per-tab scope (host + worktree + tab), or null when no tab is active. */
|
||||
scopeKey: string | null
|
||||
/** Provider model from live agent status, when the hook reported one. */
|
||||
reportedModel: string | null
|
||||
dispatchCommand: (command: string) => Promise<MobileNativeChatSendOutcome>
|
||||
/** A model change that must happen in the agent's own TUI picker was
|
||||
* dispatched — bring the terminal view forward. */
|
||||
onAgentPicker?: () => void
|
||||
}): MobileNativeChatSessionOptionsController {
|
||||
const { agent, scopeKey, reportedModel, dispatchCommand, onAgentPicker } = args
|
||||
const catalog = useMemo(
|
||||
() => (agent === 'claude' || agent === 'codex' ? getAgentSessionOptionCatalog(agent) : null),
|
||||
[agent]
|
||||
)
|
||||
const identity = agent && scopeKey ? `${scopeKey}\0${agent}` : null
|
||||
const [version, setVersion] = useState(0)
|
||||
const [pendingByIdentity, setPendingByIdentity] = useState<
|
||||
Record<string, PendingOperation | undefined>
|
||||
>({})
|
||||
const pendingId = identity ? (pendingByIdentity[identity]?.id ?? null) : null
|
||||
const bump = useCallback(() => setVersion((current) => current + 1), [])
|
||||
const activeIdentityRef = useRef(identity)
|
||||
const applyQueuesRef = useRef(new Map<string, Promise<void>>())
|
||||
const operationTokenRef = useRef(0)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
activeIdentityRef.current = identity
|
||||
return () => {
|
||||
activeIdentityRef.current = null
|
||||
}
|
||||
}, [identity])
|
||||
|
||||
// Seed the current model from live agent status; hook reports are authority
|
||||
// over locally dispatched guesses (desktop 'reported' source parity).
|
||||
useEffect(() => {
|
||||
if (!catalog || !scopeKey || !agent || !reportedModel) {
|
||||
return
|
||||
}
|
||||
const matched = matchNativeChatCatalogModelId(catalog, reportedModel)
|
||||
if (!matched) {
|
||||
return
|
||||
}
|
||||
// Why: the same report is re-delivered whenever the tab is re-entered or the
|
||||
// status stream reconnects, and a session-start report cannot have observed a
|
||||
// `/model` sent after it. Re-applying it would revert the user's pick. Only a
|
||||
// report that CHANGES is evidence; the value itself still wins when it does.
|
||||
if (appliedReportByScope.get(scopeKey) === matched) {
|
||||
return
|
||||
}
|
||||
appliedReportByScope.set(scopeKey, matched)
|
||||
const record = getScopedRecord(scopeKey, agent)
|
||||
if (applyNativeChatReportedSessionOptions(record, { model: matched })) {
|
||||
bump()
|
||||
}
|
||||
}, [agent, bump, catalog, reportedModel, scopeKey])
|
||||
|
||||
const snapshot = useMemo(() => {
|
||||
if (!catalog || !scopeKey || !agent) {
|
||||
return EMPTY_SNAPSHOT
|
||||
}
|
||||
// Why: `version` invalidates this memo after in-place record mutations.
|
||||
void version
|
||||
const record = getScopedRecord(scopeKey, agent)
|
||||
return buildNativeChatSessionOptionSnapshot({
|
||||
catalog,
|
||||
// The snapshot no longer self-heals an unlisted tracked model; every caller
|
||||
// reconciles it in, so a value the seed dropped keeps its row and options.
|
||||
models: activeModels(catalog, record),
|
||||
record,
|
||||
mode: 'live',
|
||||
modelLabel: 'Model'
|
||||
})
|
||||
}, [agent, catalog, scopeKey, version])
|
||||
|
||||
const runSerialized = useCallback(
|
||||
(operationIdentity: string, id: string, run: () => Promise<boolean>): Promise<boolean> => {
|
||||
const previous = applyQueuesRef.current.get(operationIdentity) ?? Promise.resolve()
|
||||
const runIfCurrent = (): Promise<boolean> =>
|
||||
activeIdentityRef.current === operationIdentity ? run() : Promise.resolve(false)
|
||||
const chained = previous.then(runIfCurrent, runIfCurrent)
|
||||
const tail = chained.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
)
|
||||
applyQueuesRef.current.set(operationIdentity, tail)
|
||||
operationTokenRef.current += 1
|
||||
const token = operationTokenRef.current
|
||||
setPendingByIdentity((current) => ({
|
||||
...current,
|
||||
[operationIdentity]: { id, token }
|
||||
}))
|
||||
void tail.then(() => {
|
||||
if (applyQueuesRef.current.get(operationIdentity) === tail) {
|
||||
applyQueuesRef.current.delete(operationIdentity)
|
||||
}
|
||||
setPendingByIdentity((current) => {
|
||||
if (current[operationIdentity]?.token !== token) {
|
||||
return current
|
||||
}
|
||||
const next = { ...current }
|
||||
delete next[operationIdentity]
|
||||
return next
|
||||
})
|
||||
})
|
||||
return chained
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const setOption = useCallback(
|
||||
(id: string, value: SessionOptionValue): Promise<boolean> => {
|
||||
if (!catalog || !scopeKey || !agent || !identity) {
|
||||
return Promise.resolve(false)
|
||||
}
|
||||
return runSerialized(identity, id, async () => {
|
||||
const record = getScopedRecord(scopeKey, agent)
|
||||
const previousModelId = typeof record.model?.value === 'string' ? record.model.value : null
|
||||
const apply =
|
||||
id === 'model'
|
||||
? catalog.modelApply
|
||||
: activeModels(catalog, record)
|
||||
.find((model) => model.id === previousModelId)
|
||||
?.options.find((option) => option.id === id)?.apply
|
||||
if (!apply || apply.midSession?.kind === 'agent-picker') {
|
||||
return false
|
||||
}
|
||||
const flipOnly = isFlipOnlyMidSession(apply.midSession)
|
||||
const trackedToggle = flipOnly
|
||||
? getTrackedSessionOption(record, previousModelId, id)
|
||||
: undefined
|
||||
if (flipOnly && !trackedToggle) {
|
||||
// Why: a flip from an unknown baseline cannot honor an absolute target.
|
||||
return false
|
||||
}
|
||||
// Why: same absolute target must never re-dispatch a flip (would invert the agent).
|
||||
if (flipOnly && trackedToggle?.value === value) {
|
||||
return true
|
||||
}
|
||||
const command = buildNativeChatSessionOptionCommand({
|
||||
optionId: id,
|
||||
value,
|
||||
apply,
|
||||
modelId: previousModelId,
|
||||
catalog,
|
||||
models: activeModels(catalog, record),
|
||||
record
|
||||
})
|
||||
if (!command) {
|
||||
return false
|
||||
}
|
||||
// Baseline for detecting a hook report or typed command that lands while
|
||||
// the dispatch is in flight — the record is shared mutable state and the
|
||||
// report effect is not on this queue.
|
||||
const trackedBeforeDispatch =
|
||||
id === 'model' ? undefined : getTrackedSessionOption(record, previousModelId, id)
|
||||
const outcome = await dispatchCommand(command)
|
||||
if (outcome === 'rejected') {
|
||||
return false
|
||||
}
|
||||
if (id !== 'model') {
|
||||
// Why (desktop parity): `setTrackedSessionOption` resolves the owning
|
||||
// model when it commits, not when the command was built. If the model
|
||||
// moved during the dispatch, committing now would file this value under
|
||||
// the NEW model — claiming an effort the agent was never asked for.
|
||||
const modelStill = typeof record.model?.value === 'string' ? record.model.value : null
|
||||
if (
|
||||
modelStill !== previousModelId ||
|
||||
getTrackedSessionOption(record, previousModelId, id) !== trackedBeforeDispatch
|
||||
) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if (id === 'model') {
|
||||
if (typeof value === 'string' && previousModelId !== value) {
|
||||
// Why: switching models can reset effort/toggles for the destination model.
|
||||
delete record.valuesByModel[value]
|
||||
}
|
||||
}
|
||||
// Why: flip-only never heals via agent report — track as applied best-known.
|
||||
setTrackedSessionOption(record, id, value, flipOnly ? 'applied' : 'dispatched')
|
||||
bump()
|
||||
return true
|
||||
})
|
||||
},
|
||||
[agent, bump, catalog, dispatchCommand, identity, runSerialized, scopeKey]
|
||||
)
|
||||
|
||||
const invokeAction = useCallback(
|
||||
(id: string): Promise<boolean> => {
|
||||
if (!catalog || !scopeKey || !agent || !identity) {
|
||||
return Promise.resolve(false)
|
||||
}
|
||||
return runSerialized(identity, id, async () => {
|
||||
const record = getScopedRecord(scopeKey, agent)
|
||||
const modelId = typeof record.model?.value === 'string' ? record.model.value : null
|
||||
const apply =
|
||||
id === 'model'
|
||||
? catalog.modelApply
|
||||
: activeModels(catalog, record)
|
||||
.find((model) => model.id === modelId)
|
||||
?.options.find((option) => option.id === id)?.apply
|
||||
const midSession = apply?.midSession
|
||||
if (midSession?.kind === 'agent-picker') {
|
||||
const outcome = await dispatchCommand(midSession.command)
|
||||
if (outcome === 'rejected') {
|
||||
return false
|
||||
}
|
||||
clearNativeChatSessionModel(record)
|
||||
bump()
|
||||
onAgentPicker?.()
|
||||
return true
|
||||
}
|
||||
if (isFlipOnlyMidSession(midSession) && !getTrackedSessionOption(record, modelId, id)) {
|
||||
// Why: an unknown baseline remains unknown after one inversion.
|
||||
return (await dispatchCommand(midSession.command)) !== 'rejected'
|
||||
}
|
||||
return false
|
||||
})
|
||||
},
|
||||
[agent, bump, catalog, dispatchCommand, identity, onAgentPicker, runSerialized, scopeKey]
|
||||
)
|
||||
|
||||
const recordCommand = useCallback(
|
||||
(command: string): void => {
|
||||
if (!catalog || !scopeKey || !agent) {
|
||||
return
|
||||
}
|
||||
const record = getScopedRecord(scopeKey, agent)
|
||||
const result = recordNativeChatSessionOptionCommand({
|
||||
catalog,
|
||||
models: activeModels(catalog, record),
|
||||
record,
|
||||
command
|
||||
})
|
||||
if (result.changed) {
|
||||
bump()
|
||||
}
|
||||
if (result.opensAgentPicker) {
|
||||
onAgentPicker?.()
|
||||
}
|
||||
},
|
||||
[agent, bump, catalog, onAgentPicker, scopeKey]
|
||||
)
|
||||
|
||||
return useMemo(
|
||||
() => ({ snapshot, pendingId, setOption, invokeAction, recordCommand }),
|
||||
[snapshot, pendingId, setOption, invokeAction, recordCommand]
|
||||
)
|
||||
}
|
||||
@@ -174,6 +174,221 @@ describe('useMobileNativeChatSession', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps paged-in history across an auto-reconnect replay snapshot', async () => {
|
||||
// The transport replays the subscription with its original params after an
|
||||
// in-place reconnect, so the replayed snapshot is the newest initial window
|
||||
// again. It must merge into the grown history, not truncate it back to 40.
|
||||
const sendRequest = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
result: {
|
||||
messages: Array.from({ length: 60 }, (_unused, index) => message(`paged-${index}`)),
|
||||
hasMore: false,
|
||||
beforeOffset: 40
|
||||
}
|
||||
})
|
||||
const window = Array.from({ length: 40 }, (_unused, index) => message(`win-${index}`))
|
||||
const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => {
|
||||
emit = onData
|
||||
onData({ type: 'snapshot', messages: window, hasMore: true, beforeOffset: 100 })
|
||||
return () => {}
|
||||
})
|
||||
await mount({ sendRequest, subscribe } as unknown as RpcClient)
|
||||
await act(async () => {
|
||||
state?.loadEarlier()
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(state?.messages).toHaveLength(100)
|
||||
|
||||
// Reconnect replay: the same newest-40 window. History survives untouched.
|
||||
await act(async () =>
|
||||
emit({ type: 'snapshot', messages: window, hasMore: true, beforeOffset: 100 })
|
||||
)
|
||||
expect(state?.messages).toHaveLength(100)
|
||||
expect(state?.messages[0]?.id).toBe('paged-0')
|
||||
|
||||
// A replay carrying one message that arrived while away merges it in; the
|
||||
// grown window stays bounded, so only the single oldest row trims.
|
||||
await act(async () =>
|
||||
emit({
|
||||
type: 'snapshot',
|
||||
messages: [...window, message('live-1')],
|
||||
hasMore: true,
|
||||
beforeOffset: 100
|
||||
})
|
||||
)
|
||||
expect(state?.messages).toHaveLength(100)
|
||||
expect(state?.messages[0]?.id).toBe('paged-1')
|
||||
expect(state?.messages.at(-1)?.id).toBe('live-1')
|
||||
})
|
||||
|
||||
it('enables paging when a replay trims a window that previously had no earlier rows', async () => {
|
||||
// Never settles: this asserts the request the replay's cursor produces.
|
||||
const sendRequest = vi.fn(() => new Promise<never>(() => {}))
|
||||
const window = Array.from({ length: 40 }, (_unused, index) => message(`win-${index}`))
|
||||
const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => {
|
||||
emit = onData
|
||||
onData({ type: 'snapshot', messages: window, hasMore: false, beforeOffset: 0 })
|
||||
return () => {}
|
||||
})
|
||||
await mount({ sendRequest, subscribe } as unknown as RpcClient)
|
||||
|
||||
await act(async () =>
|
||||
emit({
|
||||
type: 'snapshot',
|
||||
messages: [...window.slice(1), message('live-1')],
|
||||
hasMore: true,
|
||||
beforeOffset: 10
|
||||
})
|
||||
)
|
||||
|
||||
expect(state?.messages[0]?.id).toBe('win-1')
|
||||
expect(state?.hasMore).toBe(true)
|
||||
act(() => state?.loadEarlier())
|
||||
expect(sendRequest).toHaveBeenCalledWith('nativeChat.readSession', {
|
||||
agent: 'claude',
|
||||
sessionId: 'session',
|
||||
limit: 100
|
||||
})
|
||||
})
|
||||
|
||||
it('drops paged rows that an authoritative replay says were removed', async () => {
|
||||
const sendRequest = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
result: {
|
||||
messages: Array.from({ length: 60 }, (_unused, index) => message(`paged-${index}`)),
|
||||
hasMore: false,
|
||||
beforeOffset: 40
|
||||
}
|
||||
})
|
||||
const window = Array.from({ length: 40 }, (_unused, index) => message(`win-${index}`))
|
||||
const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => {
|
||||
emit = onData
|
||||
onData({ type: 'snapshot', messages: window, hasMore: true, beforeOffset: 100 })
|
||||
return () => {}
|
||||
})
|
||||
await mount({ sendRequest, subscribe } as unknown as RpcClient)
|
||||
await act(async () => {
|
||||
state?.loadEarlier()
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(state?.messages).toHaveLength(100)
|
||||
|
||||
await act(async () =>
|
||||
emit({ type: 'snapshot', messages: window, hasMore: false, beforeOffset: 0 })
|
||||
)
|
||||
|
||||
expect(state?.messages).toEqual(window)
|
||||
expect(state?.hasMore).toBe(false)
|
||||
})
|
||||
|
||||
it('clears a stale cursor when a replacement omits paging metadata', async () => {
|
||||
// Never settles: this asserts the request the cleared cursor produces.
|
||||
const sendRequest = vi.fn(() => new Promise<never>(() => {}))
|
||||
const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => {
|
||||
emit = onData
|
||||
onData({
|
||||
type: 'snapshot',
|
||||
messages: Array.from({ length: 40 }, (_unused, index) => message(`win-${index}`)),
|
||||
hasMore: true,
|
||||
beforeOffset: 100
|
||||
})
|
||||
return () => {}
|
||||
})
|
||||
await mount({ sendRequest, subscribe } as unknown as RpcClient)
|
||||
|
||||
await act(async () =>
|
||||
emit({
|
||||
type: 'replacement',
|
||||
messages: Array.from({ length: 40 }, (_unused, index) => message(`new-${index}`))
|
||||
})
|
||||
)
|
||||
act(() => state?.loadEarlier())
|
||||
|
||||
expect(sendRequest).toHaveBeenCalledWith('nativeChat.readSession', {
|
||||
agent: 'claude',
|
||||
sessionId: 'session',
|
||||
limit: 100
|
||||
})
|
||||
})
|
||||
|
||||
it('clears hasMore when a replaced window is shorter than the initial page', async () => {
|
||||
// A replaced window without paging metadata is judged by its own length:
|
||||
// shorter than a full page means the whole transcript is on screen.
|
||||
const sendRequest = vi.fn()
|
||||
const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => {
|
||||
emit = onData
|
||||
onData({
|
||||
type: 'snapshot',
|
||||
messages: Array.from({ length: 40 }, (_unused, index) => message(`win-${index}`)),
|
||||
hasMore: true,
|
||||
beforeOffset: 100
|
||||
})
|
||||
return () => {}
|
||||
})
|
||||
await mount({ sendRequest, subscribe } as unknown as RpcClient)
|
||||
expect(state?.hasMore).toBe(true)
|
||||
|
||||
await act(async () => emit({ type: 'replacement', messages: [message('only')] }))
|
||||
|
||||
expect(state?.hasMore).toBe(false)
|
||||
})
|
||||
|
||||
it('fences an in-flight older page when a merging replay re-cuts the byte cursor', async () => {
|
||||
let resolveEarlier: (response: unknown) => void = () => {}
|
||||
const sendRequest = vi.fn(
|
||||
() => new Promise((resolve) => (resolveEarlier = resolve))
|
||||
) as unknown as RpcClient['sendRequest']
|
||||
const retained = Array.from({ length: 40 }, (_unused, index) => message(`win-${index}`))
|
||||
const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => {
|
||||
emit = onData
|
||||
onData({ type: 'snapshot', messages: retained, hasMore: true, beforeOffset: 100 })
|
||||
return () => {}
|
||||
})
|
||||
await mount({ sendRequest, subscribe } as unknown as RpcClient)
|
||||
act(() => state?.loadEarlier())
|
||||
|
||||
// The replay merges (same rows, no trim), but the host re-cut the file, so
|
||||
// it carries a new cursor. The page already in flight was addressed with the
|
||||
// old offset and would write that stale cursor back over the fresh one.
|
||||
await act(async () =>
|
||||
emit({ type: 'snapshot', messages: retained, hasMore: true, beforeOffset: 250 })
|
||||
)
|
||||
await act(async () => {
|
||||
resolveEarlier({
|
||||
ok: true,
|
||||
result: { messages: [message('stale-page')], hasMore: true, beforeOffset: 40 }
|
||||
})
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(state?.messages.some((entry) => entry.id === 'stale-page')).toBe(false)
|
||||
expect(state?.loadingEarlier).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the base snapshot authoritative when a live append arrives first', async () => {
|
||||
const sendRequest = vi.fn()
|
||||
const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => {
|
||||
emit = onData
|
||||
return () => {}
|
||||
})
|
||||
await mount({ sendRequest, subscribe } as unknown as RpcClient)
|
||||
// Only a snapshot marks the base as delivered, so an append landing first
|
||||
// must not demote the real base snapshot to a reconnect replay.
|
||||
await act(async () =>
|
||||
emit({ type: 'appended', messages: [message('early-a'), message('early-b')] })
|
||||
)
|
||||
await act(async () =>
|
||||
emit({
|
||||
type: 'snapshot',
|
||||
messages: [message('early-b'), message('base-c')],
|
||||
hasMore: true,
|
||||
beforeOffset: 3
|
||||
})
|
||||
)
|
||||
|
||||
expect(state?.messages.map((entry) => entry.id)).toEqual(['early-b', 'base-c'])
|
||||
})
|
||||
|
||||
it('rejects a cursor page invalidated by live trim and retries with a growing tail', async () => {
|
||||
let resolveCursorPage: (response: unknown) => void = () => {}
|
||||
const sendRequest = vi
|
||||
|
||||
@@ -92,6 +92,9 @@ export function useMobileNativeChatSession(args: {
|
||||
const sessionIdRef = useRef<string | null>(sessionId)
|
||||
sessionIdRef.current = sessionId
|
||||
const streamGenerationRef = useRef(0)
|
||||
// Whether this subscription already delivered its base snapshot; later
|
||||
// snapshots on the same subscription are reconnect replays, not fresh bases.
|
||||
const snapshotSeenRef = useRef(false)
|
||||
|
||||
// Replace the base list (read results are an ordered tail). Resets the merger
|
||||
// cache so the index is rebuilt once over the new base.
|
||||
@@ -107,6 +110,7 @@ export function useMobileNativeChatSession(args: {
|
||||
streamGenerationRef.current += 1
|
||||
limitRef.current = INITIAL_LIMIT
|
||||
loadingEarlierRef.current = false
|
||||
snapshotSeenRef.current = false
|
||||
setLoadingEarlier(false)
|
||||
setList([])
|
||||
setError(undefined)
|
||||
@@ -133,20 +137,11 @@ export function useMobileNativeChatSession(args: {
|
||||
return
|
||||
}
|
||||
const frame = raw as MobileNativeChatStreamFrame
|
||||
if (frame.type === 'replacement' || frame.type === 'snapshot') {
|
||||
// Why: replacement and reconnect snapshots are authoritative windows;
|
||||
// stale page limits/results must not constrain the fresh generation.
|
||||
streamGenerationRef.current += 1
|
||||
limitRef.current = INITIAL_LIMIT
|
||||
loadingEarlierRef.current = false
|
||||
setLoadingEarlier(false)
|
||||
}
|
||||
const replaceSnapshot = frame.type === 'snapshot'
|
||||
const applied = applyMobileNativeChatStreamFrame({
|
||||
merger: mergerRef.current,
|
||||
frame,
|
||||
limit: limitRef.current,
|
||||
replaceSnapshot
|
||||
replaceSnapshot: !snapshotSeenRef.current
|
||||
})
|
||||
if (applied.kind === 'ignored') {
|
||||
return
|
||||
@@ -156,11 +151,28 @@ export function useMobileNativeChatSession(args: {
|
||||
setError(applied.error)
|
||||
return
|
||||
}
|
||||
if (frame.type === 'snapshot') {
|
||||
snapshotSeenRef.current = true
|
||||
}
|
||||
if (applied.windowReplaced || frame.type === 'snapshot') {
|
||||
// Why: any authoritative window (and any replay merge) invalidates an
|
||||
// in-flight older-page request; stale results must not land on it.
|
||||
streamGenerationRef.current += 1
|
||||
loadingEarlierRef.current = false
|
||||
setLoadingEarlier(false)
|
||||
}
|
||||
if (applied.windowReplaced) {
|
||||
// Only a genuinely fresh window resets the grown read window — an
|
||||
// overlapping reconnect replay keeps the paged-in history and limit.
|
||||
limitRef.current = INITIAL_LIMIT
|
||||
beforeOffsetRef.current = applied.beforeOffset ?? null
|
||||
setHasMore(applied.hasMore ?? applied.messages.length >= INITIAL_LIMIT)
|
||||
}
|
||||
setMessages(applied.messages)
|
||||
if (applied.hasMore != null) {
|
||||
if (!applied.windowReplaced && applied.hasMore != null) {
|
||||
setHasMore(applied.hasMore)
|
||||
}
|
||||
if (applied.beforeOffset != null) {
|
||||
if (!applied.windowReplaced && applied.beforeOffset != null) {
|
||||
beforeOffsetRef.current = applied.beforeOffset
|
||||
}
|
||||
if (applied.cursorInvalidated) {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useState } from 'react'
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import {
|
||||
createMobileNativeChatStreamingGate,
|
||||
deriveMobileNativeChatStreaming
|
||||
} from './mobile-native-chat-streaming-gate'
|
||||
|
||||
/** Live streaming-bubble text for the chat list. The gate remembers which
|
||||
* transcript tail predates the current stream segment, so a reply that repeats
|
||||
* the previous turn's prefix still shows while streaming. Call this from a
|
||||
* component that outlives the chat list itself: the baseline has to survive the
|
||||
* view toggles that unmount it, or the next segment reverts to prefix-matching.
|
||||
* `streamLive` keeps those textless gaps from reading as an idle stream. */
|
||||
export function useMobileNativeChatStreamingBubble(
|
||||
folded: readonly NativeChatMessage[],
|
||||
streamingText: string | undefined,
|
||||
scopeKey: string,
|
||||
streamLive: boolean
|
||||
): string | null {
|
||||
const [gate, setGate] = useState(() => createMobileNativeChatStreamingGate(scopeKey))
|
||||
const step = deriveMobileNativeChatStreaming(gate, folded, streamingText, {
|
||||
scopeKey,
|
||||
streamLive
|
||||
})
|
||||
if (step.gate !== gate) {
|
||||
// Render-time state adjustment (derived-state pattern): the advance is
|
||||
// idempotent for a repeated (text, tail) pair, so this settles in one pass.
|
||||
setGate(step.gate)
|
||||
}
|
||||
return step.streaming
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useOpenHostStackRoute } from '../navigation/use-open-host-stack-route'
|
||||
import { mobileSessionRouteTarget, type MobileSessionRouteParams } from './mobile-session-route'
|
||||
|
||||
export function useOpenMobileSession(): (params: MobileSessionRouteParams) => void {
|
||||
const openHostStackRoute = useOpenHostStackRoute()
|
||||
|
||||
return useCallback(
|
||||
(params) => {
|
||||
openHostStackRoute(params.hostId, mobileSessionRouteTarget(params))
|
||||
},
|
||||
[openHostStackRoute]
|
||||
)
|
||||
}
|
||||
@@ -1,72 +1,36 @@
|
||||
import {
|
||||
coordinateHostStackNavigation,
|
||||
hostStackHostRoute,
|
||||
navigateToHostStackRoute,
|
||||
type HostStackHostRoute,
|
||||
type HostStackNavigationController,
|
||||
type HostStackNavigationState,
|
||||
type HostStackRootNavigation,
|
||||
type HostStackRouteTarget,
|
||||
type HostStackRouter,
|
||||
type PendingHostStackNavigation
|
||||
} from '../navigation/host-stack-navigation'
|
||||
import type { TaskProvider } from './mobile-task-providers'
|
||||
|
||||
export type MobileTasksHostRoute = `/h/${string}`
|
||||
|
||||
export type MobileTasksNavigationState = Readonly<{
|
||||
key?: string
|
||||
index: number
|
||||
routes: readonly MobileTasksNavigationRoute[]
|
||||
}>
|
||||
|
||||
export type MobileTasksNavigationRoute = Readonly<{
|
||||
key?: string
|
||||
name: string
|
||||
params?: Readonly<{ hostId?: unknown }>
|
||||
state?: MobileTasksNavigationState
|
||||
}>
|
||||
|
||||
export type MobileTasksRootNavigation = {
|
||||
addListener: (event: 'state', listener: () => void) => () => void
|
||||
dispatch: (action: MobileTasksHostReplaceAction) => void
|
||||
getState: () => MobileTasksNavigationState
|
||||
}
|
||||
|
||||
export type MobileTasksHostReplaceAction = Readonly<{
|
||||
type: 'REPLACE'
|
||||
target: string
|
||||
source: string
|
||||
payload: Readonly<{
|
||||
name: '[hostId]/tasks'
|
||||
params: Readonly<{ hostId: string; taskSource?: TaskProvider }>
|
||||
}>
|
||||
}>
|
||||
|
||||
export type MobileTasksRouter = {
|
||||
push: (route: MobileTasksHostRoute) => void
|
||||
}
|
||||
|
||||
export type MobileTasksNavigationController = Readonly<{
|
||||
cancel: () => void
|
||||
isActive: () => boolean
|
||||
selectProvider: (provider?: TaskProvider) => void
|
||||
}>
|
||||
|
||||
export type PendingMobileTasksNavigation = Readonly<{
|
||||
hostId: string
|
||||
controller: MobileTasksNavigationController
|
||||
}>
|
||||
export type MobileTasksHostRoute = HostStackHostRoute
|
||||
export type MobileTasksNavigationState = HostStackNavigationState
|
||||
export type MobileTasksRootNavigation = HostStackRootNavigation
|
||||
export type MobileTasksRouter = HostStackRouter
|
||||
export type MobileTasksNavigationController = HostStackNavigationController
|
||||
export type PendingMobileTasksNavigation = PendingHostStackNavigation
|
||||
|
||||
export function mobileTasksHostRoute(hostId: string): MobileTasksHostRoute {
|
||||
return `/h/${encodeURIComponent(hostId)}`
|
||||
return hostStackHostRoute(hostId)
|
||||
}
|
||||
|
||||
function mountedHostStack(
|
||||
state: MobileTasksNavigationState,
|
||||
expectedHostId: string
|
||||
): { key: string; routeKey: string } | null {
|
||||
const hostContainer = state.routes[state.index]
|
||||
const hostState = hostContainer?.state
|
||||
const hostRoute = hostState?.routes[hostState.index]
|
||||
if (
|
||||
hostContainer?.name !== 'h' ||
|
||||
!hostState?.key ||
|
||||
hostRoute?.name !== '[hostId]/index' ||
|
||||
!hostRoute.key ||
|
||||
hostRoute.params?.hostId !== expectedHostId
|
||||
) {
|
||||
return null
|
||||
export function mobileTasksRouteTarget(
|
||||
hostId: string,
|
||||
provider?: TaskProvider
|
||||
): HostStackRouteTarget {
|
||||
return {
|
||||
name: '[hostId]/tasks',
|
||||
params: provider ? { hostId, taskSource: provider } : { hostId }
|
||||
}
|
||||
return { key: hostState.key, routeKey: hostRoute.key }
|
||||
}
|
||||
|
||||
export function navigateToMobileTasks(
|
||||
@@ -75,64 +39,12 @@ export function navigateToMobileTasks(
|
||||
hostId: string,
|
||||
provider?: TaskProvider
|
||||
): MobileTasksNavigationController {
|
||||
let active = true
|
||||
let hostRouteSeen = false
|
||||
let selectedProvider = provider
|
||||
let unsubscribeState = () => {}
|
||||
const dispose = () => {
|
||||
if (!active) {
|
||||
return
|
||||
}
|
||||
active = false
|
||||
unsubscribeState()
|
||||
}
|
||||
|
||||
// Why: cold Expo deep links resolve to index; target Tasks only after its HostStack exists.
|
||||
const onState = () => {
|
||||
if (!active) {
|
||||
return
|
||||
}
|
||||
const state = navigation.getState()
|
||||
const currentRoute = state.routes[state.index]
|
||||
if (currentRoute?.name === 'h') {
|
||||
hostRouteSeen = true
|
||||
} else if (hostRouteSeen) {
|
||||
dispose()
|
||||
return
|
||||
}
|
||||
const hostStack = mountedHostStack(state, hostId)
|
||||
if (!hostStack) {
|
||||
return
|
||||
}
|
||||
dispose()
|
||||
const action: MobileTasksHostReplaceAction = {
|
||||
type: 'REPLACE',
|
||||
target: hostStack.key,
|
||||
source: hostStack.routeKey,
|
||||
payload: {
|
||||
name: '[hostId]/tasks',
|
||||
params: selectedProvider ? { hostId, taskSource: selectedProvider } : { hostId }
|
||||
}
|
||||
}
|
||||
navigation.dispatch(action)
|
||||
}
|
||||
|
||||
try {
|
||||
unsubscribeState = navigation.addListener('state', onState)
|
||||
router.push(mobileTasksHostRoute(hostId))
|
||||
} catch (error) {
|
||||
dispose()
|
||||
throw error
|
||||
}
|
||||
return {
|
||||
cancel: dispose,
|
||||
isActive: () => active,
|
||||
selectProvider: (nextProvider) => {
|
||||
if (active) {
|
||||
selectedProvider = nextProvider
|
||||
}
|
||||
}
|
||||
}
|
||||
return navigateToHostStackRoute(
|
||||
navigation,
|
||||
router,
|
||||
hostId,
|
||||
mobileTasksRouteTarget(hostId, provider)
|
||||
)
|
||||
}
|
||||
|
||||
export function coordinateMobileTasksNavigation(
|
||||
@@ -142,13 +54,11 @@ export function coordinateMobileTasksNavigation(
|
||||
hostId: string,
|
||||
provider?: TaskProvider
|
||||
): PendingMobileTasksNavigation {
|
||||
if (current?.hostId === hostId && current.controller.isActive()) {
|
||||
current.controller.selectProvider(provider)
|
||||
return current
|
||||
}
|
||||
current?.controller.cancel()
|
||||
return {
|
||||
return coordinateHostStackNavigation(
|
||||
current,
|
||||
navigation,
|
||||
router,
|
||||
hostId,
|
||||
controller: navigateToMobileTasks(navigation, router, hostId, provider)
|
||||
}
|
||||
mobileTasksRouteTarget(hostId, provider)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,35 +1,15 @@
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { useNavigation, useRouter } from 'expo-router'
|
||||
import {
|
||||
coordinateMobileTasksNavigation,
|
||||
type MobileTasksRootNavigation,
|
||||
type PendingMobileTasksNavigation
|
||||
} from './mobile-task-navigation'
|
||||
import { useCallback } from 'react'
|
||||
import { useOpenHostStackRoute } from '../navigation/use-open-host-stack-route'
|
||||
import { mobileTasksRouteTarget } from './mobile-task-navigation'
|
||||
import type { TaskProvider } from './mobile-task-providers'
|
||||
|
||||
export function useOpenMobileTasks(): (hostId: string, provider?: TaskProvider) => void {
|
||||
const navigation = useNavigation<MobileTasksRootNavigation>()
|
||||
const router = useRouter()
|
||||
const pendingRef = useRef<PendingMobileTasksNavigation | null>(null)
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
pendingRef.current?.controller.cancel()
|
||||
pendingRef.current = null
|
||||
},
|
||||
[]
|
||||
)
|
||||
const openHostStackRoute = useOpenHostStackRoute()
|
||||
|
||||
return useCallback(
|
||||
(hostId, provider) => {
|
||||
pendingRef.current = coordinateMobileTasksNavigation(
|
||||
pendingRef.current,
|
||||
navigation,
|
||||
router,
|
||||
hostId,
|
||||
provider
|
||||
)
|
||||
openHostStackRoute(hostId, mobileTasksRouteTarget(hostId, provider))
|
||||
},
|
||||
[navigation, router]
|
||||
[openHostStackRoute]
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,29 +1,95 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { mobileHostEditRoute, navigateToMobileHostEdit } from './host-edit-navigation'
|
||||
import {
|
||||
mobileHostEditHostRoute,
|
||||
mobileHostEditRoute,
|
||||
navigateToMobileHostEdit,
|
||||
type MobileHostEditNavigationState
|
||||
} from './host-edit-navigation'
|
||||
|
||||
describe('mobileHostEditRoute', () => {
|
||||
it('keeps the dynamic host segment explicit for a cold host navigator', () => {
|
||||
expect(mobileHostEditRoute('host-1')).toEqual({
|
||||
pathname: '/h/[hostId]/edit',
|
||||
params: { hostId: 'host-1' }
|
||||
})
|
||||
})
|
||||
function navigationHarness(initialState: MobileHostEditNavigationState) {
|
||||
let stateListener = () => {}
|
||||
let state = initialState
|
||||
const unsubscribeState = vi.fn()
|
||||
const navigation = {
|
||||
addListener: vi.fn((_event: 'state', listener: () => void) => {
|
||||
stateListener = listener
|
||||
return unsubscribeState
|
||||
}),
|
||||
getState: () => state
|
||||
}
|
||||
return {
|
||||
navigation,
|
||||
setState(nextState: MobileHostEditNavigationState) {
|
||||
state = nextState
|
||||
stateListener()
|
||||
},
|
||||
unsubscribeState
|
||||
}
|
||||
}
|
||||
|
||||
it('mounts a cold host navigator before replacing its index with edit', () => {
|
||||
let nextFrame: FrameRequestCallback | null = null
|
||||
describe('mobile host edit navigation', () => {
|
||||
it('waits for the expected host route to commit before replacing it with Edit', () => {
|
||||
const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] })
|
||||
const push = vi.fn()
|
||||
const replace = vi.fn()
|
||||
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
||||
nextFrame = callback
|
||||
return 1
|
||||
})
|
||||
|
||||
navigateToMobileHostEdit({ push, replace }, 'host-1')
|
||||
expect(push).toHaveBeenCalledWith('/h/host-1')
|
||||
navigateToMobileHostEdit(harness.navigation, { push, replace }, 'host/1')
|
||||
|
||||
expect(push).toHaveBeenCalledWith(mobileHostEditHostRoute('host/1'))
|
||||
expect(replace).not.toHaveBeenCalled()
|
||||
|
||||
nextFrame?.(0)
|
||||
expect(replace).toHaveBeenCalledWith(mobileHostEditRoute('host-1'))
|
||||
vi.unstubAllGlobals()
|
||||
harness.setState({
|
||||
index: 1,
|
||||
routes: [{ name: 'index' }, { name: 'h', params: { hostId: 'host/1' } }]
|
||||
})
|
||||
|
||||
expect(harness.unsubscribeState).toHaveBeenCalledOnce()
|
||||
expect(replace).toHaveBeenCalledWith(mobileHostEditRoute('host/1'))
|
||||
})
|
||||
|
||||
it('does not replace an unrelated host route', () => {
|
||||
const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] })
|
||||
const replace = vi.fn()
|
||||
|
||||
navigateToMobileHostEdit(harness.navigation, { push: vi.fn(), replace }, 'host-1')
|
||||
harness.setState({ index: 0, routes: [{ name: 'h', params: { hostId: 'host-2' } }] })
|
||||
|
||||
expect(replace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('cancels a pending replacement when navigation leaves the host flow', () => {
|
||||
const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] })
|
||||
const replace = vi.fn()
|
||||
const controller = navigateToMobileHostEdit(
|
||||
harness.navigation,
|
||||
{ push: vi.fn(), replace },
|
||||
'host-1'
|
||||
)
|
||||
|
||||
harness.setState({ index: 0, routes: [{ name: 'h', params: { hostId: 'host-2' } }] })
|
||||
controller.cancel()
|
||||
harness.setState({ index: 0, routes: [{ name: 'h', params: { hostId: 'host-1' } }] })
|
||||
|
||||
expect(harness.unsubscribeState).toHaveBeenCalledOnce()
|
||||
expect(replace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('unsubscribes when mounting the host throws synchronously', () => {
|
||||
const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] })
|
||||
const error = new Error('navigation failed')
|
||||
|
||||
expect(() =>
|
||||
navigateToMobileHostEdit(
|
||||
harness.navigation,
|
||||
{
|
||||
push: () => {
|
||||
throw error
|
||||
},
|
||||
replace: vi.fn()
|
||||
},
|
||||
'host-1'
|
||||
)
|
||||
).toThrow(error)
|
||||
expect(harness.unsubscribeState).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,31 @@
|
||||
type HostEditRouter = {
|
||||
export type MobileHostEditNavigationState = Readonly<{
|
||||
index: number
|
||||
routes: readonly MobileHostEditNavigationRoute[]
|
||||
}>
|
||||
|
||||
export type MobileHostEditNavigationRoute = Readonly<{
|
||||
name: string
|
||||
params?: Readonly<{ hostId?: unknown }>
|
||||
}>
|
||||
|
||||
export type MobileHostEditRootNavigation = {
|
||||
addListener: (event: 'state', listener: () => void) => () => void
|
||||
getState: () => MobileHostEditNavigationState
|
||||
}
|
||||
|
||||
export type MobileHostEditRouter = {
|
||||
push: (href: `/h/${string}`) => void
|
||||
replace: (href: ReturnType<typeof mobileHostEditRoute>) => void
|
||||
}
|
||||
|
||||
export type MobileHostEditNavigationController = Readonly<{
|
||||
cancel: () => void
|
||||
}>
|
||||
|
||||
export function mobileHostEditHostRoute(hostId: string): `/h/${string}` {
|
||||
return `/h/${encodeURIComponent(hostId)}`
|
||||
}
|
||||
|
||||
export function mobileHostEditRoute(hostId: string) {
|
||||
return {
|
||||
pathname: '/h/[hostId]/edit' as const,
|
||||
@@ -10,10 +33,49 @@ export function mobileHostEditRoute(hostId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export function navigateToMobileHostEdit(router: HostEditRouter, hostId: string): void {
|
||||
// Why: a cold nested host navigator resolves a deep push to its index route.
|
||||
router.push(`/h/${hostId}`)
|
||||
requestAnimationFrame(() => {
|
||||
export function navigateToMobileHostEdit(
|
||||
navigation: MobileHostEditRootNavigation,
|
||||
router: MobileHostEditRouter,
|
||||
hostId: string
|
||||
): MobileHostEditNavigationController {
|
||||
let active = true
|
||||
let hostRouteSeen = false
|
||||
let unsubscribeState = () => {}
|
||||
const dispose = () => {
|
||||
if (!active) {
|
||||
return
|
||||
}
|
||||
active = false
|
||||
unsubscribeState()
|
||||
}
|
||||
|
||||
// Why: cold Expo deep links resolve to index; target Edit after the host route commits.
|
||||
const onState = () => {
|
||||
if (!active) {
|
||||
return
|
||||
}
|
||||
const state = navigation.getState()
|
||||
const currentRoute = state.routes[state.index]
|
||||
if (currentRoute?.name !== 'h') {
|
||||
if (hostRouteSeen) {
|
||||
dispose()
|
||||
}
|
||||
return
|
||||
}
|
||||
hostRouteSeen = true
|
||||
if (currentRoute.params?.hostId !== hostId) {
|
||||
return
|
||||
}
|
||||
dispose()
|
||||
router.replace(mobileHostEditRoute(hostId))
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
unsubscribeState = navigation.addListener('state', onState)
|
||||
router.push(mobileHostEditHostRoute(hostId))
|
||||
} catch (error) {
|
||||
dispose()
|
||||
throw error
|
||||
}
|
||||
return { cancel: dispose }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { useNavigation, useRouter } from 'expo-router'
|
||||
import {
|
||||
navigateToMobileHostEdit,
|
||||
type MobileHostEditNavigationController,
|
||||
type MobileHostEditRootNavigation
|
||||
} from './host-edit-navigation'
|
||||
|
||||
export function useOpenMobileHostEdit(): (hostId: string) => void {
|
||||
const navigation = useNavigation<MobileHostEditRootNavigation>()
|
||||
const router = useRouter()
|
||||
const pendingRef = useRef<MobileHostEditNavigationController | null>(null)
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
pendingRef.current?.cancel()
|
||||
pendingRef.current = null
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
return useCallback(
|
||||
(hostId) => {
|
||||
pendingRef.current?.cancel()
|
||||
pendingRef.current = navigateToMobileHostEdit(navigation, router, hostId)
|
||||
},
|
||||
[navigation, router]
|
||||
)
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "orca",
|
||||
"version": "1.4.168-rc.1",
|
||||
"version": "1.4.169-rc.0",
|
||||
"description": "Next-gen IDE for parallel agentic development",
|
||||
"homepage": "https://github.com/stablyai/orca",
|
||||
"author": "stablyai",
|
||||
|
||||
@@ -195,6 +195,7 @@ ORCA terminal close --terminal <handle> --json
|
||||
Terminal rules:
|
||||
|
||||
- `--terminal` is optional for most commands; omitted means the active terminal in the current worktree.
|
||||
- `terminal list --json` omits `visualLayouts` to keep the common agent payload bounded. Add `--include-visual-layouts` only when tab and pane topology is required.
|
||||
- Use `terminal read` before `terminal send` unless the next input is obvious.
|
||||
- Use `terminal send` only for direct terminal input or one-off prompts where no task state, inbox, or reply tracking is needed.
|
||||
- For structured coordination, invoke the `orchestration` skill; it uses `orca orchestration ...` commands for messages, handoffs, task DAGs, dispatches, inbox/reply flows, and coordinator loops. A receiving agent can run `orca orchestration check --unread --inject` to render its unread mail in agent-readable form; this checks the caller's inbox and does not remotely deliver input to another terminal.
|
||||
|
||||
@@ -140,6 +140,7 @@ Rules:
|
||||
- Use `--peek` and `--all` only for read-only history/debugging. Type filters decide when a waiter wakes; the returned actionable Delivery is still the oldest full batch.
|
||||
- Use `dispatch:<id>` for coordinator guidance to one supervised worker. Orca routes that stable address locally or through the connected-server relay; do not substitute a remote terminal handle.
|
||||
- Terminal handles remain appropriate for low-level pre-Dispatch messaging. Prefer `agentTerminalHandle` from the create response, fall back to `startupTerminal.handle` for older runtimes, then re-resolve with `orca terminal list --worktree ... --json` if missing or stale. Continue with the replacement handle only; never dual-send to old and new handles.
|
||||
- `terminal list --json` omits `visualLayouts` because handle recovery does not need topology. Add `--include-visual-layouts` only for explicit tab and pane inspection.
|
||||
- `orca orchestration check --peek --format --json` returns locally formatted unread mail without consuming it; it never writes to terminal input or remotely wakes another terminal. Use `orchestration dispatch --inject` to deliver a tracked task, or `terminal send` when an existing agent needs a free-form prompt.
|
||||
- While supervising workers manually, use `check --wait --types worker_done,escalation,question --timeout-ms <n>` instead of sleep/poll loops. Process the whole Delivery, reply to `question` messages with `orca orchestration reply --id <msg_id> --body <answer> --json`, then acknowledge and keep waiting.
|
||||
- Treat a `check --wait` timeout or `{count:0}` as a checkpoint, not a worker failure. Long coding tasks routinely run 15-60 minutes; keep using rolling waits unless you receive `worker_done`/`escalation`, the terminal exits or disappears, or the user explicitly asks you to stop.
|
||||
@@ -355,7 +356,7 @@ Sidebar lineage and orchestration lifecycle are related but not identical. A sam
|
||||
Other terminal commands coordinators often need:
|
||||
|
||||
```bash
|
||||
orca terminal list [--worktree <selector>] [--json]
|
||||
orca terminal list [--worktree <selector>] [--include-visual-layouts] [--json]
|
||||
orca terminal create [--worktree <selector>] [--title <text>] [--command <cmd>] [--json]
|
||||
orca terminal split --terminal <handle> [--direction horizontal|vertical] [--command <cmd>] [--json]
|
||||
orca terminal wait --terminal <handle> --for tui-idle --timeout-ms <n> --json
|
||||
|
||||
@@ -28,6 +28,7 @@ export const BOOLEAN_FLAGS = new Set([
|
||||
'help',
|
||||
'inject',
|
||||
'include-archived',
|
||||
'include-visual-layouts',
|
||||
'interrupt',
|
||||
'json',
|
||||
'local',
|
||||
|
||||
@@ -47,7 +47,8 @@ export function formatBrowserProfileList(result: BrowserProfileListResult): stri
|
||||
.map((profile) => {
|
||||
const marker = profile.scope === 'default' ? '* ' : ' '
|
||||
const source = profile.source?.browserFamily ?? 'none'
|
||||
return `${marker}${profile.id} ${profile.label} ${profile.scope} source:${source}`
|
||||
const userAgent = profile.userAgentMode === 'native' ? ' ua:native' : ''
|
||||
return `${marker}${profile.id} ${profile.label} ${profile.scope} source:${source}${userAgent}`
|
||||
})
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
@@ -414,6 +414,29 @@ describe('orca cli browser tab profiles', () => {
|
||||
expect(logSpy).toHaveBeenCalledWith('No browser profiles found.')
|
||||
})
|
||||
|
||||
it('marks native-UA profiles in text output', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_profiles_native_ua', {
|
||||
profiles: [
|
||||
{
|
||||
id: 'google',
|
||||
scope: 'isolated',
|
||||
label: 'Google',
|
||||
partition: 'persist:orca-browser-session-google',
|
||||
source: null,
|
||||
userAgentMode: 'native'
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(['tab', 'profile', 'list'], '/tmp/not-an-orca-worktree')
|
||||
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('ua:native'))
|
||||
})
|
||||
|
||||
it('creates isolated browser tab profiles by default', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
@@ -465,6 +488,33 @@ describe('orca cli browser tab profiles', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('creates a profile with the native user agent when requested', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
okFixture('req_profile_native_ua', {
|
||||
profile: {
|
||||
id: 'google',
|
||||
scope: 'isolated',
|
||||
label: 'Google',
|
||||
partition: 'persist:orca-browser-session-google',
|
||||
userAgentMode: 'native'
|
||||
}
|
||||
})
|
||||
)
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(
|
||||
['tab', 'profile', 'create', '--label', 'Google', '--no-ua-spoof', '--json'],
|
||||
'/tmp/not-an-orca-worktree'
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith('browser.profileCreate', {
|
||||
label: 'Google',
|
||||
scope: 'isolated',
|
||||
userAgentMode: 'native'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects unknown --scope values instead of silently defaulting to isolated', async () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -38,7 +38,8 @@ export const BROWSER_PROFILE_HANDLERS: Record<string, CommandHandler> = {
|
||||
const scope = parseScopeFlag(flags)
|
||||
const result = await client.call<BrowserProfileCreateResult>('browser.profileCreate', {
|
||||
label,
|
||||
scope
|
||||
scope,
|
||||
...(flags.get('no-ua-spoof') === true ? { userAgentMode: 'native' } : {})
|
||||
})
|
||||
if (result.result.profile === null) {
|
||||
// Why: registry refuses non-isolated/imported scopes; we already validated
|
||||
|
||||
@@ -55,7 +55,9 @@ export const TERMINAL_HANDLERS: Record<string, CommandHandler> = {
|
||||
'terminal list': async ({ flags, client, cwd, json }) => {
|
||||
const result = await client.call<RuntimeTerminalListResult>('terminal.list', {
|
||||
worktree: await getOptionalWorktreeSelector(flags, 'worktree', cwd, client),
|
||||
limit: getOptionalPositiveIntegerFlag(flags, 'limit')
|
||||
limit: getOptionalPositiveIntegerFlag(flags, 'limit'),
|
||||
// Why: agent JSON calls dominate; topology stays available through an explicit opt-in.
|
||||
includeVisualLayouts: !json || flags.has('include-visual-layouts')
|
||||
})
|
||||
printResult(result, json, formatTerminalList)
|
||||
},
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import { parseLinearIssueInput } from '../../shared/linear-links'
|
||||
import {
|
||||
buildLinearIssueLinkUpdates,
|
||||
LINEAR_ISSUE_LINK_CLEARED,
|
||||
type LinearIssueLinkUpdates
|
||||
} from '../../shared/linear-links'
|
||||
import { RuntimeClientError } from '../runtime-client'
|
||||
|
||||
type LinearIssueLinkParams = {
|
||||
linkedLinearIssue: string | null
|
||||
linkedLinearIssueWorkspaceId: string | null
|
||||
linkedLinearIssueOrganizationUrlKey: string | null
|
||||
}
|
||||
|
||||
export function getOptionalLinearIssueLinkFlag(
|
||||
flags: Map<string, string | boolean>,
|
||||
name: string,
|
||||
options: { allowNull?: boolean } = {}
|
||||
): LinearIssueLinkParams | undefined {
|
||||
): LinearIssueLinkUpdates | undefined {
|
||||
const value = getPresentStringFlag(flags, name)
|
||||
if (value === undefined) {
|
||||
return undefined
|
||||
@@ -24,28 +22,22 @@ export function getOptionalLinearIssueLinkFlag(
|
||||
'Omit --linear-issue on create, or pass a Linear issue identifier or URL.'
|
||||
)
|
||||
}
|
||||
return {
|
||||
linkedLinearIssue: null,
|
||||
linkedLinearIssueWorkspaceId: null,
|
||||
linkedLinearIssueOrganizationUrlKey: null
|
||||
}
|
||||
return { ...LINEAR_ISSUE_LINK_CLEARED }
|
||||
}
|
||||
|
||||
const parsed = parseLinearIssueInput(value)
|
||||
if (!parsed) {
|
||||
// Why: the shared builder treats empty input as "clear", which is right for a
|
||||
// text field the user can blank but wrong for a flag — `--linear-issue " "`
|
||||
// is a mistyped argument, not a request to unlink. Only the literal `null`
|
||||
// clears, and that is handled above.
|
||||
const updates = value.trim() === '' ? null : buildLinearIssueLinkUpdates(value)
|
||||
if (!updates) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'Pass a Linear issue identifier like STA-335, a Linear issue URL, or null to clear.'
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
linkedLinearIssue: parsed.identifier,
|
||||
// Why: changing a link must not keep a workspace id from a previous issue.
|
||||
// The org key from URLs is enough for current-resolution to safely rehydrate it.
|
||||
linkedLinearIssueWorkspaceId: null,
|
||||
linkedLinearIssueOrganizationUrlKey: parsed.organizationUrlKey ?? null
|
||||
}
|
||||
return updates
|
||||
}
|
||||
|
||||
function getPresentStringFlag(
|
||||
|
||||
+8
-1
@@ -237,7 +237,7 @@ Common Commands:
|
||||
orca file open <path> [--worktree <selector>] [--json]
|
||||
orca file diff <path> [--staged] [--worktree <selector>] [--json]
|
||||
orca file open-changed [--mode edit|diff|both] [--worktree <selector>] [--json]
|
||||
orca terminal list [--worktree <selector>] [--limit <n>] [--json]
|
||||
orca terminal list [--worktree <selector>] [--limit <n>] [--include-visual-layouts] [--json]
|
||||
orca terminal show [--terminal <handle>] [--json]
|
||||
orca terminal read [--terminal <handle>] [--cursor <n>] [--limit <n>] [--json]
|
||||
orca terminal send [--terminal <handle>] [--text <text>] [--enter] [--interrupt] [--json]
|
||||
@@ -272,6 +272,9 @@ Terminal Send Options:
|
||||
--enter Append Enter after sending text
|
||||
--interrupt Send as an interrupt-style input when supported
|
||||
|
||||
Terminal List Options:
|
||||
--include-visual-layouts Include tab and pane topology in JSON output
|
||||
|
||||
Wait Options:
|
||||
--for exit Wait until the target terminal exits
|
||||
--timeout-ms <ms> Maximum wait time before timing out
|
||||
@@ -318,6 +321,7 @@ Browser Options:
|
||||
--page <id> Stable browser page id (preferred for concurrent workflows)
|
||||
--profile <id> Browser profile id
|
||||
--show-profile Include the tab's browser profile in text output
|
||||
--no-ua-spoof Keep Electron's native user agent for a new profile
|
||||
--format <png|jpeg> Screenshot image format
|
||||
--from <ref> Drag source element ref
|
||||
--to <ref> Drag target element ref
|
||||
@@ -535,6 +539,8 @@ export function formatFlagHelp(flag: string): string {
|
||||
'from-x': '--from-x <x> Source window-local x coordinate',
|
||||
'from-y': '--from-y <y> Source window-local y coordinate',
|
||||
help: '--help Show this help message',
|
||||
'include-visual-layouts':
|
||||
'--include-visual-layouts Include tab and pane topology in JSON output',
|
||||
interrupt: '--interrupt Send as an interrupt-style input when supported',
|
||||
id: '--id <id> Identifier for a target item or permission',
|
||||
issue: '--issue <number|null> Linked GitHub issue number',
|
||||
@@ -611,6 +617,7 @@ export function formatFlagHelp(flag: string): string {
|
||||
page: '--page <id> Stable browser page id from `orca tab list --json`',
|
||||
profile: '--profile <id> Browser profile id',
|
||||
'show-profile': '--show-profile Include tab profile in text output',
|
||||
'no-ua-spoof': "--no-ua-spoof Keep Electron's native user agent",
|
||||
format: '--format <png|jpeg> Screenshot image format'
|
||||
}
|
||||
|
||||
|
||||
+70
-1
@@ -426,6 +426,19 @@ describe('orca root help', () => {
|
||||
expect(callMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('documents the machine-readable terminal topology opt-in', async () => {
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
logSpy.mockClear()
|
||||
|
||||
await main(['terminal', 'list', '--help'], '/tmp/repo')
|
||||
|
||||
const help = String(logSpy.mock.calls[0][0])
|
||||
expect(help).toContain('[--include-visual-layouts] [--json]')
|
||||
expect(help).toContain('--include-visual-layouts Include tab and pane topology in JSON output')
|
||||
expect(help).toContain('JSON omits visualLayouts by default')
|
||||
expect(callMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('describes worker-read cursors as opaque', async () => {
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
logSpy.mockClear()
|
||||
@@ -1245,6 +1258,9 @@ describe('orca cli worktree awareness', () => {
|
||||
linkedIssue: undefined,
|
||||
linkedLinearIssue: 'STA-335',
|
||||
linkedLinearIssueWorkspaceId: null,
|
||||
// Why: a bare identifier carries no org, and the two scoping fields
|
||||
// describe the issue being replaced. Inheriting a stale org key would
|
||||
// pin the lookup to a workspace this issue does not live in.
|
||||
linkedLinearIssueOrganizationUrlKey: null,
|
||||
comment: undefined,
|
||||
runHooks: false,
|
||||
@@ -3702,7 +3718,60 @@ describe('orca cli worktree awareness', () => {
|
||||
|
||||
expect(callMock).toHaveBeenNthCalledWith(2, 'terminal.list', {
|
||||
worktree: 'id:repo::/tmp/repo/feature',
|
||||
limit: undefined
|
||||
limit: undefined,
|
||||
includeVisualLayouts: false
|
||||
})
|
||||
})
|
||||
|
||||
it('requests visual layouts only for the human-readable terminal list', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
worktreeListFixture([buildWorktree('/tmp/repo/feature', 'feature/foo')]),
|
||||
okFixture('req_term', { terminals: [], totalCount: 0, truncated: false })
|
||||
)
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(['terminal', 'list', '--worktree', 'active'], '/tmp/repo/feature/src')
|
||||
|
||||
expect(callMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'terminal.list',
|
||||
expect.objectContaining({ includeVisualLayouts: true })
|
||||
)
|
||||
})
|
||||
|
||||
it('allows agent JSON clients to request visual layouts explicitly', async () => {
|
||||
const visualLayouts = [
|
||||
{
|
||||
worktreeId: 'repo::/tmp/repo/feature',
|
||||
worktreePath: '/tmp/repo/feature',
|
||||
root: { type: 'group', groupId: null, activeTabId: null, tabs: [] }
|
||||
}
|
||||
]
|
||||
queueFixtures(
|
||||
callMock,
|
||||
worktreeListFixture([buildWorktree('/tmp/repo/feature', 'feature/foo')]),
|
||||
okFixture('req_term', {
|
||||
terminals: [],
|
||||
visualLayouts,
|
||||
totalCount: 0,
|
||||
truncated: false
|
||||
})
|
||||
)
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(
|
||||
['terminal', 'list', '--worktree', 'active', '--include-visual-layouts', '--json'],
|
||||
'/tmp/repo/feature/src'
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'terminal.list',
|
||||
expect.objectContaining({ includeVisualLayouts: true })
|
||||
)
|
||||
expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toMatchObject({
|
||||
result: { visualLayouts }
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
+14
-56
@@ -11,15 +11,12 @@ import { getDefaultUserDataPath, readMetadata } from './metadata'
|
||||
import { getCliStatus, resolveDesktopWindowStatus } from './status'
|
||||
import { sendRequest } from './transport'
|
||||
import { RuntimeClientError, RuntimeRpcFailureError, type RuntimeRpcSuccess } from './types'
|
||||
import type { sendWebSocketRequest } from './websocket-transport'
|
||||
import { markEnvironmentUsed, resolveEnvironmentPairingOffer } from './environments'
|
||||
import { describeRuntimeCompatBlock, evaluateRuntimeCompat } from '../../shared/protocol-compat'
|
||||
import {
|
||||
MIN_COMPATIBLE_RUNTIME_SERVER_VERSION,
|
||||
ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY,
|
||||
ORCHESTRATION_CONTRACT_VERSION,
|
||||
RUNTIME_PROTOCOL_VERSION
|
||||
ORCHESTRATION_CONTRACT_VERSION
|
||||
} from '../../shared/protocol-version'
|
||||
import { RemoteRuntimeCompatGate } from './remote-runtime-compat-gate'
|
||||
import { createOrchestrationCompatibilityEnvelope } from './orchestration-compatibility-envelope'
|
||||
import { getTimeoutMsParam, isWaitingCheck } from './runtime-request-timeout'
|
||||
|
||||
@@ -34,8 +31,8 @@ const LONG_POLL_CLIENT_GRACE_MS = 10_000
|
||||
// Why: ws + tweetnacl + the remote-runtime frame stack only matter once a
|
||||
// request actually goes over a pairing offer, which local CLI calls never do.
|
||||
// Both call sites already await this, so deferring the load changes no ordering.
|
||||
async function loadSendWebSocketRequest(): Promise<typeof sendWebSocketRequest> {
|
||||
return (await import('./websocket-transport.js')).sendWebSocketRequest
|
||||
async function loadWebSocketTransport() {
|
||||
return await import('./websocket-transport.js')
|
||||
}
|
||||
|
||||
export class RuntimeClient {
|
||||
@@ -43,7 +40,7 @@ export class RuntimeClient {
|
||||
private readonly requestTimeoutMs: number
|
||||
private readonly remotePairing: PairingOffer | null
|
||||
private readonly environmentSelector: string | null
|
||||
private remoteCompatChecked = false
|
||||
private readonly remoteCompat: RemoteRuntimeCompatGate
|
||||
private orchestrationContractCheck: Promise<void> | null = null
|
||||
private readonly orchestrationCompatibility = createOrchestrationCompatibilityEnvelope(
|
||||
process.env
|
||||
@@ -62,6 +59,7 @@ export class RuntimeClient {
|
||||
this.requestTimeoutMs = requestTimeoutMs
|
||||
this.environmentSelector = environmentSelector
|
||||
this.remotePairing = resolveRemotePairing(userDataPath, remotePairingCode, environmentSelector)
|
||||
this.remoteCompat = new RemoteRuntimeCompatGate(userDataPath, environmentSelector)
|
||||
}
|
||||
|
||||
get isRemote(): boolean {
|
||||
@@ -97,19 +95,17 @@ export class RuntimeClient {
|
||||
...compatibilityEnvelope
|
||||
}
|
||||
if (this.remotePairing) {
|
||||
if (method !== 'status.get') {
|
||||
await this.ensureRemoteRuntimeCompatible(effectiveTimeoutMs)
|
||||
}
|
||||
const sendWebSocketRequest = await loadSendWebSocketRequest()
|
||||
const transport = await loadWebSocketTransport()
|
||||
let response
|
||||
try {
|
||||
response = await sendWebSocketRequest<TResult>(
|
||||
this.remotePairing,
|
||||
response = await this.remoteCompat.send<TResult>({
|
||||
transport,
|
||||
pairing: this.remotePairing,
|
||||
method,
|
||||
params,
|
||||
effectiveTimeoutMs,
|
||||
timeoutMs: effectiveTimeoutMs,
|
||||
envelope
|
||||
)
|
||||
})
|
||||
} catch (error) {
|
||||
throw attachMutationRecovery(error, orchestrationRequestId)
|
||||
}
|
||||
@@ -157,8 +153,7 @@ export class RuntimeClient {
|
||||
async getCliStatus(): Promise<RuntimeRpcSuccess<CliStatusResult>> {
|
||||
if (this.remotePairing) {
|
||||
const response = await this.call<RuntimeStatus>('status.get')
|
||||
this.assertRemoteRuntimeStatusCompatible(response.result)
|
||||
this.remoteCompatChecked = true
|
||||
this.remoteCompat.noteVerifiedStatus(response.result)
|
||||
const graphState = response.result.graphStatus
|
||||
return {
|
||||
id: response.id,
|
||||
@@ -196,29 +191,6 @@ export class RuntimeClient {
|
||||
return getCliStatus(this.userDataPath)
|
||||
}
|
||||
|
||||
private async ensureRemoteRuntimeCompatible(timeoutMs: number): Promise<void> {
|
||||
if (!this.remotePairing || this.remoteCompatChecked) {
|
||||
return
|
||||
}
|
||||
const sendWebSocketRequest = await loadSendWebSocketRequest()
|
||||
const response = await sendWebSocketRequest<RuntimeStatus>(
|
||||
this.remotePairing,
|
||||
'status.get',
|
||||
undefined,
|
||||
timeoutMs
|
||||
)
|
||||
if (response.ok === false) {
|
||||
throw new RuntimeRpcFailureError(response)
|
||||
}
|
||||
this.assertRemoteRuntimeStatusCompatible(response.result)
|
||||
this.remoteCompatChecked = true
|
||||
if (this.environmentSelector) {
|
||||
markEnvironmentUsed(this.userDataPath, this.environmentSelector, {
|
||||
runtimeId: response._meta.runtimeId
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureOrchestrationContractCompatible(timeoutMs: number): Promise<void> {
|
||||
if (!this.orchestrationContractCheck) {
|
||||
this.orchestrationContractCheck = this.checkOrchestrationContractCompatibility(timeoutMs)
|
||||
@@ -229,8 +201,7 @@ export class RuntimeClient {
|
||||
private async checkOrchestrationContractCompatibility(timeoutMs: number): Promise<void> {
|
||||
const response = await this.call<RuntimeStatus>('status.get', undefined, { timeoutMs })
|
||||
if (this.remotePairing) {
|
||||
this.assertRemoteRuntimeStatusCompatible(response.result)
|
||||
this.remoteCompatChecked = true
|
||||
this.remoteCompat.noteVerifiedStatus(response.result)
|
||||
}
|
||||
if (!response.result.capabilities?.includes(ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY)) {
|
||||
throw new RuntimeClientError(
|
||||
@@ -241,19 +212,6 @@ export class RuntimeClient {
|
||||
}
|
||||
}
|
||||
|
||||
private assertRemoteRuntimeStatusCompatible(status: RuntimeStatus): void {
|
||||
const verdict = evaluateRuntimeCompat({
|
||||
clientProtocolVersion: RUNTIME_PROTOCOL_VERSION,
|
||||
minCompatibleServerProtocolVersion: MIN_COMPATIBLE_RUNTIME_SERVER_VERSION,
|
||||
serverProtocolVersion: status.runtimeProtocolVersion ?? status.protocolVersion,
|
||||
serverMinCompatibleClientProtocolVersion:
|
||||
status.minCompatibleRuntimeClientVersion ?? status.minCompatibleMobileVersion
|
||||
})
|
||||
if (verdict.kind === 'blocked') {
|
||||
throw new RuntimeClientError('incompatible_runtime', describeRuntimeCompatBlock(verdict))
|
||||
}
|
||||
}
|
||||
|
||||
async openOrca(timeoutMs = 15_000): Promise<RuntimeRpcSuccess<CliStatusResult>> {
|
||||
const initial = await this.getCliStatus()
|
||||
if (this.remotePairing) {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { PairingOffer } from '../../shared/pairing'
|
||||
import { describeRuntimeCompatBlock, evaluateRuntimeCompat } from '../../shared/protocol-compat'
|
||||
import {
|
||||
MIN_COMPATIBLE_RUNTIME_SERVER_VERSION,
|
||||
RUNTIME_PROTOCOL_VERSION
|
||||
} from '../../shared/protocol-version'
|
||||
import type { RuntimeOrchestrationEnvelope } from '../../shared/runtime-rpc-envelope'
|
||||
import type { RuntimeStatus } from '../../shared/runtime-types'
|
||||
import { markEnvironmentUsed } from './environments'
|
||||
import { RuntimeClientError, RuntimeRpcFailureError, type RuntimeRpcResponse } from './types'
|
||||
import type {
|
||||
sendWebSocketRequest,
|
||||
sendWebSocketRequestWithStatusPreflight
|
||||
} from './websocket-transport'
|
||||
|
||||
type WebSocketTransport = {
|
||||
sendWebSocketRequest: typeof sendWebSocketRequest
|
||||
sendWebSocketRequestWithStatusPreflight: typeof sendWebSocketRequestWithStatusPreflight
|
||||
}
|
||||
|
||||
export class RemoteRuntimeCompatGate {
|
||||
private checked = false
|
||||
|
||||
constructor(
|
||||
private readonly userDataPath: string,
|
||||
private readonly environmentSelector: string | null
|
||||
) {}
|
||||
|
||||
send<TResult>(args: {
|
||||
transport: WebSocketTransport
|
||||
pairing: PairingOffer
|
||||
method: string
|
||||
params: unknown
|
||||
timeoutMs: number
|
||||
envelope?: RuntimeOrchestrationEnvelope
|
||||
}): Promise<RuntimeRpcResponse<TResult>> {
|
||||
if (this.checked || args.method === 'status.get') {
|
||||
return args.transport.sendWebSocketRequest<TResult>(
|
||||
args.pairing,
|
||||
args.method,
|
||||
args.params,
|
||||
args.timeoutMs,
|
||||
args.envelope
|
||||
)
|
||||
}
|
||||
return args.transport.sendWebSocketRequestWithStatusPreflight<TResult>(
|
||||
args.pairing,
|
||||
args.method,
|
||||
args.params,
|
||||
args.timeoutMs,
|
||||
(response) => {
|
||||
if (response.ok === false) {
|
||||
throw new RuntimeRpcFailureError(response)
|
||||
}
|
||||
this.noteVerifiedStatus(response.result)
|
||||
if (this.environmentSelector) {
|
||||
markEnvironmentUsed(this.userDataPath, this.environmentSelector, {
|
||||
runtimeId: response._meta.runtimeId
|
||||
})
|
||||
}
|
||||
},
|
||||
args.envelope
|
||||
)
|
||||
}
|
||||
|
||||
noteVerifiedStatus(status: RuntimeStatus): void {
|
||||
const verdict = evaluateRuntimeCompat({
|
||||
clientProtocolVersion: RUNTIME_PROTOCOL_VERSION,
|
||||
minCompatibleServerProtocolVersion: MIN_COMPATIBLE_RUNTIME_SERVER_VERSION,
|
||||
serverProtocolVersion: status.runtimeProtocolVersion ?? status.protocolVersion,
|
||||
serverMinCompatibleClientProtocolVersion:
|
||||
status.minCompatibleRuntimeClientVersion ?? status.minCompatibleMobileVersion
|
||||
})
|
||||
if (verdict.kind === 'blocked') {
|
||||
throw new RuntimeClientError('incompatible_runtime', describeRuntimeCompatBlock(verdict))
|
||||
}
|
||||
this.checked = true
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,8 @@ type TestRuntime = {
|
||||
publicKeyB64: string
|
||||
deviceToken: string
|
||||
authFrames: Record<string, unknown>[]
|
||||
requestMethods: string[]
|
||||
connectionCount: () => number
|
||||
close: () => Promise<void>
|
||||
}
|
||||
|
||||
@@ -168,6 +170,30 @@ describe('CLI remote WebSocket transport', () => {
|
||||
code: 'incompatible_runtime',
|
||||
message: expect.stringContaining('server is too old')
|
||||
})
|
||||
expect(runtime.connectionCount()).toBe(1)
|
||||
expect(runtime.authFrames).toHaveLength(1)
|
||||
expect(runtime.requestMethods).toEqual(['status.get'])
|
||||
})
|
||||
|
||||
it('preflights and dispatches through one authenticated connection', async () => {
|
||||
const runtime = await startTestRuntime('runtime-single-auth')
|
||||
servers.push(runtime)
|
||||
const client = new RuntimeClient(
|
||||
'/tmp/unused',
|
||||
5_000,
|
||||
encodePairingOffer({
|
||||
v: 2,
|
||||
endpoint: runtime.endpoint,
|
||||
deviceToken: runtime.deviceToken,
|
||||
publicKeyB64: runtime.publicKeyB64
|
||||
})
|
||||
)
|
||||
|
||||
await expect(client.call('repo.list')).rejects.toMatchObject({ code: 'method_not_found' })
|
||||
|
||||
expect(runtime.connectionCount()).toBe(1)
|
||||
expect(runtime.authFrames).toHaveLength(1)
|
||||
expect(runtime.requestMethods).toEqual(['status.get', 'repo.list'])
|
||||
})
|
||||
|
||||
it('blocks orchestration mutations when a remote runtime lacks the contract capability', async () => {
|
||||
@@ -214,8 +240,11 @@ async function startTestRuntime(
|
||||
const httpServer = createServer()
|
||||
const wss = new WebSocketServer({ server: httpServer })
|
||||
const authFrames: Record<string, unknown>[] = []
|
||||
const requestMethods: string[] = []
|
||||
let connectionCount = 0
|
||||
|
||||
wss.on('connection', (ws) => {
|
||||
connectionCount += 1
|
||||
let sharedKey: Uint8Array | null = null
|
||||
let authenticated = false
|
||||
|
||||
@@ -254,6 +283,7 @@ async function startTestRuntime(
|
||||
}
|
||||
|
||||
const request = JSON.parse(plaintext) as { id: string; method: string }
|
||||
requestMethods.push(request.method)
|
||||
const response =
|
||||
request.method === 'status.get'
|
||||
? {
|
||||
@@ -299,6 +329,8 @@ async function startTestRuntime(
|
||||
publicKeyB64: publicKeyToBase64(serverKeyPair.publicKey),
|
||||
deviceToken,
|
||||
authFrames,
|
||||
requestMethods,
|
||||
connectionCount: () => connectionCount,
|
||||
close: async () => {
|
||||
await new Promise<void>((resolve) => {
|
||||
wss.close(() => resolve())
|
||||
|
||||
@@ -2,8 +2,10 @@ import type { PairingOffer } from '../../shared/pairing'
|
||||
import type { RuntimeOrchestrationEnvelope } from '../../shared/runtime-rpc-envelope'
|
||||
import {
|
||||
RemoteRuntimeClientError,
|
||||
sendRemoteRuntimeRequest
|
||||
sendRemoteRuntimeRequest,
|
||||
sendRemoteRuntimeRequestWithStatusPreflight
|
||||
} from '../../shared/remote-runtime-client'
|
||||
import type { RuntimeStatus } from '../../shared/runtime-types'
|
||||
import { RuntimeClientError, type RuntimeRpcResponse } from './types'
|
||||
|
||||
export async function sendWebSocketRequest<TResult>(
|
||||
@@ -22,3 +24,28 @@ export async function sendWebSocketRequest<TResult>(
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendWebSocketRequestWithStatusPreflight<TResult>(
|
||||
pairing: PairingOffer,
|
||||
method: string,
|
||||
params: unknown,
|
||||
timeoutMs: number,
|
||||
validateStatus: (response: RuntimeRpcResponse<RuntimeStatus>) => void,
|
||||
envelope?: RuntimeOrchestrationEnvelope
|
||||
): Promise<RuntimeRpcResponse<TResult>> {
|
||||
try {
|
||||
return await sendRemoteRuntimeRequestWithStatusPreflight<TResult>(
|
||||
pairing,
|
||||
method,
|
||||
params,
|
||||
timeoutMs,
|
||||
validateStatus,
|
||||
envelope
|
||||
)
|
||||
} catch (error) {
|
||||
if (error instanceof RemoteRuntimeClientError) {
|
||||
throw new RuntimeClientError(error.code, error.message)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user