mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 16:03:20 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/fix-10385-false-connected
# Conflicts: # mobile/app/index.tsx
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
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
Monitor and steer your agents from your phone — get notified when an agent finishes and send follow-ups from anywhere.
|
||||
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.36](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.36/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.37](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
|
||||
Pair with your desktop app to monitor and steer your agents from your phone.
|
||||
|
||||
- **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) or [join TestFlight](https://testflight.apple.com/join/YjeGMQBA)
|
||||
- **Android:** [Download APK 0.0.36](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.36/app-release.apk)
|
||||
- **Android:** [Download APK 0.0.37](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.37/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -26,6 +26,23 @@ const PLAIN_NODE_ENTRY_NAMES = [
|
||||
'codex/codex-app-server-grant-entry'
|
||||
] as const
|
||||
|
||||
// Entries executed as worker threads of the main process. Electron's module is
|
||||
// not registered on worker threads, so require("electron") throws
|
||||
// "Cannot find module 'electron'" there too (verified on Electron 43) and kills
|
||||
// the worker at startup. These carry hand-written "must stay electron-free"
|
||||
// comments, which is convention, not enforcement — and the port-scan worker in
|
||||
// particular sits one import away from a client module that deliberately does
|
||||
// require electron.
|
||||
const WORKER_THREAD_ENTRY_NAMES = [
|
||||
'stt-worker',
|
||||
'warp-theme-parser-worker',
|
||||
'session-scanner-opencode-sqlite-worker-entry',
|
||||
'main-thread-hang-watchdog-entry',
|
||||
'port-scan-command-worker-entry'
|
||||
] as const
|
||||
|
||||
type EntryRuntime = 'plain-Node process' | 'worker thread'
|
||||
|
||||
const ELECTRON_REQUIRE_RE = /require\(\s*["']electron["']\s*\)/
|
||||
|
||||
function collectReachableChunks(
|
||||
@@ -56,13 +73,14 @@ function collectReachableChunks(
|
||||
function assertNoElectronRequire(
|
||||
entryName: string,
|
||||
entry: OutputChunk,
|
||||
byFileName: Map<string, OutputChunk>
|
||||
byFileName: Map<string, OutputChunk>,
|
||||
runtime: EntryRuntime = 'plain-Node process'
|
||||
): void {
|
||||
for (const chunk of collectReachableChunks(entry, byFileName)) {
|
||||
if (ELECTRON_REQUIRE_RE.test(chunk.code)) {
|
||||
throw new Error(
|
||||
`[plain-node-entry-guard] "${entryName}" reaches chunk "${chunk.fileName}" that ` +
|
||||
`requires electron. "${entryName}" runs as a plain-Node process, where ` +
|
||||
`requires electron. "${entryName}" runs as a ${runtime}, where ` +
|
||||
`require("electron") throws MODULE_NOT_FOUND and kills it at startup (the ` +
|
||||
`v1.4.129-rc.1 daemon outage). Keep electron imports out of its module graph.`
|
||||
)
|
||||
@@ -125,7 +143,14 @@ export function createPlainNodeEntryGuardPlugin(): Plugin {
|
||||
for (const entryName of PLAIN_NODE_ENTRY_NAMES) {
|
||||
const entry = entryByName.get(entryName)
|
||||
if (entry) {
|
||||
assertNoElectronRequire(entryName, entry, byFileName)
|
||||
assertNoElectronRequire(entryName, entry, byFileName, 'plain-Node process')
|
||||
}
|
||||
}
|
||||
|
||||
for (const entryName of WORKER_THREAD_ENTRY_NAMES) {
|
||||
const entry = entryByName.get(entryName)
|
||||
if (entry) {
|
||||
assertNoElectronRequire(entryName, entry, byFileName, 'worker thread')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"src/main/speech/stt-worker.ts",
|
||||
"src/main/warp-themes/warp-theme-parser-worker.ts",
|
||||
"src/main/ai-vault/session-scanner-opencode-sqlite-worker-entry.ts",
|
||||
"src/main/ports/port-scan-command-worker-entry.ts",
|
||||
"src/main/ipc/parcel-watcher-process-entry.ts",
|
||||
"src/main/hang-watchdog/main-thread-hang-watchdog-entry.ts",
|
||||
"src/main/codex/codex-app-server-grant-entry.ts",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -14,6 +14,21 @@ import { describe, expect, it } from 'vitest'
|
||||
|
||||
const itCrossHost = process.platform === 'win32' ? it.skip : it
|
||||
const projectRoot = resolve(import.meta.dirname, '../..')
|
||||
const WINDOWS_LOCK_CODES = ['EBUSY', 'ENOTEMPTY', 'EPERM']
|
||||
|
||||
// Why: Windows releases the image handle on a just-executed exe (and finishes the
|
||||
// AV scan of the freshly compiled one) after the process exits, so tearing down the
|
||||
// fixture races those locks. Retry, then leave the temp tree rather than reporting a
|
||||
// teardown lock as a launcher failure.
|
||||
function removeFixtureTree(path) {
|
||||
try {
|
||||
rmSync(path, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })
|
||||
} catch (error) {
|
||||
if (process.platform !== 'win32' || !WINDOWS_LOCK_CODES.includes(error?.code)) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
// Why: cold csc.exe startup exceeds Vitest's 5s unit budget on hosted Windows;
|
||||
// keep the larger allowance scoped to the real compiler integration test.
|
||||
function itWindows(name, test) {
|
||||
@@ -35,7 +50,7 @@ describe('Windows CLI launcher', () => {
|
||||
expect(result.stderr).toContain('Windows CLI launcher')
|
||||
expect(result.stderr).toContain('Windows host')
|
||||
} finally {
|
||||
rmSync(outputRoot, { recursive: true, force: true })
|
||||
removeFixtureTree(outputRoot)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -108,7 +123,7 @@ describe('Windows CLI launcher', () => {
|
||||
orcaNodeOptions: '--no-warnings'
|
||||
})
|
||||
} finally {
|
||||
rmSync(appRoot, { recursive: true, force: true })
|
||||
removeFixtureTree(appRoot)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -161,7 +176,7 @@ describe('Windows CLI launcher', () => {
|
||||
pathKeys: ['PATH', 'Path']
|
||||
})
|
||||
} finally {
|
||||
rmSync(appRoot, { recursive: true, force: true })
|
||||
removeFixtureTree(appRoot)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -134,5 +134,30 @@ export const CROSS_LOCALE_KEY_OVERRIDES = {
|
||||
'auto.hooks.useSettingsNavigationMetadata.40d80bad8a': {
|
||||
zh: '测试版',
|
||||
ja: 'ベータ'
|
||||
},
|
||||
// Issue/PR state picker; it sits beside 已关闭, so 进行中 is a different state.
|
||||
'auto.components.PullRequestPage.7b8f6bf6d8': {
|
||||
zh: '开放'
|
||||
},
|
||||
// This "Open" is the button that opens the config file, not an issue state.
|
||||
'auto.components.settings.McpConfigFileRow.e720c139cd': {
|
||||
zh: '打开'
|
||||
},
|
||||
// Terminal cursor-color group: the on-screen cursor, not the Cursor editor.
|
||||
'auto.components.settings.TerminalWindowSection.c9e1fdf42f': {
|
||||
ko: '커서',
|
||||
zh: '光标'
|
||||
},
|
||||
'auto.components.onboarding.ThemeStep.ab2a583a97': {
|
||||
ko: '커서',
|
||||
zh: '光标'
|
||||
},
|
||||
// Tailwind swatch labels: bare 天空 (the sky), 锌 and 空 (the metal, the sky) are not colors.
|
||||
'auto.components.sidebar.workspace.status.6437a8c253': {
|
||||
zh: '天蓝',
|
||||
ja: '空色'
|
||||
},
|
||||
'auto.components.sidebar.workspace.status.caabd5ca85': {
|
||||
zh: '锌灰'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// Ordinary UI vocabulary that reads like a brand but is not one. Every locale should translate
|
||||
// these, so the policy must neither pin the whole value to English nor revert the translation
|
||||
// inside a sentence. The product-name homonyms (Continue.dev, the agent entries) stay Latin
|
||||
// through ENGLISH_ONLY_KEY_PREFIXES, which matches on the catalog key rather than the word.
|
||||
|
||||
// Renderings a locale is expected to use. They are translations, never machine-translation
|
||||
// errors, so a brand revert must leave them alone. Nonsense forms that share the same English
|
||||
// term (zh 回购 "repurchase agreement", ja 端子 "electrical connector", es "Comprometerse")
|
||||
// stay in BRAND_MISTRANSLATIONS and keep getting reverted.
|
||||
const GENERIC_TERM_FAMILIES = [
|
||||
{
|
||||
terms: ['Agent', 'Agents', 'agent', 'agents'],
|
||||
renderings: {
|
||||
ko: ['에이전트'],
|
||||
ja: ['エージェント'],
|
||||
zh: ['代理', '智能体'],
|
||||
es: ['Agente', 'agente', 'Agentes', 'agentes']
|
||||
}
|
||||
},
|
||||
{
|
||||
terms: ['Commit', 'Commits', 'commit', 'commits'],
|
||||
renderings: {
|
||||
ko: ['커밋'],
|
||||
ja: ['コミット'],
|
||||
zh: ['提交'],
|
||||
es: [
|
||||
'Confirmación',
|
||||
'confirmación',
|
||||
'Confirmaciones',
|
||||
'confirmaciones',
|
||||
'Confirmar',
|
||||
'confirmar'
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
terms: ['Continue'],
|
||||
renderings: {
|
||||
ko: ['계속하다', '계속'],
|
||||
ja: ['続ける', '続行'],
|
||||
zh: ['继续'],
|
||||
es: ['Continuar', 'continuar']
|
||||
}
|
||||
},
|
||||
{
|
||||
terms: ['Repo', 'Repos', 'repo', 'repos'],
|
||||
renderings: {
|
||||
ko: ['저장소', '레포'],
|
||||
ja: ['リポジトリ', 'リポ'],
|
||||
zh: ['存储库', '仓库'],
|
||||
es: ['Repositorio', 'repositorio', 'Repositorios', 'repositorios']
|
||||
}
|
||||
},
|
||||
{
|
||||
terms: ['Terminal', 'Terminals', 'terminal', 'terminals'],
|
||||
renderings: {
|
||||
ko: ['터미널'],
|
||||
ja: ['ターミナル'],
|
||||
zh: ['终端'],
|
||||
es: ['Terminales', 'terminales']
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
export const LOCALIZABLE_GENERIC_TERMS = new Set(
|
||||
GENERIC_TERM_FAMILIES.flatMap((family) => family.terms)
|
||||
)
|
||||
|
||||
const RENDERINGS_BY_TERM = new Map(
|
||||
GENERIC_TERM_FAMILIES.flatMap((family) => family.terms.map((term) => [term, family.renderings]))
|
||||
)
|
||||
|
||||
export function isLocalizableGenericTerm(term) {
|
||||
return LOCALIZABLE_GENERIC_TERMS.has(term)
|
||||
}
|
||||
|
||||
export function isCanonicalGenericRendering(term, locale, form) {
|
||||
return RENDERINGS_BY_TERM.get(term)?.[locale]?.includes(form) ?? false
|
||||
}
|
||||
|
||||
export function canonicalGenericRenderings(locale) {
|
||||
return GENERIC_TERM_FAMILIES.flatMap((family) =>
|
||||
(family.renderings[locale] ?? []).map((form) => ({ form, terms: family.terms }))
|
||||
)
|
||||
}
|
||||
|
||||
function spansOf(value, needle) {
|
||||
const spans = []
|
||||
for (let at = value.indexOf(needle); at !== -1; at = value.indexOf(needle, at + 1)) {
|
||||
spans.push([at, at + needle.length])
|
||||
}
|
||||
return spans
|
||||
}
|
||||
|
||||
// Why: zh 终端子进程 ("terminal sub-process") contains 端子, so a blind revert of the
|
||||
// electrical-connector mistranslation would cut a valid word in half.
|
||||
export function overlapsCanonicalRendering(term, locale, value, start, end) {
|
||||
const renderings = RENDERINGS_BY_TERM.get(term)?.[locale] ?? []
|
||||
return renderings.some((form) =>
|
||||
spansOf(value, form).some(([from, to]) => start < to && from < end)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { LOCALIZABLE_GENERIC_TERMS } from './locale-generic-ui-terms.mjs'
|
||||
import { NEVER_TRANSLATE_VALUES, repairTranslatedValue } from './locale-translation-policy.mjs'
|
||||
|
||||
// Why: #12113 — repair-locale-catalog rewrote ~2000 translated values back to English because
|
||||
// generic UI words (agent, terminal, commit, repo, Continue) were treated as brands. These pin
|
||||
// the boundary: generic words stay translated, real brand names stay Latin.
|
||||
describe('locale generic UI terms', () => {
|
||||
it('keeps a translated generic term when the whole value is that term', () => {
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.settings.AppearancePane.terminalTitle',
|
||||
enValue: 'Terminal',
|
||||
localeValue: '터미널',
|
||||
locale: 'ko'
|
||||
})
|
||||
).toBe('터미널')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.settings.QuickCommandsPane.4ccc63da87',
|
||||
enValue: 'Agent',
|
||||
localeValue: '에이전트',
|
||||
locale: 'ko'
|
||||
})
|
||||
).toBe('에이전트')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.mobile.MobileHero.a8fb43cf1c',
|
||||
enValue: 'Continue',
|
||||
localeValue: '继续',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('继续')
|
||||
})
|
||||
|
||||
it('keeps a translated generic term inside a sentence', () => {
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.settings.EditorFontSection.matchTerminal',
|
||||
enValue: 'Match terminal font',
|
||||
localeValue: '터미널 글꼴과 동일',
|
||||
locale: 'ko'
|
||||
})
|
||||
).toBe('터미널 글꼴과 동일')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.settings.ExperimentalPane.agentDashboard.toggleLabel',
|
||||
enValue: 'Toggle Agent Dashboard',
|
||||
localeValue: '에이전트 대시보드 전환',
|
||||
locale: 'ko'
|
||||
})
|
||||
).toBe('에이전트 대시보드 전환')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'components.agentSessionContinuation.dialogTitle',
|
||||
enValue: 'Continue in New Session',
|
||||
localeValue: '新しいセッションで続ける',
|
||||
locale: 'ja'
|
||||
})
|
||||
// 新しい → 新規 is an unrelated phrase fix; 続ける is what must survive.
|
||||
).toBe('新規セッションで続ける')
|
||||
})
|
||||
|
||||
it('still reverts real brand names that machine translation localized', () => {
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.stats.StatsPane.7d26110cea',
|
||||
enValue: 'Codex',
|
||||
localeValue: '사본',
|
||||
locale: 'ko'
|
||||
})
|
||||
).toBe('Codex')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.settings.appearance.search.9ae151b26b',
|
||||
enValue: 'linear',
|
||||
localeValue: '线性',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('Linear')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.settings.NetworkPane.tailscale',
|
||||
enValue: 'Connect over Tailscale',
|
||||
localeValue: '通过尾鳞连接',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('通过 Tailscale 连接')
|
||||
})
|
||||
|
||||
it('still reverts nonsense renderings of a generic term', () => {
|
||||
// 端子 is an electrical connector, 回购 a repurchase agreement, Comprometerse "to pledge".
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.agent.AgentCombobox.986f946354',
|
||||
enValue: 'Blank Terminal',
|
||||
localeValue: '空白端子',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('空白 Terminal')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.right.sidebar.source.control.primary.action.ed93b4f14f',
|
||||
enValue: 'Commit',
|
||||
localeValue: 'Comprometerse',
|
||||
locale: 'es'
|
||||
})
|
||||
).toBe('Commit')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.LinearIssueMarkdownDescriptionEditor.d9c47069ef',
|
||||
enValue: 'Markdown',
|
||||
localeValue: '가격 인하',
|
||||
locale: 'ko'
|
||||
})
|
||||
).toBe('Markdown')
|
||||
})
|
||||
|
||||
it('does not cut a valid word that contains a nonsense rendering', () => {
|
||||
// 终端子进程 is "terminal sub-process" — 端子 sits inside 终端 + 子.
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.settings.AdvancedNetworkSettingsSection.823e0f15b1',
|
||||
enValue: 'Proxy URL for Orca network requests and local terminal subprocesses.',
|
||||
localeValue: '用于 Orca 网络请求和本地终端子进程的代理 URL。',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('用于 Orca 网络请求和本地终端子进程的代理 URL。')
|
||||
})
|
||||
|
||||
it('still fills in a translation when the catalog holds English', () => {
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.feature.wall.BrowserAnimatedVisual.04096318ab',
|
||||
enValue: 'Terminal 1',
|
||||
localeValue: 'Terminal 1',
|
||||
locale: 'ko'
|
||||
})
|
||||
).toBe('터미널 1')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.store.slices.worktrees.889487d8bb',
|
||||
enValue: 'Dismiss',
|
||||
localeValue: '해고하다',
|
||||
locale: 'ko'
|
||||
})
|
||||
).toBe('닫기')
|
||||
})
|
||||
|
||||
it('keeps agent catalog product names in English by key, not by word', () => {
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.lib.agent.catalog.9e2a9bb87b',
|
||||
enValue: 'Continue',
|
||||
localeValue: '继续',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('Continue')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.lib.agent.catalog.760bc6883d',
|
||||
enValue: 'Codex',
|
||||
localeValue: '사본',
|
||||
locale: 'ko'
|
||||
})
|
||||
).toBe('Codex')
|
||||
})
|
||||
|
||||
it('does not list a generic term as never-translatable', () => {
|
||||
const pinned = [...LOCALIZABLE_GENERIC_TERMS].filter((term) => NEVER_TRANSLATE_VALUES.has(term))
|
||||
expect(pinned).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -94,7 +94,7 @@ export const JA_VALUE_OVERRIDES = {
|
||||
'Launch plan': '起動プラン',
|
||||
'Launch agent': 'エージェントを起動',
|
||||
'Launch {{value0}} in a new terminal': '新規ターミナルで {{value0}} を起動',
|
||||
Play: 'Play',
|
||||
Play: '再生',
|
||||
Action: '操作',
|
||||
Actions: '操作',
|
||||
action: '操作',
|
||||
@@ -135,7 +135,7 @@ export const JA_VALUE_OVERRIDES = {
|
||||
'Pick a base branch below': '以下のベースブランチを選択',
|
||||
'Choose floating workspace directory': 'フローティング ワークスペース ディレクトリを選択',
|
||||
'Local project, Git repo, or folder with many repos':
|
||||
'ローカルプロジェクト、Git repo、または多数の repos を含むフォルダー',
|
||||
'ローカルプロジェクト、Git リポジトリ、または多数のリポジトリを含むフォルダー',
|
||||
'Enter passphrase': 'パスフレーズを入力',
|
||||
'Enter password': 'パスワードを入力',
|
||||
'Enter the passphrase for': 'のパスフレーズを入力',
|
||||
|
||||
@@ -162,8 +162,8 @@ const BASE_LOCALE_KEY_OVERRIDES = {
|
||||
ja: 'クローズ'
|
||||
},
|
||||
'auto.components.GitHubItemDialog.dc1ca081a8': {
|
||||
ko: '진행 중',
|
||||
zh: '进行中',
|
||||
ko: '열림',
|
||||
zh: '开放',
|
||||
ja: 'オープン'
|
||||
},
|
||||
'auto.components.tab.bar.TabBarCreateEntry.b27864279e': {
|
||||
@@ -176,10 +176,11 @@ const BASE_LOCALE_KEY_OVERRIDES = {
|
||||
zh: '打开 Linear 任务',
|
||||
ja: 'Linear タスクを開く'
|
||||
},
|
||||
// Onboarding pill beside Orca Mobile: "New" marks a new feature, not a create action.
|
||||
'auto.components.sidebar.SidebarNav.c86d83b5c3': {
|
||||
ko: '새로 만들기',
|
||||
zh: '新建',
|
||||
ja: '新規'
|
||||
ko: '신규',
|
||||
zh: '新功能',
|
||||
ja: '新機能'
|
||||
},
|
||||
'auto.components.sidebar.SidebarSettingsHelpMenu.eb9884e55b': {
|
||||
ko: 'Discord',
|
||||
|
||||
@@ -752,6 +752,21 @@
|
||||
"auto.components.contextual.tours.ContextualTourProgressDots.dcd6e6b03e": {
|
||||
"ko": "{{value1}}단계 중 {{value0}}단계"
|
||||
},
|
||||
"auto.components.contextual.tours.ContextualTourOverlaySurface.complete": {
|
||||
"ko": "완료"
|
||||
},
|
||||
"auto.components.contextual.tours.contextual.tour.overlay.measurement.automations.intro.body": {
|
||||
"ko": "자동화는 일정에 따라 agent 작업을 실행합니다. 이 버튼을 눌러 자동화를 추가하세요."
|
||||
},
|
||||
"auto.components.contextual.tours.contextual.tour.overlay.measurement.automations.intro.title": {
|
||||
"ko": "자동화란 무엇인가요?"
|
||||
},
|
||||
"auto.components.contextual.tours.contextual.tour.overlay.measurement.automations.results.body": {
|
||||
"ko": "실행 내역에서 자동화가 언제 실행되었는지, 어떤 일이 발생했는지, 출력을 어디서 확인할 수 있는지 볼 수 있습니다."
|
||||
},
|
||||
"auto.components.contextual.tours.contextual.tour.overlay.measurement.automations.results.title": {
|
||||
"ko": "결과 확인"
|
||||
},
|
||||
"auto.components.crash.report.CrashReportDialog.88fea8e84e": {
|
||||
"ko": "보내지 않음"
|
||||
},
|
||||
@@ -1868,6 +1883,9 @@
|
||||
"auto.components.right.sidebar.SourceControl.3a231c845b": {
|
||||
"ko": "알 수 없음"
|
||||
},
|
||||
"auto.components.right.sidebar.SourceControl.6d7f2a47e5": {
|
||||
"ko": "폴더의 변경 사항 취소"
|
||||
},
|
||||
"auto.components.right.sidebar.SourceControl.812cb992ee": {
|
||||
"ko": "{{value0}}에서 열기"
|
||||
},
|
||||
@@ -1880,6 +1898,9 @@
|
||||
"auto.components.right.sidebar.SourceControl.9bb062a886": {
|
||||
"ko": "commit 되지 않음"
|
||||
},
|
||||
"auto.components.right.sidebar.SourceControl.a5e5a11090": {
|
||||
"ko": "모든 변경 사항 취소 실패 — 취소 전에 파일의 스테이징을 해제하지 못했습니다."
|
||||
},
|
||||
"auto.components.right.sidebar.SourceControl.aaf1451654": {
|
||||
"ko": "초안 {{value0}} 만들기"
|
||||
},
|
||||
@@ -1994,6 +2015,9 @@
|
||||
"auto.components.right.sidebar.source.control.discard.confirmation.2ae5a785b3": {
|
||||
"ko": "스테이징되지 않은 변경 사항을 모두 삭제하시겠습니까?"
|
||||
},
|
||||
"auto.components.right.sidebar.source.control.discard.confirmation.40e9357b2a": {
|
||||
"ko": "이렇게 하면 HEAD에서 파일을 복원하고 파일 삭제를 취소합니다. 이 작업은 취소할 수 없습니다."
|
||||
},
|
||||
"auto.components.right.sidebar.source.control.discard.confirmation.5ddd8cac7f": {
|
||||
"ko": "스테이징된 변경 사항을 모두 삭제하시겠습니까?"
|
||||
},
|
||||
@@ -2015,6 +2039,9 @@
|
||||
"auto.components.right.sidebar.source.control.primary.action.1d47e850cf": {
|
||||
"ko": "연결된 리뷰 브랜치에 업데이트 푸시"
|
||||
},
|
||||
"auto.components.right.sidebar.source.control.primary.action.5a477d80cb": {
|
||||
"ko": "모든 변경 사항 스테이징"
|
||||
},
|
||||
"auto.components.right.sidebar.source.control.primary.action.a5d1f7b036": {
|
||||
"ko": "{{value0}}을(를) 만들기 전에 commits을 게시하세요."
|
||||
},
|
||||
@@ -2162,6 +2189,9 @@
|
||||
"auto.components.settings.AutoRenameBranchFromWorkSetting.a4fa380b67": {
|
||||
"ko": "{assistantMessage}"
|
||||
},
|
||||
"auto.components.settings.AutoRenameBranchFromWorkSetting.d9b65054ef": {
|
||||
"ko": ") 작업을 요약하는 짧은 이름으로 변경됩니다. Orca가 직접 이름 붙인 브랜치만 이름을 바꾸며, 푸시된 후에는 이름을 바꾸지 않습니다."
|
||||
},
|
||||
"auto.components.settings.AutoRenameBranchPromptEditor.ebb942a2ec": {
|
||||
"ko": "fix-login-flow"
|
||||
},
|
||||
@@ -2228,6 +2258,9 @@
|
||||
"auto.components.settings.DefaultWindowsProjectRuntimeSetting.wslUnavailable": {
|
||||
"ko": "WSL을 사용할 수 없습니다. WSL을 상속하는 프로젝트는 복구가 필요합니다."
|
||||
},
|
||||
"auto.components.settings.DevToolsPane.orcaCloudDescription": {
|
||||
"ko": "자사 클라우드 로그인의 개발자 전용 미리보기. 프로덕션에서는 숨겨집니다. 개발 환경에서는 ORCA_CLOUD_API_URL과 ORCA_CLOUD_CLIENT_ID가 설정되면 사이드바 계정 전환기에도 표시됩니다."
|
||||
},
|
||||
"auto.components.settings.DeveloperPermissionsPane.16381e040a": {
|
||||
"ko": "마이크"
|
||||
},
|
||||
@@ -2255,6 +2288,12 @@
|
||||
"auto.components.settings.ExperimentalPane.newWorktreeCardStyle.description": {
|
||||
"ko": "업데이트된 워크트리 카드 레이아웃, 메타데이터 배치, 카드 표시 메뉴 옵션 및 상태 표시를 미리 봅니다."
|
||||
},
|
||||
"auto.components.settings.EphemeralVmsPane.recipes": {
|
||||
"ko": "레시피"
|
||||
},
|
||||
"auto.components.settings.EphemeralVmsPane.whatTitle": {
|
||||
"ko": "이 스킬로 함께 하는 작업"
|
||||
},
|
||||
"auto.components.settings.GeneralEditorSettingsSection.45c6e85c4d": {
|
||||
"ko": "편집기"
|
||||
},
|
||||
@@ -2727,7 +2766,7 @@
|
||||
"ko": "이 SSH 대상의 원격 릴레이를 강제로 중지합니다. 이 대상의 활성 원격 terminals과 포트 포워딩이 종료됩니다."
|
||||
},
|
||||
"auto.components.settings.SshTargetForm.137e88ce8d": {
|
||||
"ko": "연결 해제 후 릴레이가 terminals을 활성 상태로 유지하는 기간입니다. 기본값: 10800(3시간). 최대:"
|
||||
"ko": "Orca가 이 호스트에서 연결 해제된 뒤에도 원격 터미널은 계속 실행됩니다."
|
||||
},
|
||||
"auto.components.settings.SshTargetForm.2ee9bcd2e8": {
|
||||
"ko": "server, deploy@server:2222, ssh://server"
|
||||
@@ -3710,6 +3749,9 @@
|
||||
"auto.components.settings.ssh.search.f9493b80c0": {
|
||||
"ko": "서버"
|
||||
},
|
||||
"auto.components.settings.source.control.action.recipe.options.commitMessage": {
|
||||
"ko": "스테이징된 변경 사항에서 commit 메시지를 생성합니다."
|
||||
},
|
||||
"auto.components.settings.task.tracker.integration.cards.1a12e33fe5": {
|
||||
"ko": "Linear 액세스 추가"
|
||||
},
|
||||
@@ -4683,7 +4725,7 @@
|
||||
"ko": "중지 중"
|
||||
},
|
||||
"auto.components.status.bar.WorkspaceSpaceCompactPanel.8ff597593d": {
|
||||
"ko": "Space"
|
||||
"ko": "저장 공간"
|
||||
},
|
||||
"auto.components.status.bar.WorkspaceSpaceCompactPanel.9be86c46a0": {
|
||||
"ko": "확보 가능"
|
||||
@@ -4953,7 +4995,7 @@
|
||||
"ko": "선택한 리포지토리와 일치하는 비활성 워크스페이스가 없습니다."
|
||||
},
|
||||
"auto.components.workspace.space.WorkspaceSpacePage.45f6302dbc": {
|
||||
"ko": "Space"
|
||||
"ko": "저장 공간"
|
||||
},
|
||||
"auto.components.workspace.space.WorkspaceSpacePage.8d0048e1cb": {
|
||||
"ko": "워크스페이스 디스크 사용량과 회수 가능한 워크트리 저장 공간."
|
||||
|
||||
@@ -209,7 +209,7 @@ export const KO_VALUE_OVERRIDES = {
|
||||
'Install the Orca skill so agents know to use the Orca CLI.':
|
||||
'에이전트가 Orca CLI를 사용하도록 Orca 스킬을 설치하세요.',
|
||||
'Local project, Git repo, or folder with many repos':
|
||||
'로컬 프로젝트, Git repo 또는 repos가 많은 폴더',
|
||||
'로컬 프로젝트, Git 저장소 또는 저장소가 많은 폴더',
|
||||
'Linear, GitLab, Bitbucket, Azure DevOps, Gitea, and Jira live in Settings > Integrations.':
|
||||
'Linear, GitLab, Bitbucket, Azure DevOps, Gitea 및 Jira는 설정 > 연동에 있습니다.',
|
||||
'changed since you last approved. Re-review before it runs':
|
||||
|
||||
@@ -371,9 +371,10 @@ export const LOCALE_PHRASE_FIXES = {
|
||||
{ pattern: /指挥进展/g, replacement: 'Conductor 进度', whenEnIncludes: 'Conductor Progress' },
|
||||
{ pattern: /指挥评论/g, replacement: 'Conductor 评审', whenEnIncludes: 'Conductor Review' },
|
||||
{ pattern: /指挥完成/g, replacement: 'Conductor 完成', whenEnIncludes: 'Conductor Done' },
|
||||
{ pattern: /琥珀色/g, replacement: 'Amber', whenEnIncludes: 'Amber' },
|
||||
{ pattern: /蓝色的/g, replacement: 'Blue', whenEnIncludes: 'Blue' },
|
||||
{ pattern: /中性的/g, replacement: 'Neutral', whenEnIncludes: 'Neutral' },
|
||||
// Swatch labels want the bare color noun, not the adjectival 的 form — and not English.
|
||||
{ pattern: /琥珀色/g, replacement: '琥珀', whenEnIncludes: 'Amber' },
|
||||
{ pattern: /蓝色的/g, replacement: '蓝色', whenEnIncludes: 'Blue' },
|
||||
{ pattern: /中性的/g, replacement: '中性', whenEnIncludes: 'Neutral' },
|
||||
{ pattern: /破坏性的/g, replacement: 'destructive', whenEnIncludes: 'destructive' },
|
||||
{ pattern: /注解/g, replacement: '批注', whenEnIncludes: 'Annotation' },
|
||||
...ZH_PHRASE_FIXES_ROUND5
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
const LOCALIZED_PROSE_TERM_KEYS = new Set([
|
||||
'auto.hooks.useMacosTccPromptNotice.description',
|
||||
'auto.components.settings.DeveloperPermissionsPane.7ca17b62c8'
|
||||
])
|
||||
|
||||
const LOCALIZABLE_PROSE_TERMS = new Set([
|
||||
'Agent',
|
||||
'Agents',
|
||||
'agent',
|
||||
'agents',
|
||||
'Terminal',
|
||||
'Terminals',
|
||||
'terminal',
|
||||
'terminals'
|
||||
])
|
||||
|
||||
export function isLocalizedProseTermContext(term, key) {
|
||||
return LOCALIZED_PROSE_TERM_KEYS.has(key) && LOCALIZABLE_PROSE_TERMS.has(term)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { repairCatalog } from './locale-translation-policy.mjs'
|
||||
|
||||
// Regression: en.json routinely carries keys a locale catalog has not been bootstrapped with yet
|
||||
// (~190 per locale at the time of writing), which crashed the whole repair run before it did any work.
|
||||
describe('repairCatalog with un-bootstrapped keys', () => {
|
||||
const enCatalog = {
|
||||
auto: {
|
||||
lib: { agent: { catalog: { '760bc6883d': 'Codex' } } },
|
||||
components: { untranslated: 'Continue', nested: { alsoMissing: 'Refresh' } }
|
||||
}
|
||||
}
|
||||
|
||||
const translatedOnly = () => ({ auto: { lib: { agent: { catalog: { '760bc6883d': '사본' } } } } })
|
||||
|
||||
it('skips leaves the locale catalog is missing instead of throwing', () => {
|
||||
for (const locale of ['ko', 'ja', 'zh', 'es']) {
|
||||
const localeCatalog = translatedOnly()
|
||||
expect(() => repairCatalog(enCatalog, localeCatalog, locale), locale).not.toThrow()
|
||||
expect(localeCatalog.auto.components, locale).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('still repairs the leaves that are present', () => {
|
||||
const localeCatalog = translatedOnly()
|
||||
expect(repairCatalog(enCatalog, localeCatalog, 'ko')).toBe(1)
|
||||
expect(localeCatalog.auto.lib.agent.catalog['760bc6883d']).toBe('Codex')
|
||||
})
|
||||
})
|
||||
@@ -45,7 +45,7 @@ describe('locale-translation-policy ko round 5', () => {
|
||||
localeValue: '에이전트가 Orca CLI 사용 방법을 알 수 있도록 Orca 기술을 설치합니다.',
|
||||
locale: 'ko'
|
||||
})
|
||||
).toBe('agents가 Orca CLI를 사용하도록 Orca 스킬을 설치하세요.')
|
||||
).toBe('에이전트가 Orca CLI를 사용하도록 Orca 스킬을 설치하세요.')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.editor.MarkdownPreview.322afab6ff',
|
||||
@@ -92,7 +92,8 @@ describe('locale-translation-policy ko round 5', () => {
|
||||
).toBe('파이프라인')
|
||||
})
|
||||
|
||||
it('keeps protected workflow terms in English', () => {
|
||||
// Why: #12113 — brand names stay Latin, but generic workflow nouns keep their Korean.
|
||||
it('keeps brand names English and generic workflow terms translated', () => {
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.feature.wall.BrowserAnimatedVisual.04096318ab',
|
||||
@@ -100,7 +101,7 @@ describe('locale-translation-policy ko round 5', () => {
|
||||
localeValue: '터미널 1',
|
||||
locale: 'ko'
|
||||
})
|
||||
).toBe('Terminal 1')
|
||||
).toBe('터미널 1')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.skills.SkillsPage.38e0951c3a',
|
||||
@@ -108,7 +109,7 @@ describe('locale-translation-policy ko round 5', () => {
|
||||
localeValue: '에이전트 스킬',
|
||||
locale: 'ko'
|
||||
})
|
||||
).toBe('Agent 스킬')
|
||||
).toBe('에이전트 스킬')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.LinearIssueMarkdownDescriptionEditor.d9c47069ef',
|
||||
@@ -124,7 +125,7 @@ describe('locale-translation-policy ko round 5', () => {
|
||||
localeValue: '푸시되지 않은 커밋',
|
||||
locale: 'ko'
|
||||
})
|
||||
).toBe('푸시되지 않은 commits')
|
||||
).toBe('푸시되지 않은 커밋')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.0b1766738a',
|
||||
@@ -132,7 +133,7 @@ describe('locale-translation-policy ko round 5', () => {
|
||||
localeValue: '레포',
|
||||
locale: 'ko'
|
||||
})
|
||||
).toBe('Repo')
|
||||
).toBe('레포')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.sidebar.add.repo.local.start.actions.fb4fc5380e',
|
||||
@@ -140,7 +141,7 @@ describe('locale-translation-policy ko round 5', () => {
|
||||
localeValue: '로컬 프로젝트, Git 저장소 또는 저장소가 많은 폴더',
|
||||
locale: 'ko'
|
||||
})
|
||||
).toBe('로컬 프로젝트, Git repo 또는 repos가 많은 폴더')
|
||||
).toBe('로컬 프로젝트, Git 저장소 또는 저장소가 많은 폴더')
|
||||
})
|
||||
|
||||
it('re-glues Korean particles after Latin terms without gluing content words', () => {
|
||||
|
||||
@@ -3,7 +3,8 @@ import { describe, expect, it } from 'vitest'
|
||||
import { repairTranslatedValue } from './locale-translation-policy.mjs'
|
||||
|
||||
describe('locale-translation-policy es round 5', () => {
|
||||
it('keeps protected workflow terms in English', () => {
|
||||
// Why: #12113 — brand names stay Latin, but generic workflow nouns keep their Spanish.
|
||||
it('keeps brand names English and generic workflow terms translated', () => {
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.LinearIssueMarkdownDescriptionEditor.d9c47069ef',
|
||||
@@ -43,7 +44,7 @@ describe('locale-translation-policy es round 5', () => {
|
||||
localeValue: 'mensaje de confirmación',
|
||||
locale: 'es'
|
||||
})
|
||||
).toBe('mensaje de Commit')
|
||||
).toBe('mensaje de confirmación')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.workspace.cleanup.WorkspaceCleanupDialog.0b1766738a',
|
||||
@@ -51,7 +52,7 @@ describe('locale-translation-policy es round 5', () => {
|
||||
localeValue: 'repositorio',
|
||||
locale: 'es'
|
||||
})
|
||||
).toBe('Repo')
|
||||
).toBe('repositorio')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.sidebar.add.repo.local.start.actions.fb4fc5380e',
|
||||
@@ -59,6 +60,6 @@ describe('locale-translation-policy es round 5', () => {
|
||||
localeValue: 'Proyecto local, repositorio de Git o carpeta con muchos repositorios',
|
||||
locale: 'es'
|
||||
})
|
||||
).toBe('Proyecto local, repo de Git o carpeta con muchos repos')
|
||||
).toBe('Proyecto local, repositorio de Git o carpeta con muchos repositorios')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -27,7 +27,7 @@ describe('locale-translation-policy ja round 5', () => {
|
||||
localeValue: '遊ぶ',
|
||||
locale: 'ja'
|
||||
})
|
||||
).toBe('Play')
|
||||
).toBe('再生')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.TaskPage.8396825a14',
|
||||
@@ -102,7 +102,8 @@ describe('locale-translation-policy ja round 5', () => {
|
||||
).toBe('まずプロジェクトを追加')
|
||||
})
|
||||
|
||||
it('keeps protected workflow terms in English', () => {
|
||||
// Why: #12113 — brand names stay Latin, but generic workflow nouns keep their Japanese.
|
||||
it('keeps brand names English and generic workflow terms translated', () => {
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.status.bar.WorkspaceSpaceManagerPanel.e9528a89b3',
|
||||
@@ -118,7 +119,7 @@ describe('locale-translation-policy ja round 5', () => {
|
||||
localeValue: 'エージェントのスキル',
|
||||
locale: 'ja'
|
||||
})
|
||||
).toBe('Agent のスキル')
|
||||
).toBe('エージェントのスキル')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.tab.bar.TabBar.3d5d6c960d',
|
||||
@@ -134,7 +135,7 @@ describe('locale-translation-policy ja round 5', () => {
|
||||
localeValue: 'コミット',
|
||||
locale: 'ja'
|
||||
})
|
||||
).toBe('commits')
|
||||
).toBe('コミット')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.mobile.slides.WorktreeListSlide.22971156df',
|
||||
@@ -142,7 +143,7 @@ describe('locale-translation-policy ja round 5', () => {
|
||||
localeValue: 'リポ',
|
||||
locale: 'ja'
|
||||
})
|
||||
).toBe('Repo')
|
||||
).toBe('リポ')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.sidebar.add.repo.local.start.actions.fb4fc5380e',
|
||||
@@ -151,6 +152,6 @@ describe('locale-translation-policy ja round 5', () => {
|
||||
'ローカル プロジェクト、Git リポジトリ、または多数のリポジトリを含むフォルダー',
|
||||
locale: 'ja'
|
||||
})
|
||||
).toBe('ローカルプロジェクト、Git repo、または多数の repos を含むフォルダー')
|
||||
).toBe('ローカルプロジェクト、Git リポジトリ、または多数のリポジトリを含むフォルダー')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { CJK_LATIN_SPACED_TERMS } from './locale-cjk-latin-spaced-terms.mjs'
|
||||
import { isLocalizedProseTermContext } from './locale-prose-term-exemptions.mjs'
|
||||
import {
|
||||
isCanonicalGenericRendering,
|
||||
overlapsCanonicalRendering
|
||||
} from './locale-generic-ui-terms.mjs'
|
||||
import { isScreenCursorContext } from './locale-screen-cursor-exemptions.mjs'
|
||||
import { LOCALE_KEY_OVERRIDES } from './locale-key-overrides.mjs'
|
||||
import { LOCALE_PHRASE_FIXES } from './locale-phrase-fixes.mjs'
|
||||
@@ -17,9 +20,10 @@ const OPEN_IN_APP_CATALOG_PREFIX = 'auto.lib.open.in.app.catalog.'
|
||||
// Why: product names and agent labels stay Latin — MT reads them as common words (Codex→copy, Gemini→zodiac).
|
||||
export const ENGLISH_ONLY_KEY_PREFIXES = [AGENT_CATALOG_PREFIX, OPEN_IN_APP_CATALOG_PREFIX]
|
||||
|
||||
// Only genuine brand, product, and code tokens belong here. Ordinary UI words that happen to
|
||||
// name a product (agent, terminal, commit, repo, Continue) live in locale-generic-ui-terms.mjs
|
||||
// and are translated; their product sense is pinned by ENGLISH_ONLY_KEY_PREFIXES instead.
|
||||
export const NEVER_TRANSLATE_VALUES = new Set([
|
||||
'Agent',
|
||||
'Agents',
|
||||
'Aider',
|
||||
'Amp',
|
||||
'Android',
|
||||
@@ -33,7 +37,6 @@ export const NEVER_TRANSLATE_VALUES = new Set([
|
||||
'Codebuff',
|
||||
'Codex',
|
||||
'Command Code',
|
||||
'Continue',
|
||||
'Cursor',
|
||||
'Droid',
|
||||
'Devin',
|
||||
@@ -60,30 +63,16 @@ export const NEVER_TRANSLATE_VALUES = new Set([
|
||||
'Pi',
|
||||
'PostHog',
|
||||
'Qwen Code',
|
||||
'Repo',
|
||||
'Repos',
|
||||
'Rovo Dev',
|
||||
'Commit',
|
||||
'Commits',
|
||||
'Markdown',
|
||||
'Terminal',
|
||||
'Terminals',
|
||||
'VS Code',
|
||||
'Warp',
|
||||
'Zed',
|
||||
'agent',
|
||||
'agents',
|
||||
'android',
|
||||
'codex',
|
||||
'commit',
|
||||
'commits',
|
||||
'gemini',
|
||||
'claude',
|
||||
'markdown',
|
||||
'repo',
|
||||
'repos',
|
||||
'terminal',
|
||||
'terminals',
|
||||
'gh',
|
||||
'idle',
|
||||
'anthropic',
|
||||
@@ -368,6 +357,18 @@ function includesPreservedLatinTerm(value, term) {
|
||||
return new RegExp(`(^|[^A-Za-z_])${escapeRegExp(term)}($|[^A-Za-z_])`).test(value)
|
||||
}
|
||||
|
||||
function replaceMistranslatedForm(value, wrong, brand, locale) {
|
||||
let result = ''
|
||||
let cursor = 0
|
||||
for (let at = value.indexOf(wrong); at !== -1; at = value.indexOf(wrong, cursor)) {
|
||||
const end = at + wrong.length
|
||||
result += value.slice(cursor, at)
|
||||
result += overlapsCanonicalRendering(brand, locale, value, at, end) ? wrong : brand
|
||||
cursor = end
|
||||
}
|
||||
return result + value.slice(cursor)
|
||||
}
|
||||
|
||||
function applyBrandMistranslationFixes(enValue, localeValue, locale, key = '') {
|
||||
let result = localeValue
|
||||
const mistranslations = BRAND_MISTRANSLATIONS[locale] ?? {}
|
||||
@@ -383,9 +384,6 @@ function applyBrandMistranslationFixes(enValue, localeValue, locale, key = '') {
|
||||
if (isScreenCursorContext(brand, enValue, key)) {
|
||||
continue
|
||||
}
|
||||
if (isLocalizedProseTermContext(brand, key)) {
|
||||
continue
|
||||
}
|
||||
if (includesPreservedLatinTerm(result, brand)) {
|
||||
continue
|
||||
}
|
||||
@@ -393,11 +391,16 @@ function applyBrandMistranslationFixes(enValue, localeValue, locale, key = '') {
|
||||
if (!result.includes(wrong)) {
|
||||
continue
|
||||
}
|
||||
// Why: #12113 — a generic term's correct translation is not a mistranslation; reverting it
|
||||
// rewrote ~2000 translated values back to English on every repair run.
|
||||
if (isCanonicalGenericRendering(brand, locale, wrong)) {
|
||||
continue
|
||||
}
|
||||
// Why: "Copy identifier" legitimately uses 사본/复制 — only swap when English names the brand.
|
||||
if (brand === 'Codex' && /\bCopy\b/i.test(enValue)) {
|
||||
continue
|
||||
}
|
||||
result = result.replaceAll(wrong, brand)
|
||||
result = replaceMistranslatedForm(result, wrong, brand, locale)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -548,6 +551,11 @@ export function repairCatalog(enCatalog, localeCatalog, locale) {
|
||||
|
||||
for (const leaf of leaves) {
|
||||
const current = leaf.key.split('.').reduce((cursor, part) => cursor?.[part], localeCatalog)
|
||||
// Why: en.json carries keys the locale catalog has not been bootstrapped with yet; repair only
|
||||
// rewrites values that already exist, so skip instead of crashing on undefined.
|
||||
if (typeof current !== 'string') {
|
||||
continue
|
||||
}
|
||||
const next = repairTranslatedValue({
|
||||
key: leaf.key,
|
||||
enValue: leaf.value,
|
||||
|
||||
@@ -80,7 +80,7 @@ describe('locale-translation-policy', () => {
|
||||
localeValue: '壊れた小切手に対して AI エージェントを開始しました。',
|
||||
locale: 'ja'
|
||||
})
|
||||
).toBe('失敗したチェックに対して AI agent を開始しました。')
|
||||
).toBe('失敗したチェックに対して AI エージェントを開始しました。')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.hooks.useSettingsNavigationMetadata.95a1886d94',
|
||||
@@ -88,7 +88,7 @@ describe('locale-translation-policy', () => {
|
||||
localeValue: '電話機からターミナルとエージェントを制御します。',
|
||||
locale: 'ja'
|
||||
})
|
||||
).toBe('スマートフォンから terminals と agents を操作')
|
||||
).toBe('スマートフォンからターミナルとエージェントを操作')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.GitHubItemDialog.934add88b6',
|
||||
@@ -267,7 +267,7 @@ describe('locale-translation-policy', () => {
|
||||
localeValue: '未已检测代理',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('未检测到 agents')
|
||||
).toBe('未检测到代理')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.skills.SkillsPage.38e0951c3a',
|
||||
@@ -275,7 +275,7 @@ describe('locale-translation-policy', () => {
|
||||
localeValue: '代理技巧',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('Agent 技能')
|
||||
).toBe('代理技能')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.settings.appearance.search.9ae151b26b',
|
||||
@@ -358,7 +358,7 @@ describe('locale-translation-policy', () => {
|
||||
localeValue: 'コミットするものは何もありません。 PR はすでに統合されています。',
|
||||
locale: 'ja'
|
||||
})
|
||||
).toBe('commit するものはありません。PR はすでにマージされています。')
|
||||
).toBe('コミットするものはありません。PR はすでにマージされています。')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.settings.integrations.search.581844769a',
|
||||
@@ -546,7 +546,7 @@ describe('locale-translation-policy', () => {
|
||||
localeValue: '玩',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('Play')
|
||||
).toBe('播放')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.right.sidebar.SourceControlAgentActionDialogForm.1bc0bdbb5e',
|
||||
|
||||
@@ -86,7 +86,9 @@ describe('locale-translation-policy zh round 5', () => {
|
||||
).toBe('集成')
|
||||
})
|
||||
|
||||
it('keeps Terminal as a product surface term', () => {
|
||||
// Why: #12113 — 终端 is the correct rendering and survives; only the 端子 ("electrical
|
||||
// connector") mistranslation still reverts to Latin.
|
||||
it('keeps terminal translated but reverts the 端子 mistranslation', () => {
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.settings.Settings.3de4bbb841',
|
||||
@@ -94,7 +96,7 @@ describe('locale-translation-policy zh round 5', () => {
|
||||
localeValue: '终端',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('Terminal')
|
||||
).toBe('终端')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.feature.wall.BrowserAnimatedVisual.04096318ab',
|
||||
@@ -102,7 +104,7 @@ describe('locale-translation-policy zh round 5', () => {
|
||||
localeValue: '终端 1',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('Terminal 1')
|
||||
).toBe('终端 1')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.agent.AgentCombobox.986f946354',
|
||||
@@ -118,7 +120,7 @@ describe('locale-translation-policy zh round 5', () => {
|
||||
localeValue: '分体式端子右',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('向右拆分 Terminal')
|
||||
).toBe('向右拆分终端')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.settings.TerminalAppearanceSection.abcb4dd019',
|
||||
@@ -126,7 +128,7 @@ describe('locale-translation-policy zh round 5', () => {
|
||||
localeValue: '终端Cursor',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('Terminal Cursor')
|
||||
).toBe('终端 Cursor')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.terminal.FloatingTerminalPanel.3215fc73e9',
|
||||
@@ -137,7 +139,8 @@ describe('locale-translation-policy zh round 5', () => {
|
||||
).toBe('新 Terminal')
|
||||
})
|
||||
|
||||
it('keeps workflow terms in English', () => {
|
||||
// Why: #12113 — brand names stay Latin, but generic workflow nouns keep their Chinese.
|
||||
it('keeps brand names English and generic workflow terms translated', () => {
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.sidebar.SidebarNav.9c95e1ce91',
|
||||
@@ -145,7 +148,7 @@ describe('locale-translation-policy zh round 5', () => {
|
||||
localeValue: '代理',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('Agents')
|
||||
).toBe('代理')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.GitHubItemDialog.28986b3747',
|
||||
@@ -153,7 +156,7 @@ describe('locale-translation-policy zh round 5', () => {
|
||||
localeValue: '已启动 AI 代理处理失败的检查。',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('已启动 AI agent 处理失败的检查。')
|
||||
).toBe('已启动 AI 代理处理失败的检查。')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.components.LinearIssueMarkdownDescriptionEditor.d9c47069ef',
|
||||
@@ -177,7 +180,7 @@ describe('locale-translation-policy zh round 5', () => {
|
||||
localeValue: '次提交',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('commits')
|
||||
).toBe('次提交')
|
||||
expect(
|
||||
repairTranslatedValue({
|
||||
key: 'auto.store.slices.worktrees.d1d78a7baa',
|
||||
@@ -187,7 +190,7 @@ describe('locale-translation-policy zh round 5', () => {
|
||||
'Git 无法安全删除分支“{{value0}}”{{value1}},因此 Orca 保留它以避免丢失本地提交。',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('Git 无法安全删除分支“{{value0}}”{{value1}},因此 Orca 保留它以避免丢失本地 commits。')
|
||||
).toBe('Git 无法安全删除分支“{{value0}}”{{value1}},因此 Orca 保留它以避免丢失本地提交。')
|
||||
})
|
||||
|
||||
it('does not confuse proxy copy with Agent terminology', () => {
|
||||
@@ -234,7 +237,7 @@ describe('locale-translation-policy zh round 5', () => {
|
||||
localeValue: '本地项目、Git 存储库或包含多个存储库的文件夹',
|
||||
locale: 'zh'
|
||||
})
|
||||
).toBe('本地项目、Git repo 或包含多个 repos 的文件夹')
|
||||
).toBe('本地项目、Git 仓库或包含多个仓库的文件夹')
|
||||
})
|
||||
|
||||
it('keeps product, provider, code, and shell tokens untranslated', () => {
|
||||
|
||||
@@ -8,7 +8,7 @@ export const LOCALE_VALUE_OVERRIDES = {
|
||||
'OpenCode Go': 'OpenCode Go',
|
||||
'Open in Cursor': 'Abrir en Cursor',
|
||||
'Local project, Git repo, or folder with many repos':
|
||||
'Proyecto local, repo de Git o carpeta con muchos repos'
|
||||
'Proyecto local, repositorio de Git o carpeta con muchos repositorios'
|
||||
},
|
||||
ko: {
|
||||
Save: '저장',
|
||||
@@ -305,7 +305,8 @@ export const LOCALE_VALUE_OVERRIDES = {
|
||||
teams: '团队',
|
||||
passing: '通过',
|
||||
'opened this issue': '创建了此议题',
|
||||
Open: '进行中',
|
||||
// No blanket "Open" override: it is a verb on buttons (打开) and a state next to 已关闭 (开放),
|
||||
// so a single value-wide mapping is wrong for half the call sites. Per-key catalog values decide.
|
||||
'Join Discord': '加入 Discord',
|
||||
'Pull request merged': '拉取请求已合并',
|
||||
'Agent Skills': '代理技能',
|
||||
|
||||
@@ -67,8 +67,8 @@ export const ZH_VALUE_OVERRIDES = {
|
||||
Inline: '内联',
|
||||
thread: '帖子串',
|
||||
destructive: 'destructive',
|
||||
sheet: 'sheet',
|
||||
page: 'page',
|
||||
sheet: '面板',
|
||||
page: '页面',
|
||||
Annotation: '批注',
|
||||
'check #': '检查 #',
|
||||
by: '由',
|
||||
@@ -92,16 +92,18 @@ export const ZH_VALUE_OVERRIDES = {
|
||||
'仓库操作方案。在此仓库自定义之前,将使用全局设置。',
|
||||
'Adds action recipes for Source Control commit, pull request, branch-name, and fix actions.':
|
||||
'为源代码管理的提交、拉取请求、分支命名和修复操作添加操作方案。',
|
||||
Play: 'Play',
|
||||
Flag: 'Flag',
|
||||
Zinc: 'Zinc',
|
||||
Rose: 'Rose',
|
||||
Emerald: 'Emerald',
|
||||
Amber: 'Amber',
|
||||
Violet: 'Violet',
|
||||
Sky: 'Sky',
|
||||
Blue: 'Blue',
|
||||
Neutral: 'Neutral',
|
||||
// Workspace status icon and color swatch labels; siblings below are translated, so English
|
||||
// pins here would leave the picker half-Chinese on the next catalog regeneration.
|
||||
Play: '播放',
|
||||
Flag: '标记',
|
||||
Zinc: '锌灰',
|
||||
Rose: '玫瑰',
|
||||
Emerald: '翡翠',
|
||||
Amber: '琥珀',
|
||||
Violet: '紫罗兰',
|
||||
Sky: '天蓝',
|
||||
Blue: '蓝色',
|
||||
Neutral: '中性',
|
||||
Dashed: '虚线',
|
||||
Dot: '圆点',
|
||||
Circle: '圆形',
|
||||
@@ -201,8 +203,7 @@ export const ZH_VALUE_OVERRIDES = {
|
||||
'每个已连接的 Linear 工作区都有一个由活动运行时存储的密钥。全权限密钥可覆盖密钥所有者可访问的所有团队;受限密钥可随时更换。',
|
||||
'Show Linear in the Tasks source picker and sidebar shortcuts.':
|
||||
'在任务源选择器和侧边栏快捷方式中显示 Linear。',
|
||||
'Local project, Git repo, or folder with many repos':
|
||||
'本地项目、Git repo 或包含多个 repos 的文件夹',
|
||||
'Local project, Git repo, or folder with many repos': '本地项目、Git 仓库或包含多个仓库的文件夹',
|
||||
'Staged Changes': '已暂存的更改',
|
||||
Changes: '更改',
|
||||
'Untracked Files': '未跟踪文件',
|
||||
@@ -210,8 +211,8 @@ export const ZH_VALUE_OVERRIDES = {
|
||||
'Split Down': '向下拆分',
|
||||
'Split Left': '向左拆分',
|
||||
'Split Right': '向右拆分',
|
||||
'Split Terminal Down': '向下拆分 Terminal',
|
||||
'Split Terminal Right': '向右拆分 Terminal',
|
||||
'Split Terminal Down': '向下拆分终端',
|
||||
'Split Terminal Right': '向右拆分终端',
|
||||
'Optional account switching for Claude while preserving shared chat context.':
|
||||
'Claude 的可选账户切换,同时保留共享聊天上下文。',
|
||||
'Countdown timer showing time until prompt cache expires (Claude agents).':
|
||||
|
||||
@@ -85,3 +85,91 @@ describe('plain Node entry guard', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// Why (#11161): Electron's module is not registered on worker threads either —
|
||||
// require("electron") throws "Cannot find module 'electron'" inside a
|
||||
// main-process worker and kills it at startup. The worker entries carried only
|
||||
// hand-written "must stay electron-free" comments, and the port-scan worker sits
|
||||
// one import away from a client that deliberately does require electron.
|
||||
describe('worker thread entry guard', () => {
|
||||
function runWorkerWriteBundle(plugin: Plugin, bundle: Rollup.OutputBundle): void {
|
||||
const hook = plugin.writeBundle
|
||||
if (typeof hook !== 'function') {
|
||||
throw new Error('Expected writeBundle hook')
|
||||
}
|
||||
hook.call(
|
||||
{ meta: { watchMode: false } } as never,
|
||||
{ dir: createOutputDir() } as Rollup.NormalizedOutputOptions,
|
||||
bundle
|
||||
)
|
||||
}
|
||||
|
||||
function workerChunk(name: string, code: string, imports: string[] = []): Rollup.OutputChunk {
|
||||
return {
|
||||
type: 'chunk',
|
||||
code,
|
||||
dynamicImports: [],
|
||||
fileName: `${name}.js`,
|
||||
imports,
|
||||
isEntry: true,
|
||||
name
|
||||
} as Rollup.OutputChunk
|
||||
}
|
||||
|
||||
it('rejects an Electron require reachable from a worker entry', () => {
|
||||
const plugin = createPlainNodeEntryGuardPlugin()
|
||||
const bundle = {
|
||||
'port-scan-command-worker-entry.js': workerChunk(
|
||||
'port-scan-command-worker-entry',
|
||||
'require("electron")'
|
||||
)
|
||||
} as Rollup.OutputBundle
|
||||
|
||||
expect(() => runWorkerWriteBundle(plugin, bundle)).toThrow('requires electron')
|
||||
})
|
||||
|
||||
it('names the worker-thread runtime so the failure is actionable', () => {
|
||||
const plugin = createPlainNodeEntryGuardPlugin()
|
||||
const bundle = {
|
||||
'stt-worker.js': workerChunk('stt-worker', 'require("electron")')
|
||||
} as Rollup.OutputBundle
|
||||
|
||||
expect(() => runWorkerWriteBundle(plugin, bundle)).toThrow('runs as a worker thread')
|
||||
})
|
||||
|
||||
// The real risk is transitive: a worker entry importing a shared chunk that
|
||||
// reaches the electron-requiring client, not a direct import anyone would spot.
|
||||
it('follows shared chunks out of a worker entry', () => {
|
||||
const plugin = createPlainNodeEntryGuardPlugin()
|
||||
const bundle = {
|
||||
'session-scanner-opencode-sqlite-worker-entry.js': workerChunk(
|
||||
'session-scanner-opencode-sqlite-worker-entry',
|
||||
'require("./chunks/shared.js")',
|
||||
['chunks/shared.js']
|
||||
),
|
||||
'chunks/shared.js': {
|
||||
type: 'chunk',
|
||||
code: 'require("electron")',
|
||||
dynamicImports: [],
|
||||
fileName: 'chunks/shared.js',
|
||||
imports: [],
|
||||
isEntry: false,
|
||||
name: 'shared'
|
||||
} as Rollup.OutputChunk
|
||||
} as Rollup.OutputBundle
|
||||
|
||||
expect(() => runWorkerWriteBundle(plugin, bundle)).toThrow('chunks/shared.js')
|
||||
})
|
||||
|
||||
it('passes a clean worker entry', () => {
|
||||
const plugin = createPlainNodeEntryGuardPlugin()
|
||||
const bundle = {
|
||||
'warp-theme-parser-worker.js': workerChunk(
|
||||
'warp-theme-parser-worker',
|
||||
'require("node:worker_threads")'
|
||||
)
|
||||
} as Rollup.OutputBundle
|
||||
|
||||
expect(() => runWorkerWriteBundle(plugin, bundle)).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()}`
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@ import process from 'node:process'
|
||||
// TypeScript 7 is a native CLI; AST consumers still need the legacy JavaScript API.
|
||||
import ts from 'typescript-api'
|
||||
|
||||
import { canonicalGenericRenderings } from './locale-generic-ui-terms.mjs'
|
||||
import { repairTranslatedValue } from './locale-translation-policy.mjs'
|
||||
|
||||
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts'])
|
||||
const SKIP_PATH_PARTS = new Set(['.git', 'dist', 'node_modules', 'out', '__snapshots__', 'assets'])
|
||||
const LOCALIZATION_FUNCTION_NAMES = new Set(['t', 'translate', 'translateMain'])
|
||||
@@ -282,9 +285,51 @@ function applyMissingEnglishEntries(catalog, missing) {
|
||||
return changed
|
||||
}
|
||||
|
||||
// Why: #12113 — parity checks pass while repair-locale-catalog rewrites translated generic terms
|
||||
// back to English, so drift only surfaces when someone regenerates the catalog.
|
||||
export function collectGenericTermRegressions(enEntries, localeEntries, localeName) {
|
||||
const renderings = canonicalGenericRenderings(localeName)
|
||||
if (renderings.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const regressions = []
|
||||
for (const [key, enValue] of enEntries) {
|
||||
const localeValue = localeEntries.get(key)
|
||||
if (typeof enValue !== 'string' || typeof localeValue !== 'string') {
|
||||
continue
|
||||
}
|
||||
const repaired = repairTranslatedValue({ key, enValue, localeValue, locale: localeName })
|
||||
if (repaired === localeValue) {
|
||||
continue
|
||||
}
|
||||
// Why: {{agent}} is an interpolation name, not English copy the reader sees.
|
||||
const repairedCopy = repaired.replace(PLACEHOLDER_RE, '')
|
||||
for (const { form, terms } of renderings) {
|
||||
if (!localeValue.includes(form) || repaired.includes(form)) {
|
||||
continue
|
||||
}
|
||||
if (
|
||||
terms.some((term) => new RegExp(`(^|[^A-Za-z])${term}($|[^A-Za-z])`).test(repairedCopy))
|
||||
) {
|
||||
regressions.push({ key, form, localeValue, repaired })
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return regressions
|
||||
}
|
||||
|
||||
function formatGenericTermRegressions(regressions) {
|
||||
return regressions
|
||||
.map((entry) => `${entry.key}: ${entry.form} -> English (${entry.repaired})`)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
function verifyLocaleCatalog(enCatalog, localeName, localeCatalog) {
|
||||
const { enEntries, localeEntries, missingInLocale, extraInLocale, interpolationMismatches } =
|
||||
collectLocaleParityIssues(enCatalog, localeCatalog)
|
||||
const genericTermRegressions = collectGenericTermRegressions(enEntries, localeEntries, localeName)
|
||||
|
||||
// Why: feature PRs own English declarations; absent target leaves deliberately
|
||||
// use i18next's existing English fallback until a localization PR supplies them.
|
||||
@@ -292,7 +337,11 @@ function verifyLocaleCatalog(enCatalog, localeName, localeCatalog) {
|
||||
`${localeName}.json coverage: ${enEntries.size - missingInLocale.length}/${enEntries.size} translated, ${missingInLocale.length} missing.`
|
||||
)
|
||||
|
||||
if (extraInLocale.length > 0 || interpolationMismatches.length > 0) {
|
||||
if (
|
||||
extraInLocale.length > 0 ||
|
||||
interpolationMismatches.length > 0 ||
|
||||
genericTermRegressions.length > 0
|
||||
) {
|
||||
console.error(`Locale catalog validation failed for ${localeName}.json.`)
|
||||
if (extraInLocale.length > 0) {
|
||||
console.error('')
|
||||
@@ -310,6 +359,18 @@ function verifyLocaleCatalog(enCatalog, localeName, localeCatalog) {
|
||||
console.error(`...and ${interpolationMismatches.length - 20} more interpolation mismatches`)
|
||||
}
|
||||
}
|
||||
if (genericTermRegressions.length > 0) {
|
||||
console.error('')
|
||||
console.error(
|
||||
'repair-locale-catalog would rewrite these translated terms back to English.',
|
||||
'Treat the term as generic in config/scripts/locale-generic-ui-terms.mjs',
|
||||
'instead of listing its translation as a mistranslation.'
|
||||
)
|
||||
console.error(formatGenericTermRegressions(genericTermRegressions.slice(0, 20)))
|
||||
if (genericTermRegressions.length > 20) {
|
||||
console.error(`...and ${genericTermRegressions.length - 20} more generic term regressions`)
|
||||
}
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,10 @@ import path from 'node:path'
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { main as verifyLocalizationCatalog } from './verify-localization-catalog.mjs'
|
||||
import {
|
||||
collectGenericTermRegressions,
|
||||
main as verifyLocalizationCatalog
|
||||
} from './verify-localization-catalog.mjs'
|
||||
|
||||
function writeJson(filePath, value) {
|
||||
writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8')
|
||||
@@ -123,4 +126,47 @@ describe('verify-localization-catalog', () => {
|
||||
report.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
// Why: #12113 — parity checks passed while the repair policy rewrote translated terms to English.
|
||||
it('flags catalog values the repair policy would rewrite back to English', () => {
|
||||
const enEntries = new Map([['auto.example.commitLabel', 'Commit message']])
|
||||
|
||||
expect(
|
||||
collectGenericTermRegressions(
|
||||
enEntries,
|
||||
new Map([['auto.example.commitLabel', 'mensaje de confirmación']]),
|
||||
'es'
|
||||
)
|
||||
).toEqual([])
|
||||
|
||||
// A locale whose committed value is the English term is stable, not a regression.
|
||||
expect(
|
||||
collectGenericTermRegressions(
|
||||
enEntries,
|
||||
new Map([['auto.example.commitLabel', 'mensaje de Commit']]),
|
||||
'es'
|
||||
)
|
||||
).toEqual([])
|
||||
|
||||
// 'Comprometerse' is a real mistranslation, so reverting it to Latin is expected.
|
||||
expect(
|
||||
collectGenericTermRegressions(
|
||||
enEntries,
|
||||
new Map([['auto.example.commitLabel', 'mensaje de Comprometerse']]),
|
||||
'es'
|
||||
)
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('ignores interpolation names when looking for English rewrites', () => {
|
||||
expect(
|
||||
collectGenericTermRegressions(
|
||||
new Map([
|
||||
['components.agentSessionContinuation.originalAgent', 'Original agent: {{agent}}']
|
||||
]),
|
||||
new Map([['components.agentSessionContinuation.originalAgent', '原智能体:{{agent}}']]),
|
||||
'zh'
|
||||
)
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 13m">
|
||||
<title>downloads: 13m</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="106" height="20" role="img" aria-label="downloads: 14m">
|
||||
<title>downloads: 14m</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
@@ -15,7 +15,7 @@
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
|
||||
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
|
||||
<text x="37" y="14">downloads</text>
|
||||
<text x="90" y="15" fill="#010101" fill-opacity=".3">13m</text>
|
||||
<text x="90" y="14">13m</text>
|
||||
<text x="90" y="15" fill="#010101" fill-opacity=".3">14m</text>
|
||||
<text x="90" y="14">14m</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 935 B After Width: | Height: | Size: 935 B |
@@ -216,6 +216,11 @@ export const electronViteConfig: UserConfig = {
|
||||
'session-scanner-opencode-sqlite-worker-entry': resolve(
|
||||
'src/main/ai-vault/session-scanner-opencode-sqlite-worker-entry.ts'
|
||||
),
|
||||
// Why: libuv spawns processes inline on the calling loop, so the port
|
||||
// scan's probe commands run on a worker thread instead of the UI one.
|
||||
'port-scan-command-worker-entry': resolve(
|
||||
'src/main/ports/port-scan-command-worker-entry.ts'
|
||||
),
|
||||
// Why: forked with ELECTRON_RUN_AS_NODE so @parcel/watcher faults
|
||||
// can't take down the main process (issue #7547).
|
||||
'parcel-watcher-process-entry': resolve('src/main/ipc/parcel-watcher-process-entry.ts'),
|
||||
|
||||
@@ -198,7 +198,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,
|
||||
@@ -3195,44 +3195,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.
|
||||
@@ -4753,6 +4732,7 @@ export default function SessionScreen() {
|
||||
))}
|
||||
<MobileNativeChatOverlay
|
||||
controller={nativeChatController}
|
||||
onOpenFile={handleNativeChatFileTap}
|
||||
images={nativeChatImages}
|
||||
onMicPress={handleDictationToggle}
|
||||
micActive={dictation.isRecording}
|
||||
|
||||
+10
-10
@@ -17,7 +17,7 @@ import {
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import { loadHosts } from '../src/transport/host-store'
|
||||
import { getHostListLoadRevision } from '../src/transport/host-list-load-sharing'
|
||||
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'
|
||||
@@ -57,7 +57,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 {
|
||||
@@ -209,7 +209,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()
|
||||
@@ -740,13 +742,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}>
|
||||
@@ -910,7 +910,7 @@ export default function HomeScreen() {
|
||||
onDismiss: () => setActionTarget(null),
|
||||
onReconnect: (hostId) => void forceReconnectHost(hostId).catch(showForceReconnectError),
|
||||
onDisconnect: closeHostClient,
|
||||
onEdit: (hostId) => navigateToMobileHostEdit(router, hostId),
|
||||
onEdit: openMobileHostEdit,
|
||||
onRemove: setConfirmRemove
|
||||
})}
|
||||
onClose={() => setActionTarget(null)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,19 +45,27 @@ 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}
|
||||
onAnswerAsk={controller.handleNativeChatAnswerAsk}
|
||||
@@ -57,7 +74,7 @@ export function MobileNativeChatOverlay({
|
||||
onAnswerQuestion={controller.handleNativeChatQuestionAnswer}
|
||||
permission={controller.nativeChatPermission}
|
||||
onRespondPermission={controller.handleNativeChatRespondPermission}
|
||||
onOpenFile={controller.handleNativeChatOpenFile}
|
||||
onOpenFile={onOpenFile}
|
||||
hasMore={session.hasMore}
|
||||
loadingEarlier={session.loadingEarlier}
|
||||
onLoadEarlier={session.loadEarlier}
|
||||
|
||||
@@ -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,7 +16,6 @@ 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'
|
||||
@@ -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
|
||||
@@ -102,12 +104,13 @@ type Props = {
|
||||
|
||||
export function MobileNativeChatView({
|
||||
messages,
|
||||
folded,
|
||||
status,
|
||||
error,
|
||||
agent,
|
||||
agentWorking,
|
||||
onStop,
|
||||
streamingText,
|
||||
streaming,
|
||||
hasMore,
|
||||
loadingEarlier,
|
||||
onLoadEarlier,
|
||||
@@ -165,10 +168,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 +274,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={() => {
|
||||
|
||||
+185
-11
@@ -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',
|
||||
@@ -106,7 +106,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 +153,7 @@ describe('openMobileTerminalFileTap', () => {
|
||||
])
|
||||
const pushPreviewRoute = vi.fn()
|
||||
|
||||
openMobileTerminalFileTap({
|
||||
openMobileFileTap({
|
||||
client,
|
||||
hostId: 'host-1',
|
||||
worktreeId: 'wt-1',
|
||||
@@ -204,7 +204,7 @@ describe('openMobileTerminalFileTap', () => {
|
||||
const pushPreviewRoute = vi.fn()
|
||||
const triggerOpenFeedback = vi.fn()
|
||||
|
||||
openMobileTerminalFileTap({
|
||||
openMobileFileTap({
|
||||
client,
|
||||
hostId: 'host-1',
|
||||
worktreeId: 'wt-1',
|
||||
@@ -258,7 +258,7 @@ describe('openMobileTerminalFileTap', () => {
|
||||
])
|
||||
const openBrowser = vi.fn()
|
||||
|
||||
openMobileTerminalFileTap({
|
||||
openMobileFileTap({
|
||||
client,
|
||||
hostId: 'host-1',
|
||||
worktreeId: 'wt-1',
|
||||
@@ -299,7 +299,7 @@ describe('openMobileTerminalFileTap', () => {
|
||||
ok({ opened: true })
|
||||
])
|
||||
|
||||
openMobileTerminalFileTap({
|
||||
openMobileFileTap({
|
||||
client,
|
||||
hostId: 'host-1',
|
||||
worktreeId: 'wt-1',
|
||||
@@ -346,7 +346,7 @@ describe('openMobileTerminalFileTap', () => {
|
||||
])
|
||||
const openBrowser = vi.fn()
|
||||
|
||||
openMobileTerminalFileTap({
|
||||
openMobileFileTap({
|
||||
client,
|
||||
hostId: 'host-1',
|
||||
worktreeId: 'wt-1',
|
||||
@@ -387,7 +387,7 @@ describe('openMobileTerminalFileTap', () => {
|
||||
let activeTerminalHandle: string | null = 'terminal-1'
|
||||
const pushPreviewRoute = vi.fn()
|
||||
|
||||
openMobileTerminalFileTap({
|
||||
openMobileFileTap({
|
||||
client,
|
||||
hostId: 'host-1',
|
||||
worktreeId: 'wt-1',
|
||||
@@ -431,6 +431,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 +626,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',
|
||||
+40
-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(
|
||||
@@ -65,12 +83,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 +128,7 @@ async function openMobileTerminalFileTapAsync<T extends TerminalFileTapSessionTa
|
||||
? resolved.openTarget.relativePath
|
||||
: resolved.relativePath
|
||||
if (!openedPath) {
|
||||
reportOpenFailure(options)
|
||||
return
|
||||
}
|
||||
options.triggerOpenFeedback()
|
||||
@@ -143,13 +165,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
|
||||
@@ -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' },
|
||||
{ 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' },
|
||||
{ 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
|
||||
}
|
||||
|
||||
@@ -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,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,134 @@
|
||||
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' },
|
||||
{ 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' },
|
||||
{ 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,
|
||||
|
||||
@@ -47,8 +47,12 @@ vi.mock('./use-mobile-native-chat-drafts', () => ({
|
||||
vi.mock('./use-mobile-native-chat-prompts', () => ({
|
||||
useMobileNativeChatPrompts: () => ({ permission: null, question: null, ask: null })
|
||||
}))
|
||||
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()
|
||||
@@ -421,3 +425,99 @@ describe('useMobileNativeChatController launch-draft wiring', () => {
|
||||
expect(draftsArgs.at(-1)).toMatchObject({ launchDraft: null, chatActive: true })
|
||||
})
|
||||
})
|
||||
|
||||
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', () => {
|
||||
// Both feed the streaming gate, which lives above the chat view's mount and
|
||||
// must not be reset by a view toggle.
|
||||
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', () => {
|
||||
// `streamIdentity` fences a delayed answer/stop to the route it was armed
|
||||
// on, so it must still drop its session when chat closes — the scope key is
|
||||
// the one that has to survive the toggle. Same string while chat is open.
|
||||
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,10 +1,4 @@
|
||||
import {
|
||||
useCallback,
|
||||
useRef,
|
||||
type Dispatch,
|
||||
type MutableRefObject,
|
||||
type SetStateAction
|
||||
} from 'react'
|
||||
import { useRef, type Dispatch, type MutableRefObject, type SetStateAction } from 'react'
|
||||
import { useMobileSessionViewMode } from './use-mobile-session-view-mode'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { ConnectionState } from '../transport/types'
|
||||
@@ -16,7 +10,6 @@ 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'
|
||||
@@ -49,10 +42,13 @@ 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>
|
||||
nativeChatAsk: ReturnType<typeof parseAskFromStatus>
|
||||
handleNativeChatOpenFile: (relativePath: string) => void
|
||||
handleNativeChatAnswerAsk: (
|
||||
prompt: AskPrompt,
|
||||
selections: AskAnswerSelection[]
|
||||
@@ -124,7 +120,12 @@ export function useMobileNativeChatController(args: {
|
||||
activeChatAgentRef.current = activeChatResolution?.agent ?? null
|
||||
|
||||
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 +161,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(
|
||||
@@ -176,21 +180,6 @@ export function useMobileNativeChatController(args: {
|
||||
messages: nativeChatSession.messages
|
||||
})
|
||||
|
||||
const handleNativeChatOpenFile = useCallback(
|
||||
(pathText: string) => {
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
void openMobileNativeChatFile({
|
||||
client,
|
||||
worktreeId,
|
||||
pathText,
|
||||
terminal: activeHandleRef.current
|
||||
})
|
||||
},
|
||||
[activeHandleRef, client, worktreeId]
|
||||
)
|
||||
|
||||
// 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.
|
||||
const inputSendable = nativeChatInputLeaseReady && connState === 'connected'
|
||||
@@ -273,10 +262,11 @@ export function useMobileNativeChatController(args: {
|
||||
nativeChatSession,
|
||||
nativeChatAgentWorking,
|
||||
nativeChatStreamingText,
|
||||
nativeChatStreamLive,
|
||||
nativeChatStreamScopeKey: streamScopeKey,
|
||||
nativeChatPermission,
|
||||
nativeChatQuestion,
|
||||
nativeChatAsk,
|
||||
handleNativeChatOpenFile,
|
||||
handleNativeChatAnswerAsk: answerAsk,
|
||||
handleNativeChatCancelAsk: cancelAsk,
|
||||
handleNativeChatRespondPermission: respond,
|
||||
|
||||
@@ -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,6 +33,7 @@ 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
|
||||
let onSendError = vi.fn()
|
||||
|
||||
const mount = (
|
||||
readSeededLaunchDraftSeed: () => { text: string; createdAt: number | null } | null
|
||||
@@ -44,7 +50,7 @@ describe('useMobileNativeChatMessageSend', () => {
|
||||
restoreRejectedDraft: () => {},
|
||||
acceptSend: () => {},
|
||||
holdUnconfirmedSend: () => {},
|
||||
onSendError: () => {}
|
||||
onSendError
|
||||
})
|
||||
return null
|
||||
}
|
||||
@@ -70,6 +76,8 @@ describe('useMobileNativeChatMessageSend', () => {
|
||||
sendWithOutcome.mockResolvedValue('accepted')
|
||||
clearInputWrite.mockReset()
|
||||
clearInputWrite.mockResolvedValue(true)
|
||||
onSendError = vi.fn()
|
||||
resetMobileNativeChatTerminalWritesForTests()
|
||||
})
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
@@ -173,4 +181,27 @@ describe('useMobileNativeChatMessageSend', () => {
|
||||
})
|
||||
expect(sentArgs().resolvedLaunchDraft).toBeUndefined()
|
||||
})
|
||||
|
||||
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,10 @@ import {
|
||||
type MobileNativeChatSendOutcome
|
||||
} from './mobile-native-chat-send'
|
||||
import { healMobileNativeChatStaleInput } from './mobile-native-chat-stale-input'
|
||||
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'
|
||||
@@ -210,11 +214,26 @@ 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)) !== 'rejected'
|
||||
} finally {
|
||||
if (terminal) {
|
||||
releaseMobileNativeChatTerminalWrite(terminal)
|
||||
}
|
||||
}
|
||||
},
|
||||
[handleRef, onSendError, sendMessage]
|
||||
)
|
||||
|
||||
return { send, sendWithOutcome, answerQuestion }
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -1245,6 +1245,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,
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { RelayAgentHookServer } from '../../relay/agent-hook-server'
|
||||
import type { AgentHookRelayEnvelope } from '../../shared/agent-hook-relay'
|
||||
import { makePaneKey } from '../../shared/stable-pane-id'
|
||||
import { AgentHookServer } from './server'
|
||||
|
||||
vi.mock('../telemetry/client', () => ({ track: vi.fn() }))
|
||||
vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn(() => ({})) }))
|
||||
|
||||
const PANE_KEY = makePaneKey('manual-compact', '11111111-1111-4111-8111-111111111111')
|
||||
const PROMPT_ID_1 = '22222222-2222-4222-8222-222222222222'
|
||||
const PROMPT_ID_2 = '33333333-3333-4333-8333-333333333333'
|
||||
const SESSION = { key: 'session_id' as const, id: 'session-a' }
|
||||
|
||||
type ClaudeHook = {
|
||||
hook_event_name: string
|
||||
prompt?: string
|
||||
prompt_id: string
|
||||
session_id: string
|
||||
trigger?: 'manual' | 'auto'
|
||||
agent_id?: string
|
||||
agent_type?: string
|
||||
}
|
||||
|
||||
function claudeHook(
|
||||
hookEventName: string,
|
||||
promptId: string,
|
||||
extra: Partial<ClaudeHook> = {}
|
||||
): ClaudeHook {
|
||||
return {
|
||||
hook_event_name: hookEventName,
|
||||
prompt_id: promptId,
|
||||
session_id: 'session-a',
|
||||
...extra
|
||||
}
|
||||
}
|
||||
|
||||
function manualEnvelope(hookEventName: string, state: 'working' | 'done') {
|
||||
return {
|
||||
source: 'claude' as const,
|
||||
paneKey: PANE_KEY,
|
||||
hasExplicitPrompt: hookEventName === 'UserPromptSubmit' ? true : undefined,
|
||||
hookEventName,
|
||||
providerPromptId: hookEventName === 'UserPromptSubmit' ? PROMPT_ID_2 : PROMPT_ID_1,
|
||||
compactTrigger: hookEventName === 'UserPromptSubmit' ? undefined : ('manual' as const),
|
||||
providerSession: SESSION,
|
||||
payload: { state, prompt: 'work before compact', agentType: 'claude' as const }
|
||||
}
|
||||
}
|
||||
|
||||
function postHook(port: number, token: string, payload: Record<string, unknown>) {
|
||||
return fetch(`http://127.0.0.1:${port}/hook/claude`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Orca-Agent-Hook-Token': token
|
||||
},
|
||||
body: JSON.stringify({ paneKey: PANE_KEY, payload })
|
||||
})
|
||||
}
|
||||
|
||||
describe('manual Claude compact hook stream', () => {
|
||||
const servers: { stop: () => void }[] = []
|
||||
const temporaryPaths: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const server of servers.splice(0)) {
|
||||
server.stop()
|
||||
}
|
||||
for (const path of temporaryPaths.splice(0)) {
|
||||
rmSync(path, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('settles an exact local HTTP lifecycle and rejects a duplicate completion', async () => {
|
||||
const server = new AgentHookServer()
|
||||
servers.push(server)
|
||||
await server.start({ env: 'production' })
|
||||
const env = server.buildPtyEnv()
|
||||
const events: string[] = []
|
||||
const unsubscribe = server.subscribeEnrichedStatus((event) => {
|
||||
events.push(`${event.hookEventName}:${event.payload.state}`)
|
||||
})
|
||||
|
||||
await postHook(
|
||||
Number(env.ORCA_AGENT_HOOK_PORT),
|
||||
env.ORCA_AGENT_HOOK_TOKEN,
|
||||
claudeHook('UserPromptSubmit', PROMPT_ID_2, { prompt: 'work before compact' })
|
||||
)
|
||||
await postHook(
|
||||
Number(env.ORCA_AGENT_HOOK_PORT),
|
||||
env.ORCA_AGENT_HOOK_TOKEN,
|
||||
claudeHook('PreCompact', PROMPT_ID_1, { trigger: 'manual' })
|
||||
)
|
||||
await postHook(
|
||||
Number(env.ORCA_AGENT_HOOK_PORT),
|
||||
env.ORCA_AGENT_HOOK_TOKEN,
|
||||
claudeHook('SubagentStart', PROMPT_ID_1, {
|
||||
agent_id: 'compact-agent',
|
||||
agent_type: 'general-purpose'
|
||||
})
|
||||
)
|
||||
await postHook(
|
||||
Number(env.ORCA_AGENT_HOOK_PORT),
|
||||
env.ORCA_AGENT_HOOK_TOKEN,
|
||||
claudeHook('SubagentStop', PROMPT_ID_1, {
|
||||
agent_id: 'compact-agent',
|
||||
agent_type: 'general-purpose'
|
||||
})
|
||||
)
|
||||
await postHook(
|
||||
Number(env.ORCA_AGENT_HOOK_PORT),
|
||||
env.ORCA_AGENT_HOOK_TOKEN,
|
||||
claudeHook('PostCompact', PROMPT_ID_1, { trigger: 'manual' })
|
||||
)
|
||||
await postHook(
|
||||
Number(env.ORCA_AGENT_HOOK_PORT),
|
||||
env.ORCA_AGENT_HOOK_TOKEN,
|
||||
claudeHook('PostCompact', PROMPT_ID_1, { trigger: 'manual' })
|
||||
)
|
||||
|
||||
expect(events).toEqual([
|
||||
'UserPromptSubmit:working',
|
||||
'PreCompact:working',
|
||||
'SubagentStart:working',
|
||||
'SubagentStop:working',
|
||||
'PostCompact:done'
|
||||
])
|
||||
expect(server.getStatusSnapshot()).toEqual([
|
||||
expect.objectContaining({
|
||||
state: 'done',
|
||||
prompt: 'work before compact',
|
||||
agentType: 'claude'
|
||||
})
|
||||
])
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
it('preserves the manual identity over relay and rejects stale transport identities', async () => {
|
||||
const main = new AgentHookServer()
|
||||
const forwarded: AgentHookRelayEnvelope[] = []
|
||||
const emitted: string[] = []
|
||||
const connectionId = 'conn-a'
|
||||
const endpointDir = mkdtempSync(join(tmpdir(), 'orca-compact-relay-'))
|
||||
temporaryPaths.push(endpointDir)
|
||||
const relay = new RelayAgentHookServer({
|
||||
endpointDir,
|
||||
token: 'manual-compact-token',
|
||||
forward: (envelope) => {
|
||||
forwarded.push(envelope)
|
||||
main.ingestRemote(envelope, connectionId)
|
||||
}
|
||||
})
|
||||
servers.push(main, relay)
|
||||
const unsubscribe = main.subscribeEnrichedStatus((event) => {
|
||||
emitted.push(`${event.hookEventName}:${event.payload.state}`)
|
||||
})
|
||||
await relay.start({ publishEndpoint: false })
|
||||
const coordinates = relay.getCoordinates()
|
||||
|
||||
await postHook(
|
||||
coordinates.port,
|
||||
coordinates.token,
|
||||
claudeHook('UserPromptSubmit', PROMPT_ID_2, { prompt: 'work before compact' })
|
||||
)
|
||||
// A restarted relay has no prior listener cache; main still owns the status boundary.
|
||||
relay.clearPaneState(PANE_KEY)
|
||||
await postHook(
|
||||
coordinates.port,
|
||||
coordinates.token,
|
||||
claudeHook('PreCompact', PROMPT_ID_1, { trigger: 'manual' })
|
||||
)
|
||||
const validPost = {
|
||||
...forwarded.at(-1)!,
|
||||
hookEventName: 'PostCompact',
|
||||
payload: {
|
||||
state: 'done' as const,
|
||||
prompt: 'work before compact',
|
||||
agentType: 'claude' as const
|
||||
}
|
||||
}
|
||||
|
||||
main.ingestRemote({ ...validPost, providerPromptId: PROMPT_ID_2 }, 'conn-a')
|
||||
main.ingestRemote({ ...validPost, providerPromptId: undefined }, 'conn-a')
|
||||
main.ingestRemote({ ...validPost, providerSession: undefined }, 'conn-a')
|
||||
main.ingestRemote({ ...validPost, source: 'codex' }, 'conn-a')
|
||||
main.ingestRemote(validPost, 'conn-b')
|
||||
|
||||
expect(emitted).toEqual(['UserPromptSubmit:working', 'PreCompact:working'])
|
||||
expect(main.getStatusSnapshot()[0]).toMatchObject({
|
||||
state: 'working',
|
||||
prompt: 'work before compact'
|
||||
})
|
||||
|
||||
await postHook(
|
||||
coordinates.port,
|
||||
coordinates.token,
|
||||
claudeHook('SubagentStart', PROMPT_ID_1, {
|
||||
agent_id: 'compact-agent',
|
||||
agent_type: 'general-purpose'
|
||||
})
|
||||
)
|
||||
await postHook(
|
||||
coordinates.port,
|
||||
coordinates.token,
|
||||
claudeHook('SubagentStop', PROMPT_ID_1, {
|
||||
agent_id: 'compact-agent',
|
||||
agent_type: 'general-purpose'
|
||||
})
|
||||
)
|
||||
await postHook(
|
||||
coordinates.port,
|
||||
coordinates.token,
|
||||
claudeHook('PostCompact', PROMPT_ID_1, { trigger: 'manual' })
|
||||
)
|
||||
|
||||
expect(forwarded.at(-1)).toMatchObject({
|
||||
source: 'claude',
|
||||
providerPromptId: PROMPT_ID_1,
|
||||
compactTrigger: 'manual',
|
||||
providerSession: SESSION,
|
||||
hookEventName: 'PostCompact'
|
||||
})
|
||||
expect(emitted.at(-1)).toBe('PostCompact:done')
|
||||
expect(emitted.slice(-3)).toEqual([
|
||||
'SubagentStart:working',
|
||||
'SubagentStop:working',
|
||||
'PostCompact:done'
|
||||
])
|
||||
expect(main.getStatusSnapshot()[0]).toMatchObject({
|
||||
state: 'done',
|
||||
prompt: 'work before compact'
|
||||
})
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
it('invalidates a compact completion when later provider work owns the pane', () => {
|
||||
const server = new AgentHookServer()
|
||||
servers.push(server)
|
||||
server.ingestRemote(manualEnvelope('UserPromptSubmit', 'working'), 'conn-a')
|
||||
server.ingestRemote(manualEnvelope('PreCompact', 'working'), 'conn-a')
|
||||
server.ingestRemote(
|
||||
{
|
||||
source: 'codex',
|
||||
paneKey: PANE_KEY,
|
||||
hookEventName: 'UserPromptSubmit',
|
||||
hasExplicitPrompt: true,
|
||||
payload: { state: 'working', prompt: 'new work', agentType: 'codex' }
|
||||
},
|
||||
'conn-a'
|
||||
)
|
||||
server.ingestRemote({ ...manualEnvelope('PreCompact', 'working'), isReplay: true }, 'conn-a')
|
||||
server.ingestRemote(manualEnvelope('PostCompact', 'done'), 'conn-a')
|
||||
|
||||
expect(server.getStatusSnapshot()[0]).toMatchObject({
|
||||
state: 'working',
|
||||
prompt: 'new work',
|
||||
agentType: 'claude'
|
||||
})
|
||||
expect(server._getStateForTests().lastStatusByPaneKey.get(PANE_KEY)).toMatchObject({
|
||||
source: 'codex',
|
||||
hookEventName: 'UserPromptSubmit'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps automatic compact hooks working without settling the turn', async () => {
|
||||
const main = new AgentHookServer()
|
||||
const forwarded: AgentHookRelayEnvelope[] = []
|
||||
const endpointDir = mkdtempSync(join(tmpdir(), 'orca-auto-compact-relay-'))
|
||||
temporaryPaths.push(endpointDir)
|
||||
const relay = new RelayAgentHookServer({
|
||||
endpointDir,
|
||||
forward: (envelope) => {
|
||||
forwarded.push(envelope)
|
||||
main.ingestRemote(envelope, 'conn-auto')
|
||||
}
|
||||
})
|
||||
servers.push(main, relay)
|
||||
await relay.start({ publishEndpoint: false })
|
||||
const { port, token } = relay.getCoordinates()
|
||||
|
||||
await postHook(
|
||||
port,
|
||||
token,
|
||||
claudeHook('UserPromptSubmit', PROMPT_ID_1, { prompt: 'continue the task' })
|
||||
)
|
||||
await postHook(port, token, claudeHook('PreCompact', PROMPT_ID_1, { trigger: 'auto' }))
|
||||
await postHook(port, token, claudeHook('PostCompact', PROMPT_ID_1, { trigger: 'auto' }))
|
||||
|
||||
expect(forwarded.map((event) => event.hookEventName)).toEqual([
|
||||
'UserPromptSubmit',
|
||||
'PreCompact',
|
||||
'PostCompact'
|
||||
])
|
||||
expect(main.getStatusSnapshot()[0]).toMatchObject({ state: 'working', agentType: 'claude' })
|
||||
expect(main._getStateForTests().lastStatusByPaneKey.get(PANE_KEY)).toMatchObject({
|
||||
hookEventName: 'PostCompact',
|
||||
compactTrigger: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects unproven compact sources at the main relay boundary', () => {
|
||||
const server = new AgentHookServer()
|
||||
servers.push(server)
|
||||
server.ingestRemote(
|
||||
{
|
||||
...manualEnvelope('PreCompact', 'working'),
|
||||
source: 'kimi',
|
||||
payload: { state: 'working', prompt: '', agentType: 'kimi' }
|
||||
},
|
||||
'conn-kimi'
|
||||
)
|
||||
server.ingestRemote(
|
||||
{ ...manualEnvelope('PreCompact', 'working'), compactTrigger: undefined },
|
||||
'conn-auto'
|
||||
)
|
||||
|
||||
expect(server.getStatusSnapshot()).toEqual([])
|
||||
expect(server._getStateForTests().lastStatusByPaneKey.size).toBe(0)
|
||||
})
|
||||
|
||||
it('hydrates a manual PreCompact identity and accepts only its exact local completion', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-compact-restore-'))
|
||||
temporaryPaths.push(userDataPath)
|
||||
const first = new AgentHookServer()
|
||||
servers.push(first)
|
||||
await first.start({ env: 'production', userDataPath })
|
||||
const firstEnv = first.buildPtyEnv()
|
||||
await postHook(
|
||||
Number(firstEnv.ORCA_AGENT_HOOK_PORT),
|
||||
firstEnv.ORCA_AGENT_HOOK_TOKEN,
|
||||
claudeHook('UserPromptSubmit', PROMPT_ID_2, { prompt: 'work before compact' })
|
||||
)
|
||||
await postHook(
|
||||
Number(firstEnv.ORCA_AGENT_HOOK_PORT),
|
||||
firstEnv.ORCA_AGENT_HOOK_TOKEN,
|
||||
claudeHook('PreCompact', PROMPT_ID_1, { trigger: 'manual' })
|
||||
)
|
||||
first.stop()
|
||||
|
||||
const restored = new AgentHookServer()
|
||||
servers.push(restored)
|
||||
await restored.start({ env: 'production', userDataPath })
|
||||
const restoredEnv = restored.buildPtyEnv()
|
||||
await postHook(
|
||||
Number(restoredEnv.ORCA_AGENT_HOOK_PORT),
|
||||
restoredEnv.ORCA_AGENT_HOOK_TOKEN,
|
||||
claudeHook('PostCompact', PROMPT_ID_2, { trigger: 'manual' })
|
||||
)
|
||||
expect(restored.getStatusSnapshot()[0]).toMatchObject({ state: 'working' })
|
||||
|
||||
await postHook(
|
||||
Number(restoredEnv.ORCA_AGENT_HOOK_PORT),
|
||||
restoredEnv.ORCA_AGENT_HOOK_TOKEN,
|
||||
claudeHook('PostCompact', PROMPT_ID_1, { trigger: 'manual' })
|
||||
)
|
||||
expect(restored.getStatusSnapshot()[0]).toMatchObject({
|
||||
state: 'done',
|
||||
prompt: 'work before compact',
|
||||
agentType: 'claude'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { makePaneKey } from '../../shared/stable-pane-id'
|
||||
import { AgentHookServer } from './server'
|
||||
|
||||
vi.mock('../telemetry/client', () => ({ track: vi.fn() }))
|
||||
vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn(() => ({})) }))
|
||||
|
||||
const PANE_KEY = makePaneKey('manual-compact', '11111111-1111-4111-8111-111111111111')
|
||||
|
||||
function compactEvent(hookEventName: string, state: 'working' | 'done') {
|
||||
return {
|
||||
source: 'claude' as const,
|
||||
paneKey: PANE_KEY,
|
||||
hasExplicitPrompt: hookEventName === 'UserPromptSubmit' ? true : undefined,
|
||||
hookEventName,
|
||||
providerPromptId:
|
||||
hookEventName === 'UserPromptSubmit'
|
||||
? '22222222-2222-4222-8222-222222222222'
|
||||
: '33333333-3333-4333-8333-333333333333',
|
||||
compactTrigger: hookEventName === 'UserPromptSubmit' ? undefined : ('manual' as const),
|
||||
providerSession: { key: 'session_id' as const, id: 'session-a' },
|
||||
payload: { state, prompt: 'work before compact', agentType: 'claude' as const }
|
||||
}
|
||||
}
|
||||
|
||||
describe('manual compact status cleanup', () => {
|
||||
it('retires authority with pane, tab, and server cleanup', () => {
|
||||
const server = new AgentHookServer()
|
||||
const begin = (): void => {
|
||||
server.ingestRemote(compactEvent('UserPromptSubmit', 'working'), 'conn-a')
|
||||
server.ingestRemote(compactEvent('PreCompact', 'working'), 'conn-a')
|
||||
}
|
||||
|
||||
begin()
|
||||
server.clearPaneState(PANE_KEY)
|
||||
server.ingestRemote(compactEvent('PostCompact', 'done'), 'conn-a')
|
||||
expect(server.getStatusSnapshot()).toEqual([])
|
||||
|
||||
begin()
|
||||
server.dropStatusEntriesByTabPrefix('manual-compact')
|
||||
server.ingestRemote(compactEvent('PostCompact', 'done'), 'conn-a')
|
||||
expect(server.getStatusSnapshot()).toEqual([])
|
||||
|
||||
server.stop()
|
||||
expect(server._getStateForTests().lastStatusByPaneKey.size).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -22,11 +22,14 @@ import {
|
||||
markCodexLeadTurnInterrupted,
|
||||
MAX_PANE_KEY_LEN,
|
||||
movePaneCacheState,
|
||||
canAcceptClaudeCompactTransition,
|
||||
normalizeClaudePromptId,
|
||||
normalizeHookPayload,
|
||||
parseFormEncodedBody,
|
||||
readRequestBody,
|
||||
reapRestoredClaudeSubagentsForDeadPane,
|
||||
reconcileRemoteCodexState,
|
||||
resolveCachedClaudeCompactOwnership,
|
||||
resolveHookSource,
|
||||
preparePendingGrokResultDiscovery,
|
||||
seedClaudeSubagentRosterFromSnapshots,
|
||||
@@ -41,7 +44,11 @@ import {
|
||||
claudeRosterHasWorkingSubagent,
|
||||
claudeRosterToSnapshots
|
||||
} from '../../shared/claude-subagent-roster'
|
||||
import { restoreShedStatusFields, type AgentHookSource } from '../../shared/agent-hook-relay'
|
||||
import {
|
||||
isAgentHookSource,
|
||||
restoreShedStatusFields,
|
||||
type AgentHookSource
|
||||
} from '../../shared/agent-hook-relay'
|
||||
import {
|
||||
CLAUDE_STATUSLINE_PATHNAME,
|
||||
parseClaudeStatusLineBody,
|
||||
@@ -293,13 +300,23 @@ function sanitizeHydratedEntry(
|
||||
if (providerSessionOnly && !isValidPiProviderSessionOnly(providerSession, payload.agentType)) {
|
||||
return null
|
||||
}
|
||||
const source = isAgentHookSource(record.source) ? record.source : undefined
|
||||
const providerPromptId =
|
||||
source === 'claude' ? normalizeClaudePromptId(record.providerPromptId) : undefined
|
||||
const compactTrigger =
|
||||
source === 'claude' && (record.compactTrigger === 'manual' || record.compactTrigger === 'auto')
|
||||
? record.compactTrigger
|
||||
: undefined
|
||||
return {
|
||||
paneKey,
|
||||
source,
|
||||
tabId: typeof tabId === 'string' ? tabId : undefined,
|
||||
worktreeId: typeof worktreeId === 'string' ? worktreeId : undefined,
|
||||
connectionId,
|
||||
hasExplicitPrompt: record.hasExplicitPrompt === true ? true : undefined,
|
||||
hookEventName: typeof record.hookEventName === 'string' ? record.hookEventName : undefined,
|
||||
providerPromptId,
|
||||
compactTrigger,
|
||||
toolUseId: typeof record.toolUseId === 'string' ? record.toolUseId : undefined,
|
||||
toolAgentId: typeof record.toolAgentId === 'string' ? record.toolAgentId : undefined,
|
||||
toolAgentType: typeof record.toolAgentType === 'string' ? record.toolAgentType : undefined,
|
||||
@@ -1236,7 +1253,8 @@ export class AgentHookServer {
|
||||
if (!identity.inheritedFromActivePane) {
|
||||
this.maybeTrackAgentPromptSent(effectivePayload, previous)
|
||||
}
|
||||
const enriched = this.attachStatusTiming(effectivePayload, now)
|
||||
const cachedPayload = resolveCachedClaudeCompactOwnership(previous, effectivePayload)
|
||||
const enriched = this.attachStatusTiming(cachedPayload, now)
|
||||
this.runtimeObservedStatusPaneKeys.add(enriched.paneKey)
|
||||
this.state.lastStatusByPaneKey.set(enriched.paneKey, enriched)
|
||||
this.scheduleStatusPersist()
|
||||
@@ -1834,6 +1852,9 @@ export class AgentHookServer {
|
||||
hasExplicitPrompt?: boolean
|
||||
promptInteractionKey?: string
|
||||
hookEventName?: string
|
||||
source?: unknown
|
||||
providerPromptId?: unknown
|
||||
compactTrigger?: unknown
|
||||
toolUseId?: string
|
||||
toolAgentId?: string
|
||||
toolAgentType?: string
|
||||
@@ -1895,6 +1916,14 @@ export class AgentHookServer {
|
||||
typeof envelope.hookEventName === 'string' && envelope.hookEventName.trim().length > 0
|
||||
? envelope.hookEventName.trim()
|
||||
: undefined
|
||||
const source = isAgentHookSource(envelope.source) ? envelope.source : undefined
|
||||
const providerPromptId =
|
||||
source === 'claude' ? normalizeClaudePromptId(envelope.providerPromptId) : undefined
|
||||
const compactTrigger =
|
||||
source === 'claude' &&
|
||||
(envelope.compactTrigger === 'manual' || envelope.compactTrigger === 'auto')
|
||||
? envelope.compactTrigger
|
||||
: undefined
|
||||
const statusDisposition = this.getAgentStatusDisposition(paneKey, {
|
||||
hookEventName,
|
||||
isReplay: envelope.isReplay === true
|
||||
@@ -1930,11 +1959,50 @@ export class AgentHookServer {
|
||||
return
|
||||
}
|
||||
// Why: restore a shed roster only when its digest and turn identity still match the cache.
|
||||
const normalizedPayload = restoreShedStatusFields(
|
||||
let normalizedPayload = restoreShedStatusFields(
|
||||
validatedPayload,
|
||||
envelope.shedFields,
|
||||
this.state.lastStatusByPaneKey.get(paneKey)?.payload
|
||||
)
|
||||
const previousStatus = this.state.lastStatusByPaneKey.get(paneKey)
|
||||
if (hookEventName === 'PreCompact' || hookEventName === 'PostCompact') {
|
||||
if (
|
||||
source !== 'claude' ||
|
||||
compactTrigger === undefined ||
|
||||
normalizedPayload.agentType !== source
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
hookEventName === 'PreCompact' &&
|
||||
envelope.isReplay === true &&
|
||||
(previousStatus?.hookEventName !== 'PreCompact' ||
|
||||
previousStatus.compactTrigger !== compactTrigger ||
|
||||
previousStatus.providerPromptId !== providerPromptId)
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
!canAcceptClaudeCompactTransition(previousStatus, {
|
||||
source,
|
||||
connectionId: trimmedConnectionId,
|
||||
hookEventName,
|
||||
providerPromptId,
|
||||
compactTrigger,
|
||||
providerSession
|
||||
})
|
||||
) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if (
|
||||
source === 'claude' &&
|
||||
compactTrigger !== undefined &&
|
||||
normalizedPayload.prompt.length === 0 &&
|
||||
previousStatus?.payload.prompt
|
||||
) {
|
||||
normalizedPayload = { ...normalizedPayload, prompt: previousStatus.payload.prompt }
|
||||
}
|
||||
if (
|
||||
envelope.providerSessionOnly === true &&
|
||||
!isValidPiProviderSessionOnly(providerSession, normalizedPayload.agentType)
|
||||
@@ -1954,6 +2022,7 @@ export class AgentHookServer {
|
||||
})
|
||||
const event: AgentHookEventPayload = {
|
||||
paneKey,
|
||||
source,
|
||||
launchToken: statusDisposition === 'restart' ? undefined : envelope.launchToken,
|
||||
tabId,
|
||||
worktreeId,
|
||||
@@ -1961,6 +2030,8 @@ export class AgentHookServer {
|
||||
hasExplicitPrompt: envelope.hasExplicitPrompt === true ? true : undefined,
|
||||
promptInteractionKey,
|
||||
hookEventName,
|
||||
providerPromptId,
|
||||
compactTrigger,
|
||||
toolUseId,
|
||||
toolAgentId,
|
||||
toolAgentType,
|
||||
|
||||
@@ -9,14 +9,26 @@ const {
|
||||
translateWslOutputPathsMock,
|
||||
statMock,
|
||||
readFileMock,
|
||||
resolveGitDirMock
|
||||
resolveGitDirMock,
|
||||
moveWorktreeDirectoryToTrashMock,
|
||||
restoreWorktreeDirectoryFromTrashMock,
|
||||
scheduleWorktreeTrashDeletionMock
|
||||
} = vi.hoisted(() => ({
|
||||
gitExecFileAsyncMock: vi.fn(),
|
||||
gitExecFileSyncMock: vi.fn(),
|
||||
translateWslOutputPathsMock: vi.fn((output: string) => output),
|
||||
statMock: vi.fn(),
|
||||
readFileMock: vi.fn(),
|
||||
resolveGitDirMock: vi.fn()
|
||||
resolveGitDirMock: vi.fn(),
|
||||
moveWorktreeDirectoryToTrashMock: vi.fn(),
|
||||
restoreWorktreeDirectoryFromTrashMock: vi.fn(),
|
||||
scheduleWorktreeTrashDeletionMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../worktree-trash', () => ({
|
||||
moveWorktreeDirectoryToTrash: moveWorktreeDirectoryToTrashMock,
|
||||
restoreWorktreeDirectoryFromTrash: restoreWorktreeDirectoryFromTrashMock,
|
||||
scheduleWorktreeTrashDeletion: scheduleWorktreeTrashDeletionMock
|
||||
}))
|
||||
|
||||
vi.mock('./runner', () => ({
|
||||
@@ -45,7 +57,8 @@ import {
|
||||
removeWorktree,
|
||||
_resetWorktreeScanCacheForTests,
|
||||
WORKTREE_LIST_TIMEOUT_MS,
|
||||
WORKTREE_REMOVAL_PREFLIGHT_TIMEOUT_MS
|
||||
WORKTREE_REMOVAL_PREFLIGHT_TIMEOUT_MS,
|
||||
WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS
|
||||
} from './worktree'
|
||||
|
||||
// Why: detectSparseCheckout on main also requires core.sparseCheckout=true in git
|
||||
@@ -57,6 +70,12 @@ const ENABLED_SPARSE_CHECKOUT_CONFIG = '[core]\nsparseCheckout = true\n'
|
||||
beforeEach(() => {
|
||||
clearGitCapabilityStateForTests()
|
||||
_resetWorktreeScanCacheForTests()
|
||||
// Default: the checkout cannot be renamed aside, so removal deletes it in place.
|
||||
moveWorktreeDirectoryToTrashMock.mockReset()
|
||||
moveWorktreeDirectoryToTrashMock.mockResolvedValue(undefined)
|
||||
restoreWorktreeDirectoryFromTrashMock.mockReset()
|
||||
restoreWorktreeDirectoryFromTrashMock.mockResolvedValue(true)
|
||||
scheduleWorktreeTrashDeletionMock.mockReset()
|
||||
})
|
||||
|
||||
type MockResult = {
|
||||
@@ -276,6 +295,8 @@ branch refs/heads/main
|
||||
const calls = getGitCalls()
|
||||
expect(calls).toEqual([
|
||||
'git worktree list --porcelain -z',
|
||||
// The cleanliness probe that decides whether the checkout may be renamed aside.
|
||||
'git status --porcelain --untracked-files=all',
|
||||
'git worktree remove /repo-feature',
|
||||
'git branch -d -- feature/test',
|
||||
'git worktree prune',
|
||||
@@ -283,6 +304,209 @@ branch refs/heads/main
|
||||
])
|
||||
})
|
||||
|
||||
it('renames the checkout aside and deregisters the missing path', async () => {
|
||||
mockGitCommands({
|
||||
'git worktree list --porcelain': {
|
||||
stdout: `worktree /repo
|
||||
HEAD abc123
|
||||
branch refs/heads/main
|
||||
|
||||
worktree /repo-feature
|
||||
HEAD def456
|
||||
branch refs/heads/feature/test
|
||||
`
|
||||
},
|
||||
'git worktree list --porcelain#2': {
|
||||
stdout: `worktree /repo
|
||||
HEAD abc123
|
||||
branch refs/heads/main
|
||||
`
|
||||
}
|
||||
})
|
||||
moveWorktreeDirectoryToTrashMock.mockResolvedValue('/trash/wt-1-abcdef01')
|
||||
|
||||
await removeWorktree('/repo', '/repo-feature')
|
||||
|
||||
const calls = getGitCalls()
|
||||
expect(moveWorktreeDirectoryToTrashMock).toHaveBeenCalledWith('/repo-feature')
|
||||
expect(scheduleWorktreeTrashDeletionMock).toHaveBeenCalledWith('/trash/wt-1-abcdef01')
|
||||
expect(calls).toContain('git worktree remove --force /repo-feature')
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
['worktree', 'remove', '--force', '/repo-feature'],
|
||||
{ cwd: '/repo', timeout: WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS }
|
||||
)
|
||||
expect(calls).not.toContain('git worktree remove /repo-feature')
|
||||
expect(calls).not.toContain('git worktree prune')
|
||||
expect(calls).toContain('git branch -d -- feature/test')
|
||||
})
|
||||
|
||||
it('prunes the registration when deregistering the moved checkout fails', async () => {
|
||||
mockGitCommands({
|
||||
'git worktree list --porcelain': {
|
||||
stdout: `worktree /repo
|
||||
HEAD abc123
|
||||
branch refs/heads/main
|
||||
|
||||
worktree /repo-feature
|
||||
HEAD def456
|
||||
branch refs/heads/feature/test
|
||||
`
|
||||
},
|
||||
'git worktree list --porcelain#2': {
|
||||
stdout: `worktree /repo
|
||||
HEAD abc123
|
||||
branch refs/heads/main
|
||||
`
|
||||
},
|
||||
'git worktree remove --force /repo-feature': {
|
||||
error: new Error('fatal: validation failed, cannot remove working directory')
|
||||
}
|
||||
})
|
||||
moveWorktreeDirectoryToTrashMock.mockResolvedValue('/trash/wt-2-abcdef02')
|
||||
|
||||
await removeWorktree('/repo', '/repo-feature')
|
||||
|
||||
expect(getGitCalls()).toContain('git worktree prune')
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['worktree', 'prune'], {
|
||||
cwd: '/repo',
|
||||
timeout: WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS
|
||||
})
|
||||
expect(
|
||||
gitExecFileAsyncMock.mock.calls.filter(
|
||||
([args, options]) =>
|
||||
args.join(' ') === 'worktree list --porcelain -z' &&
|
||||
options.timeout === WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS
|
||||
)
|
||||
).toHaveLength(2)
|
||||
expect(scheduleWorktreeTrashDeletionMock).toHaveBeenCalledWith('/trash/wt-2-abcdef02')
|
||||
expect(restoreWorktreeDirectoryFromTrashMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('restores the moved checkout and removes in place when the registration survives pruning', async () => {
|
||||
mockGitCommands({
|
||||
'git worktree list --porcelain': {
|
||||
stdout: `worktree /repo
|
||||
HEAD abc123
|
||||
branch refs/heads/main
|
||||
|
||||
worktree /repo-feature
|
||||
HEAD def456
|
||||
branch refs/heads/feature/test
|
||||
`
|
||||
},
|
||||
'git worktree remove --force /repo-feature': {
|
||||
error: new Error('fatal: validation failed, cannot remove working directory')
|
||||
}
|
||||
})
|
||||
moveWorktreeDirectoryToTrashMock.mockResolvedValue('/trash/wt-3-abcdef03')
|
||||
|
||||
await removeWorktree('/repo', '/repo-feature')
|
||||
|
||||
expect(restoreWorktreeDirectoryFromTrashMock).toHaveBeenCalledWith(
|
||||
'/trash/wt-3-abcdef03',
|
||||
'/repo-feature'
|
||||
)
|
||||
expect(scheduleWorktreeTrashDeletionMock).not.toHaveBeenCalled()
|
||||
expect(getGitCalls()).toContain('git worktree remove /repo-feature')
|
||||
})
|
||||
|
||||
it('removes in place when the checkout cannot be renamed aside', async () => {
|
||||
mockGitCommands({
|
||||
'git worktree list --porcelain': {
|
||||
stdout: `worktree /repo
|
||||
HEAD abc123
|
||||
branch refs/heads/main
|
||||
|
||||
worktree /repo-feature
|
||||
HEAD def456
|
||||
branch refs/heads/feature/test
|
||||
`
|
||||
},
|
||||
'git worktree list --porcelain#2': {
|
||||
stdout: `worktree /repo
|
||||
HEAD abc123
|
||||
branch refs/heads/main
|
||||
`
|
||||
}
|
||||
})
|
||||
// Windows open handles and cross-volume renames both surface as an unavailable rename.
|
||||
moveWorktreeDirectoryToTrashMock.mockResolvedValue(undefined)
|
||||
|
||||
await removeWorktree('/repo', '/repo-feature')
|
||||
|
||||
expect(getGitCalls()).toContain('git worktree remove /repo-feature')
|
||||
expect(scheduleWorktreeTrashDeletionMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('never renames a dirty checkout aside', async () => {
|
||||
mockGitCommands({
|
||||
'git worktree list --porcelain': {
|
||||
stdout: `worktree /repo
|
||||
HEAD abc123
|
||||
branch refs/heads/main
|
||||
|
||||
worktree /repo-feature
|
||||
HEAD def456
|
||||
branch refs/heads/feature/test
|
||||
`
|
||||
},
|
||||
'git status --porcelain --untracked-files=all': { stdout: ' M src/app.ts\n' },
|
||||
'git worktree remove /repo-feature': {
|
||||
error: new Error('fatal: contains modified or untracked files, use --force to delete it')
|
||||
}
|
||||
})
|
||||
|
||||
await expect(removeWorktree('/repo', '/repo-feature')).rejects.toThrow(
|
||||
'contains modified or untracked files'
|
||||
)
|
||||
expect(moveWorktreeDirectoryToTrashMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('deletes WSL-hosted checkouts in place inside the distro', async () => {
|
||||
mockGitCommands({
|
||||
'git worktree list --porcelain': {
|
||||
stdout: `worktree /repo
|
||||
HEAD abc123
|
||||
branch refs/heads/main
|
||||
|
||||
worktree /repo-feature
|
||||
HEAD def456
|
||||
branch refs/heads/feature/test
|
||||
`
|
||||
},
|
||||
'git worktree list --porcelain#2': {
|
||||
stdout: `worktree /repo
|
||||
HEAD abc123
|
||||
branch refs/heads/main
|
||||
`
|
||||
}
|
||||
})
|
||||
|
||||
await removeWorktree('/repo', '/repo-feature', false, { wslDistro: 'Ubuntu' })
|
||||
|
||||
expect(moveWorktreeDirectoryToTrashMock).not.toHaveBeenCalled()
|
||||
expect(getGitCalls()).not.toContain('git status --porcelain --untracked-files=all')
|
||||
expect(getGitCalls()).toContain('git worktree remove /repo-feature')
|
||||
})
|
||||
|
||||
it('does not rename a WSL checkout configured for a native Windows repo', async () => {
|
||||
const originalPlatform = process.platform
|
||||
const worktreePath = '\\\\wsl.localhost\\Ubuntu\\home\\dev\\feature'
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
try {
|
||||
mockGitCommands({})
|
||||
|
||||
await removeWorktree('C:\\repo', worktreePath, false, {
|
||||
knownRemovedWorktree: { branch: '', head: '', locked: false }
|
||||
})
|
||||
|
||||
expect(moveWorktreeDirectoryToTrashMock).not.toHaveBeenCalled()
|
||||
expect(getGitCalls()).toEqual([`git worktree remove ${worktreePath}`])
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
|
||||
}
|
||||
})
|
||||
|
||||
it('passes one --force before the worktree path for dirty-file removal', async () => {
|
||||
mockGitCommands({
|
||||
'git worktree list --porcelain': {
|
||||
@@ -339,13 +563,15 @@ branch refs/heads/main
|
||||
expectGitCallOrder(
|
||||
calls,
|
||||
'git worktree remove /repo-feature',
|
||||
'git status --porcelain --untracked-files=all'
|
||||
)
|
||||
expectGitCallOrder(
|
||||
calls,
|
||||
'git status --porcelain --untracked-files=all',
|
||||
'git worktree remove --force /repo-feature'
|
||||
)
|
||||
// The re-proof of cleanliness between the refusal and the forced retry.
|
||||
expect(calls.lastIndexOf('git status --porcelain --untracked-files=all')).toBeGreaterThan(
|
||||
calls.indexOf('git worktree remove /repo-feature')
|
||||
)
|
||||
expect(calls.lastIndexOf('git status --porcelain --untracked-files=all')).toBeLessThan(
|
||||
calls.indexOf('git worktree remove --force /repo-feature')
|
||||
)
|
||||
expect(calls).toContain('git branch -d -- feature/test')
|
||||
})
|
||||
|
||||
@@ -420,7 +646,10 @@ branch refs/heads/feature/test
|
||||
'git worktree remove failed'
|
||||
)
|
||||
expect(getGitCalls()).not.toContain('git worktree remove --force /repo-feature')
|
||||
expect(getGitCalls()).not.toContain('git status --porcelain --untracked-files=all')
|
||||
// Only the pre-rename probe ran: an unrelated failure must not re-prove cleanliness.
|
||||
expect(
|
||||
getGitCalls().filter((call) => call === 'git status --porcelain --untracked-files=all')
|
||||
).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('rejects a locked worktree with stable app-owned copy before invoking remove', async () => {
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
// Real-binary coverage for deferred worktree deletion: the mocked-runner suite cannot prove that Git
|
||||
// accepts `worktree remove --force` on a path Orca just renamed away.
|
||||
import { execFile } from 'node:child_process'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdir, mkdtemp, readdir, realpath, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { promisify } from 'node:util'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { removeWorktree } from './worktree'
|
||||
import {
|
||||
getWorktreeTrashRoot,
|
||||
isWorktreeTrashEntryName,
|
||||
sweepStaleWorktreeTrash,
|
||||
whenWorktreeTrashDeletionsSettled,
|
||||
WORKTREE_TRASH_DIR_NAME
|
||||
} from '../worktree-trash'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
let scratchDir = ''
|
||||
let repoPath = ''
|
||||
let workspaceRoot = ''
|
||||
let worktreePath = ''
|
||||
|
||||
async function git(args: string[], cwd: string): Promise<string> {
|
||||
const { stdout } = await execFileAsync('git', args, { cwd })
|
||||
return stdout
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
// realpath: macOS hands out /var/... temp paths while Git reports /private/var/..., and Orca
|
||||
// matches the worktree it is removing against Git's own list.
|
||||
scratchDir = await realpath(await mkdtemp(join(tmpdir(), 'orca-deferred-worktree-removal-')))
|
||||
repoPath = join(scratchDir, 'repo')
|
||||
workspaceRoot = join(scratchDir, 'workspaces')
|
||||
worktreePath = join(workspaceRoot, 'repo', 'feature')
|
||||
await mkdir(repoPath, { recursive: true })
|
||||
await mkdir(join(workspaceRoot, 'repo'), { recursive: true })
|
||||
await git(['init', '-q'], repoPath)
|
||||
await git(['config', 'user.email', 'deferred@example.invalid'], repoPath)
|
||||
await git(['config', 'user.name', 'Deferred Removal'], repoPath)
|
||||
await writeFile(join(repoPath, 'seed.txt'), 'seed\n')
|
||||
// Committed before the worktree exists so its branch stays merged and branch cleanup can run.
|
||||
await mkdir(join(repoPath, 'node_modules', 'pkg'), { recursive: true })
|
||||
await writeFile(join(repoPath, 'node_modules', 'pkg', 'index.js'), 'module.exports = 1\n')
|
||||
await git(['add', '-A'], repoPath)
|
||||
await git(['commit', '-qm', 'seed'], repoPath)
|
||||
await git(['worktree', 'add', '-q', worktreePath, '-b', 'feature'], repoPath)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await whenWorktreeTrashDeletionsSettled()
|
||||
await rm(scratchDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('deferred worktree removal against the real Git binary', () => {
|
||||
it('clears the registration and deletes the renamed checkout in the background', async () => {
|
||||
const trashRoot = getWorktreeTrashRoot(worktreePath)
|
||||
|
||||
await removeWorktree(repoPath, worktreePath, false, { deleteBranch: false })
|
||||
|
||||
// The user-visible removal is complete: nothing on disk, nothing registered.
|
||||
expect(existsSync(worktreePath)).toBe(false)
|
||||
expect(await git(['worktree', 'list'], repoPath)).not.toContain(worktreePath)
|
||||
// Only the rename path creates this root, so its presence proves the deletion was deferred.
|
||||
expect(existsSync(trashRoot)).toBe(true)
|
||||
expect((await readdir(trashRoot)).every(isWorktreeTrashEntryName)).toBe(true)
|
||||
|
||||
await whenWorktreeTrashDeletionsSettled()
|
||||
expect(await readdir(trashRoot)).toEqual([])
|
||||
})
|
||||
|
||||
it('leaves sibling worktrees registered', async () => {
|
||||
const siblingPath = join(workspaceRoot, 'repo', 'sibling')
|
||||
await git(['worktree', 'add', '-q', siblingPath, '-b', 'sibling'], repoPath)
|
||||
|
||||
await removeWorktree(repoPath, worktreePath, false, { deleteBranch: false })
|
||||
|
||||
expect(await git(['worktree', 'list'], repoPath)).toContain(siblingPath)
|
||||
expect(existsSync(siblingPath)).toBe(true)
|
||||
})
|
||||
|
||||
it('deletes the branch exactly as the in-place removal did', async () => {
|
||||
await removeWorktree(repoPath, worktreePath, false)
|
||||
|
||||
expect(await git(['branch', '--list', 'feature'], repoPath)).toBe('')
|
||||
})
|
||||
|
||||
it('refuses to move a dirty checkout aside', async () => {
|
||||
await writeFile(join(worktreePath, 'seed.txt'), 'edited\n')
|
||||
|
||||
await expect(removeWorktree(repoPath, worktreePath, false)).rejects.toThrow()
|
||||
expect(existsSync(join(worktreePath, 'seed.txt'))).toBe(true)
|
||||
expect(await git(['worktree', 'list'], repoPath)).toContain(worktreePath)
|
||||
expect(existsSync(getWorktreeTrashRoot(worktreePath))).toBe(false)
|
||||
})
|
||||
|
||||
it('sweeps trash a previous run left behind', async () => {
|
||||
const stalePath = join(
|
||||
workspaceRoot,
|
||||
'repo',
|
||||
WORKTREE_TRASH_DIR_NAME,
|
||||
'wt-1700000000000-deadbeef'
|
||||
)
|
||||
await mkdir(join(stalePath, 'node_modules'), { recursive: true })
|
||||
|
||||
await sweepStaleWorktreeTrash([workspaceRoot])
|
||||
|
||||
expect(existsSync(stalePath)).toBe(false)
|
||||
expect(existsSync(worktreePath)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,85 @@
|
||||
// Manual benchmark for the `worktree.remove.git_remove` stage on a large checkout.
|
||||
// Opt in (it builds ~100k files): ORCA_WORKTREE_REMOVAL_BENCH=1 pnpm exec vitest run \
|
||||
// --config config/vitest.config.ts src/main/git/worktree-removal-large-tree.bench.test.ts
|
||||
import { execFile } from 'node:child_process'
|
||||
import { mkdir, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { promisify } from 'node:util'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { removeWorktree } from './worktree'
|
||||
import { getWorktreeTrashRoot, whenWorktreeTrashDeletionsSettled } from '../worktree-trash'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const describeBench = process.env.ORCA_WORKTREE_REMOVAL_BENCH ? describe : describe.skip
|
||||
|
||||
const FIXTURE_DIRECTORIES = 200
|
||||
const FIXTURE_FILES_PER_DIRECTORY = 500
|
||||
|
||||
describeBench('worktree removal on a large checkout', () => {
|
||||
let scratchDir = ''
|
||||
let repoPath = ''
|
||||
let worktreePath = ''
|
||||
|
||||
async function git(args: string[], cwd: string): Promise<void> {
|
||||
await execFileAsync('git', args, { cwd, maxBuffer: 64 * 1024 * 1024 })
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
scratchDir = await mkdtemp(join(tmpdir(), 'orca-worktree-removal-bench-'))
|
||||
repoPath = join(scratchDir, 'repo')
|
||||
worktreePath = join(scratchDir, 'workspaces', 'repo', 'bench')
|
||||
await mkdir(repoPath, { recursive: true })
|
||||
await git(['init', '-q', '-b', 'main'], repoPath)
|
||||
await git(['config', 'user.email', 'bench@example.invalid'], repoPath)
|
||||
await git(['config', 'user.name', 'Bench'], repoPath)
|
||||
await writeFile(join(repoPath, 'seed.txt'), 'seed\n')
|
||||
await git(['add', 'seed.txt'], repoPath)
|
||||
await git(['commit', '-qm', 'seed'], repoPath)
|
||||
await mkdir(join(scratchDir, 'workspaces', 'repo'), { recursive: true })
|
||||
await git(['worktree', 'add', '-q', worktreePath, '-b', 'bench'], repoPath)
|
||||
|
||||
// A committed synthetic node_modules-shaped tree: the removal must be clean-checked and deleted.
|
||||
for (let dirIndex = 0; dirIndex < FIXTURE_DIRECTORIES; dirIndex += 1) {
|
||||
const dir = join(worktreePath, 'node_modules', `pkg-${dirIndex}`)
|
||||
await mkdir(dir, { recursive: true })
|
||||
await Promise.all(
|
||||
Array.from({ length: FIXTURE_FILES_PER_DIRECTORY }, (_unused, fileIndex) =>
|
||||
writeFile(join(dir, `file-${fileIndex}.js`), `module.exports = ${fileIndex}\n`)
|
||||
)
|
||||
)
|
||||
}
|
||||
await git(['add', '-A'], worktreePath)
|
||||
await git(['commit', '-qm', 'large tree'], worktreePath)
|
||||
}, 900_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await whenWorktreeTrashDeletionsSettled()
|
||||
if (scratchDir) {
|
||||
await rm(scratchDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('returns from removeWorktree without waiting for the recursive delete', async () => {
|
||||
const startedAt = Date.now()
|
||||
await removeWorktree(repoPath, worktreePath, false, { deleteBranch: false })
|
||||
const userVisibleMs = Date.now() - startedAt
|
||||
|
||||
const trashRoot = getWorktreeTrashRoot(worktreePath)
|
||||
console.log(
|
||||
`[bench] files=${FIXTURE_DIRECTORIES * FIXTURE_FILES_PER_DIRECTORY} removeWorktree=${userVisibleMs}ms`
|
||||
)
|
||||
expect(existsSync(worktreePath)).toBe(false)
|
||||
const { stdout } = await execFileAsync('git', ['worktree', 'list'], { cwd: repoPath })
|
||||
expect(stdout).not.toContain(worktreePath)
|
||||
|
||||
const backgroundStartedAt = Date.now()
|
||||
await whenWorktreeTrashDeletionsSettled()
|
||||
console.log(
|
||||
`[bench] background deletion=${Date.now() - backgroundStartedAt}ms (trash root ${trashRoot})`
|
||||
)
|
||||
expect(existsSync(trashRoot)).toBe(true)
|
||||
expect(await readdir(trashRoot)).toEqual([])
|
||||
}, 900_000)
|
||||
})
|
||||
@@ -4,13 +4,17 @@
|
||||
without a meaningful boundary. */
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { gitExecFileAsyncMock, gitExecFileSyncMock, translateWslOutputPathsMock } = vi.hoisted(
|
||||
() => ({
|
||||
gitExecFileAsyncMock: vi.fn(),
|
||||
gitExecFileSyncMock: vi.fn(),
|
||||
translateWslOutputPathsMock: vi.fn((output: string) => output)
|
||||
})
|
||||
)
|
||||
const {
|
||||
gitExecFileAsyncMock,
|
||||
gitExecFileSyncMock,
|
||||
translateWslOutputPathsMock,
|
||||
moveWorktreeDirectoryToTrashMock
|
||||
} = vi.hoisted(() => ({
|
||||
gitExecFileAsyncMock: vi.fn(),
|
||||
gitExecFileSyncMock: vi.fn(),
|
||||
translateWslOutputPathsMock: vi.fn((output: string) => output),
|
||||
moveWorktreeDirectoryToTrashMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./runner', () => ({
|
||||
gitExecFileAsync: gitExecFileAsyncMock,
|
||||
@@ -18,6 +22,13 @@ vi.mock('./runner', () => ({
|
||||
translateWslOutputPaths: translateWslOutputPathsMock
|
||||
}))
|
||||
|
||||
// Default: the checkout cannot be renamed aside, so removal deletes it in place.
|
||||
vi.mock('../worktree-trash', () => ({
|
||||
moveWorktreeDirectoryToTrash: moveWorktreeDirectoryToTrashMock.mockResolvedValue(undefined),
|
||||
restoreWorktreeDirectoryFromTrash: vi.fn().mockResolvedValue(true),
|
||||
scheduleWorktreeTrashDeletion: vi.fn()
|
||||
}))
|
||||
|
||||
import { clearGitCapabilityStateForTests } from './git-capability-state'
|
||||
|
||||
import {
|
||||
@@ -1792,6 +1803,7 @@ describe('removeWorktree', () => {
|
||||
|
||||
it('uses safe `branch -d` and preserves a branch with unmerged commits', async () => {
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: beforeRemoval }) // list before
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // clean probe before the rename attempt
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree remove
|
||||
// Git refuses to delete an unmerged branch with `-d`.
|
||||
gitExecFileAsyncMock.mockRejectedValueOnce(new Error('not fully merged')) // branch -d
|
||||
@@ -1808,6 +1820,7 @@ describe('removeWorktree', () => {
|
||||
|
||||
it('deletes the branch when `branch -d` succeeds (fully merged)', async () => {
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: beforeRemoval }) // list before
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // clean probe before the rename attempt
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree remove
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // branch -d succeeds
|
||||
|
||||
@@ -1822,6 +1835,7 @@ describe('removeWorktree', () => {
|
||||
})
|
||||
|
||||
it('reuses known removed worktree metadata instead of relisting before removal', async () => {
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // clean probe before the rename attempt
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree remove
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // branch -d succeeds
|
||||
|
||||
@@ -1833,6 +1847,7 @@ describe('removeWorktree', () => {
|
||||
})
|
||||
|
||||
expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0])).toEqual([
|
||||
['status', '--porcelain', '--untracked-files=all'],
|
||||
['worktree', 'remove', '/repo-feature'],
|
||||
['branch', '-d', '--', 'feature/test']
|
||||
])
|
||||
@@ -1840,6 +1855,7 @@ describe('removeWorktree', () => {
|
||||
|
||||
it('prunes and retries branch deletion only when Git reports a checked-out branch', async () => {
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: beforeRemoval }) // list before
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // clean probe before the rename attempt
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree remove
|
||||
gitExecFileAsyncMock.mockRejectedValueOnce(
|
||||
new Error("error: cannot delete branch 'feature/test' used by worktree at '/repo-stale'")
|
||||
@@ -1851,6 +1867,7 @@ describe('removeWorktree', () => {
|
||||
|
||||
expect(gitExecFileAsyncMock.mock.calls.map((call) => call[0])).toEqual([
|
||||
['worktree', 'list', '--porcelain', '-z'],
|
||||
['status', '--porcelain', '--untracked-files=all'],
|
||||
['worktree', 'remove', '/repo-feature'],
|
||||
['branch', '-d', '--', 'feature/test'],
|
||||
['worktree', 'prune'],
|
||||
|
||||
+104
-16
@@ -7,6 +7,13 @@ import {
|
||||
} from '../../shared/git-branch-cleanup'
|
||||
import { resolveWorktreeAddBaseRef } from '../../shared/worktree-base-ref'
|
||||
import { withSpan } from '../observability/tracer'
|
||||
import { withWorktreeRemoveStageSpan } from '../observability/instrumentation'
|
||||
import {
|
||||
moveWorktreeDirectoryToTrash,
|
||||
restoreWorktreeDirectoryFromTrash,
|
||||
scheduleWorktreeTrashDeletion
|
||||
} from '../worktree-trash'
|
||||
import { parseWslPath } from '../wsl'
|
||||
import type {
|
||||
GitWorktreeInfo,
|
||||
LocalBaseRefRefreshResult,
|
||||
@@ -87,6 +94,7 @@ const PRUNABLE_EXISTENCE_PROBE_CONCURRENCY = 8
|
||||
// Why: bound `git worktree add` so a OneDrive cloud-placeholder stall fails fast (STA-1292); generous enough for a legit large checkout (#7225).
|
||||
export const WORKTREE_ADD_TIMEOUT_MS = 180_000
|
||||
export const WORKTREE_REMOVAL_PREFLIGHT_TIMEOUT_MS = 30_000
|
||||
export const WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS = 30_000
|
||||
// Why: one wedged shared scan otherwise hangs every later list, including create's post-add re-list.
|
||||
export const WORKTREE_LIST_TIMEOUT_MS = 30_000
|
||||
|
||||
@@ -1131,23 +1139,27 @@ async function performRemoveWorktree(
|
||||
// Why: callers outside the IPC/runtime preflight must not bypass Git's lock contract or rely on localized stderr after side effects.
|
||||
assertWorktreeUnlockedForRemoval(removedWorktree)
|
||||
|
||||
const args = ['worktree', 'remove']
|
||||
if (force) {
|
||||
args.push('--force')
|
||||
}
|
||||
args.push(worktreePath)
|
||||
try {
|
||||
await gitExecFileAsync(args, gitExecOptions(repoPath, options))
|
||||
} catch (error) {
|
||||
if (force || !isSubmoduleWorktreeRemovalRefusal(error)) {
|
||||
throw error
|
||||
if (
|
||||
!(await tryRemoveWorktreeWithDeferredDirectoryDeletion(repoPath, worktreePath, force, options))
|
||||
) {
|
||||
const args = ['worktree', 'remove']
|
||||
if (force) {
|
||||
args.push('--force')
|
||||
}
|
||||
args.push(worktreePath)
|
||||
try {
|
||||
await gitExecFileAsync(args, gitExecOptions(repoPath, options))
|
||||
} catch (error) {
|
||||
if (force || !isSubmoduleWorktreeRemovalRefusal(error)) {
|
||||
throw error
|
||||
}
|
||||
// Why: Git refuses non-force removal of a worktree with an initialised submodule even when clean; re-prove cleanliness, then --force.
|
||||
await assertWorktreeCleanForRemoval(worktreePath, false, options)
|
||||
await gitExecFileAsync(
|
||||
['worktree', 'remove', '--force', worktreePath],
|
||||
gitExecOptions(repoPath, options)
|
||||
)
|
||||
}
|
||||
// Why: Git refuses non-force removal of a worktree with an initialised submodule even when clean; re-prove cleanliness, then --force.
|
||||
await assertWorktreeCleanForRemoval(worktreePath, false, options)
|
||||
await gitExecFileAsync(
|
||||
['worktree', 'remove', '--force', worktreePath],
|
||||
gitExecOptions(repoPath, options)
|
||||
)
|
||||
}
|
||||
|
||||
if (!branchName) {
|
||||
@@ -1164,6 +1176,82 @@ async function performRemoveWorktree(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename the checkout into a sibling trash directory and clear Git's registration for the
|
||||
* now-missing path, so the multi-GB recursive delete runs after this removal has returned.
|
||||
* Returns false when the caller must let `git worktree remove` delete the directory inline.
|
||||
*/
|
||||
async function tryRemoveWorktreeWithDeferredDirectoryDeletion(
|
||||
repoPath: string,
|
||||
worktreePath: string,
|
||||
force: boolean,
|
||||
options: RemoveWorktreeOptions
|
||||
): Promise<boolean> {
|
||||
// Why: WSL-owned checkouts are deleted inside the distro, so Node on Windows must not rename them.
|
||||
if (options.wslDistro || parseWslPath(worktreePath)) {
|
||||
return false
|
||||
}
|
||||
if (!force) {
|
||||
try {
|
||||
// Why: `git worktree remove` re-checks cleanliness as it removes; prove the same thing here or leave removal to Git.
|
||||
await assertWorktreeCleanForRemoval(worktreePath, false, options)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const trashPath = await withWorktreeRemoveStageSpan('trash_rename', 'local', () =>
|
||||
moveWorktreeDirectoryToTrash(worktreePath)
|
||||
)
|
||||
if (!trashPath) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
await clearGitRegistrationForMissingWorktree(repoPath, worktreePath, options)
|
||||
} catch (error) {
|
||||
// Why: put the checkout back so the in-place removal below still sees the worktree Git registered.
|
||||
if (await restoreWorktreeDirectoryFromTrash(trashPath, worktreePath)) {
|
||||
return false
|
||||
}
|
||||
throw error
|
||||
}
|
||||
scheduleWorktreeTrashDeletion(trashPath)
|
||||
return true
|
||||
}
|
||||
|
||||
async function clearGitRegistrationForMissingWorktree(
|
||||
repoPath: string,
|
||||
worktreePath: string,
|
||||
options: RemoveWorktreeOptions
|
||||
): Promise<void> {
|
||||
const registrationOptions = {
|
||||
...options,
|
||||
timeout: options.timeout ?? WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS
|
||||
}
|
||||
try {
|
||||
// Removing an already-missing directory is accepted back to the Git 2.25 baseline and touches only this entry.
|
||||
await gitExecFileAsync(
|
||||
['worktree', 'remove', '--force', worktreePath],
|
||||
gitExecOptions(repoPath, registrationOptions)
|
||||
)
|
||||
return
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[git] Failed to deregister the moved worktree "${worktreePath}"; pruning instead`,
|
||||
error
|
||||
)
|
||||
}
|
||||
|
||||
await gitExecFileAsync(['worktree', 'prune'], gitExecOptions(repoPath, registrationOptions))
|
||||
// Strict (not the shared scan): an unreadable repo must not read as proof that the row is gone.
|
||||
const stillRegistered = (await listWorktreesStrict(repoPath, registrationOptions)).some(
|
||||
(worktree) => areWorktreePathsEqual(worktree.path, worktreePath)
|
||||
)
|
||||
if (stillRegistered) {
|
||||
throw new Error(`Git still reports a registration for "${worktreePath}" after pruning it.`)
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteBranchAfterWorktreeRemoval(
|
||||
repoPath: string,
|
||||
branchName: string,
|
||||
|
||||
@@ -221,6 +221,7 @@ import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from './codex/cod
|
||||
import { normalizeRuntimePathForComparison } from '../shared/cross-platform-path'
|
||||
import type { AgentProviderSessionMetadata } from '../shared/agent-session-resume'
|
||||
import { getDefaultWslDistro } from './wsl'
|
||||
import { collectWorktreeTrashSweepRoots, sweepStaleWorktreeTrash } from './worktree-trash'
|
||||
import { ClaudeAccountService } from './claude-accounts/service'
|
||||
import { ClaudeRuntimeAuthService } from './claude-accounts/runtime-auth-service'
|
||||
import {
|
||||
@@ -2627,6 +2628,13 @@ void app.whenReady().then(async () => {
|
||||
// Why: externally started serve-sim processes must stay independent — only Orca-managed/attached helpers belong to a workspace.
|
||||
const emulatorBridge = new EmulatorBridge()
|
||||
runtimeService.setEmulatorBridge(emulatorBridge)
|
||||
// Why: worktree deletion renames the checkout aside and deletes it in the background, so a quit or
|
||||
// crash mid-delete can leave the moved directory on disk.
|
||||
void sweepStaleWorktreeTrash(
|
||||
collectWorktreeTrashSweepRoots(store.getRepos(), store.getSettings())
|
||||
).catch((error) => {
|
||||
console.warn('[worktrees] Failed to sweep leftover worktree directories:', error)
|
||||
})
|
||||
nativeTheme.themeSource = store.getSettings().theme ?? 'system'
|
||||
if (codexRuntimeHome.isHostSystemDefaultRealHomeSelected()) {
|
||||
// Why: establish capability before managed-hook reconciliation so an
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user