mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 08:03:14 +00:00
Merge branch 'main' of https://github.com/stablyai/orca into the-sandbox-flag-is
This commit is contained in:
@@ -11,6 +11,7 @@ on:
|
||||
- 'config/scripts/computer-use-smoke.mjs'
|
||||
- 'config/scripts/computer-use-smoke.test.mjs'
|
||||
- 'config/scripts/daemon-boot-smoke.mjs'
|
||||
- 'config/scripts/windows-daemon-workspace-close-repro.mjs'
|
||||
- 'config/scripts/verify-computer-native.mjs'
|
||||
# Why: the native-smoke job boots the built terminal daemon under plain
|
||||
# Node, so any change to the daemon bundle graph or the main build must
|
||||
@@ -111,6 +112,11 @@ jobs:
|
||||
# the PR when the built daemon cannot start on ubuntu-22.04 / windows.
|
||||
- name: Daemon boot smoke
|
||||
run: node config/scripts/daemon-boot-smoke.mjs
|
||||
# Why: workspace removal overlaps graceful renderer teardown with the
|
||||
# forced main-process sweep; exercise that exact pair against real ConPTY.
|
||||
- name: Windows daemon workspace-close repro
|
||||
if: runner.os == 'Windows'
|
||||
run: node config/scripts/windows-daemon-workspace-close-repro.mjs
|
||||
- if: runner.os == 'Linux'
|
||||
env:
|
||||
ORCA_COMPUTER_E2E: '1'
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install native build tools
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential python3 zsh
|
||||
run: sudo apt-get update && sudo apt-get install -y build-essential python3 zlib1g-dev zsh
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
@@ -103,6 +103,33 @@ jobs:
|
||||
- name: Typecheck
|
||||
run: pnpm typecheck
|
||||
|
||||
# Why: real old Git diagnostics differ from mocked errors. Keep the
|
||||
# fallback predicates executable across the baseline, transition, and
|
||||
# current command shapes so a newly added flag cannot silently regress.
|
||||
- name: Verify Git binary compatibility matrix
|
||||
run: |
|
||||
archive="$RUNNER_TEMP/git-2.25.5.tar.gz"
|
||||
source="$RUNNER_TEMP/git-2.25.5"
|
||||
curl -fsSL https://www.kernel.org/pub/software/scm/git/git-2.25.5.tar.gz -o "$archive"
|
||||
echo "41662c52fc16fec4963bfc41075e71f8ead6b5e386797eb6f9a1111ff95a8ddf $archive" \
|
||||
| sha256sum --check
|
||||
mkdir -p "$source"
|
||||
tar -xzf "$archive" -C "$source" --strip-components=1
|
||||
make -C "$source" -j2 NO_GETTEXT=YesPlease NO_TCLTK=YesPlease NO_PYTHON=YesPlease git
|
||||
ORCA_GIT_COMPAT_BINARY="$source/git" ORCA_GIT_COMPAT_VERSION="2.25.5" \
|
||||
pnpm exec vitest run --config config/vitest.config.ts \
|
||||
src/shared/git-binary-compatibility.test.ts
|
||||
|
||||
for spec in \
|
||||
"alpine/git:edge-2.38.1|2.38.1" \
|
||||
"alpine/git:v2.49.1|2.49.1"; do
|
||||
image="${spec%%|*}"
|
||||
version="${spec#*|}"
|
||||
ORCA_GIT_COMPAT_IMAGE="$image" ORCA_GIT_COMPAT_VERSION="$version" \
|
||||
pnpm exec vitest run --config config/vitest.config.ts \
|
||||
src/shared/git-binary-compatibility.test.ts
|
||||
done
|
||||
|
||||
# Why: postinstall rebuilds better-sqlite3 for Electron's ABI via
|
||||
# @electron/rebuild, but vitest runs under system Node.js. Rebuild
|
||||
# it for Node so orchestration tests can load the native module.
|
||||
|
||||
@@ -41,6 +41,11 @@ on:
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
version_suffix:
|
||||
description: Extra prerelease identifier appended to an rc version (e.g. "perf" -> 1.2.3-rc.4.perf). rc kind only.
|
||||
required: false
|
||||
type: string
|
||||
default: ''
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -192,6 +197,7 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
KIND: ${{ github.event_name == 'schedule' && 'rc' || inputs.kind }}
|
||||
VERSION_SUFFIX: ${{ github.event_name == 'schedule' && '' || inputs.version_suffix }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
@@ -366,6 +372,18 @@ jobs:
|
||||
fi
|
||||
new="${base}-rc.$((highest_rc + 1))"
|
||||
fi
|
||||
if [[ -n "${VERSION_SUFFIX:-}" ]]; then
|
||||
# Why a dot-appended identifier (rc.N.perf): it sorts just
|
||||
# above its own base rc.N but BELOW rc.N+1, so suffixed side-
|
||||
# branch builds never outrank the main RC series and cannot
|
||||
# hijack the update channel; clients find them by matching the
|
||||
# identifier ("perf") in the prerelease components.
|
||||
if [[ ! "$VERSION_SUFFIX" =~ ^[0-9A-Za-z]+$ ]]; then
|
||||
echo "::error::version_suffix must be alphanumeric, got: $VERSION_SUFFIX" >&2
|
||||
exit 1
|
||||
fi
|
||||
new="${new}.${VERSION_SUFFIX}"
|
||||
fi
|
||||
;;
|
||||
patch|minor|major)
|
||||
new="$(bump "$latest_stable" "$KIND")"
|
||||
@@ -808,7 +826,10 @@ jobs:
|
||||
TAG: ${{ needs.cut.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then
|
||||
# Why the optional trailing identifier: suffixed side-branch RCs
|
||||
# (vX.Y.Z-rc.N.perf) are rc-channel prerelease builds — same telemetry
|
||||
# identity as plain RCs.
|
||||
if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+(\.[0-9A-Za-z]+)?$ ]]; then
|
||||
identity=rc
|
||||
elif [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
identity=stable
|
||||
|
||||
@@ -96,7 +96,10 @@ jobs:
|
||||
TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then
|
||||
# Why the optional trailing identifier: suffixed side-branch RCs
|
||||
# (vX.Y.Z-rc.N.perf) are rc-channel prerelease builds — same telemetry
|
||||
# identity as plain RCs.
|
||||
if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+(\.[0-9A-Za-z]+)?$ ]]; then
|
||||
identity=rc
|
||||
elif [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
identity=stable
|
||||
|
||||
@@ -122,3 +122,6 @@ src/renderer/src/i18n/locales/.zh-catalog-cache.json
|
||||
src/renderer/src/i18n/locales/.ko-catalog-cache.json
|
||||
src/renderer/src/i18n/locales/.ja-catalog-cache.json
|
||||
src/renderer/src/i18n/locales/.es-catalog-cache.json
|
||||
|
||||
# Bench result JSONs are working artifacts; headline numbers live in notes/terminal-performance-initiative.md
|
||||
tools/benchmarks/results/terminal-pipeline-*.json
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
shamefully-hoist=true
|
||||
minimum-release-age=4320
|
||||
minimum-release-age-exclude[]=electron@42.3.3
|
||||
minimum-release-age-exclude[]=electron@43.1.0
|
||||
minimum-release-age-exclude[]=node-abi@4.33.0
|
||||
minimum-release-age-exclude[]=typescript@7.0.2
|
||||
minimum-release-age-exclude[]=@typescript/typescript-*
|
||||
minimum-release-age-exclude[]=mermaid@11.15.0
|
||||
minimum-release-age-exclude[]=@mermaid-js/parser@1.1.1
|
||||
minimum-release-age-exclude[]=serve-sim@0.1.40
|
||||
|
||||
@@ -36,6 +36,18 @@ Orca targets macOS, Linux, and Windows. Keep all platform-dependent behavior beh
|
||||
|
||||
All changes must consider the SSH use case. Don't assume local-only execution.
|
||||
|
||||
## Git Binary Compatibility
|
||||
|
||||
Orca runs the user's Git binary on native, WSL, and SSH hosts, which may all have different versions. Treat Git 2.25 as the core-workflow baseline and follow [`docs/reference/git-compatibility.md`](./docs/reference/git-compatibility.md).
|
||||
|
||||
When adding or changing a Git command:
|
||||
|
||||
- Check when every subcommand and option was introduced. For newer behavior, keep a baseline-compatible fallback or degrade safely.
|
||||
- Use `GitCapabilityCache` with a narrow unsupported-error predicate so recurring operations do not retry a known-invalid command. Do not rely only on `git --version`; wrappers such as `simple-git` do not remove host-version differences.
|
||||
- Scope capability state to the host that executes Git: native, WSL distro, SSH provider, or relay connection. Cover the first fallback, later cached calls, concurrent probes, and relevant host isolation in tests.
|
||||
- Keep the real-binary compatibility contract in PR CI current. When adopting a newer Git feature, add its version boundary so the preferred command and fallback both run against representative Git releases.
|
||||
- Preserve commands that begin with global Git options such as `-c` before the subcommand, including auto-maintenance suppression used by worktree-create fetches.
|
||||
|
||||
## Git Provider Compatibility
|
||||
|
||||
Source-control and review changes must consider GitLab and other supported git providers, not only GitHub. Keep provider-specific behavior behind explicit checks, and avoid GitHub-only naming for generic review concepts.
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+391
-25
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"updatedAt": "2026-07-09",
|
||||
"updatedAt": "2026-07-10",
|
||||
"policy": {
|
||||
"maturityLevels": [
|
||||
"experimental",
|
||||
@@ -969,7 +969,7 @@
|
||||
"id": "terminal-platform.windows-conpty-liveness",
|
||||
"title": "Windows ConPTY terminals stay input-live, render-live, and geometry-live",
|
||||
"maturity": "experimental",
|
||||
"protection": "none",
|
||||
"protection": "partial",
|
||||
"owner": "terminal-platform",
|
||||
"layer": "windows-electron-conpty",
|
||||
"surfaces": [
|
||||
@@ -987,34 +987,77 @@
|
||||
"daemon",
|
||||
"wsl"
|
||||
],
|
||||
"coveredPlatforms": [],
|
||||
"coveredProviders": [],
|
||||
"coverageNotes": "Registered gap only; no executable coverage is wired yet.",
|
||||
"coveredPlatforms": [
|
||||
"windows"
|
||||
],
|
||||
"coveredProviders": [
|
||||
"daemon"
|
||||
],
|
||||
"coverageNotes": "Issue #8048 now has deterministic wrapper and cold-restore re-anchor tests plus a Windows PR-CI harness that drives the built daemon through 25 real ConPTY workspace-close races while an unrelated witness PTY stays alive. Keyboard reset, CJK repaint, WSL, and full visible Electron coverage remain gaps.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/pull/6541",
|
||||
"https://github.com/stablyai/orca/pull/6858",
|
||||
"https://github.com/stablyai/orca/pull/6876",
|
||||
"https://github.com/stablyai/orca/pull/6968",
|
||||
"https://github.com/stablyai/orca/pull/6970",
|
||||
"https://github.com/stablyai/orca/pull/6999"
|
||||
"https://github.com/stablyai/orca/pull/6999",
|
||||
"https://github.com/stablyai/orca/issues/8048"
|
||||
],
|
||||
"invariant": "Windows local and daemon terminals must spawn with the intended shell, survive overlapping graceful/forced workspace teardown without affecting unrelated PTYs, retain recovered scrollback across the fresh daemon's first checkpoint, accept normal Enter/Backspace/Arrow input after agent or TUI exit, render cursor/CJK/wide-glyph redraws without stale cells, and converge to nonzero applied size.",
|
||||
"oracle": "The issue #8048 slice asserts one node-pty ConPTY close for a graceful-then-force sequence, atomically seeds recovered history before fresh shell output and re-anchoring, preserves recovery after seed failure plus adapter restart, and runs 25 built-daemon close races while checking victim session/PID reaping, a stable daemon PID, and a live witness PTY. A broader Windows live gate still needs shell input, resize, cursor, and CJK/wide-glyph pixel evidence.",
|
||||
"commands": [
|
||||
"pnpm vitest run src/main/daemon/pty-subprocess.test.ts src/main/daemon/daemon-pty-adapter.test.ts",
|
||||
"pnpm build:electron-vite && node config/scripts/windows-daemon-workspace-close-repro.mjs",
|
||||
"node config/scripts/windows-daemon-workspace-close-repro.mjs"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/main/daemon/pty-subprocess.test.ts",
|
||||
"src/main/daemon/daemon-pty-adapter.test.ts",
|
||||
"config/scripts/windows-daemon-workspace-close-repro.mjs"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/main/daemon/pty-subprocess.test.ts",
|
||||
"assertions": [
|
||||
"graceful kill followed by force and dispose invokes Windows node-pty kill exactly once and never retries the dead child PID"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/daemon/daemon-pty-adapter.test.ts",
|
||||
"assertions": [
|
||||
"the first checkpoint orders recovered scrollback before synchronously emitted fresh-shell startup output",
|
||||
"a failed atomic history seed remains non-authoritative across adapter restart and cannot overwrite the recovery files"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "config/scripts/windows-daemon-workspace-close-repro.mjs",
|
||||
"assertions": [
|
||||
"all 25 victim sessions and OS PIDs are reaped while the built daemon PID and an unrelated witness PowerShell remain alive"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-07-10",
|
||||
"runner": "local",
|
||||
"platform": "windows",
|
||||
"command": "node config/scripts/windows-daemon-workspace-close-repro.mjs",
|
||||
"result": "passed",
|
||||
"durationSeconds": 7.6,
|
||||
"summary": "All 25 victim sessions and OS PIDs were reaped while the built daemon and witness PTY survived the real ConPTY workspace-close races. The double-close, history ordering, and seed-failure restart regressions produced intentional red failures before their fixes and passed afterward."
|
||||
}
|
||||
],
|
||||
"invariant": "Windows local and daemon terminals must spawn with the intended shell, accept normal Enter/Backspace/Arrow input after agent or TUI exit, render cursor/CJK/wide-glyph redraws without stale cells, and converge to nonzero applied size.",
|
||||
"oracle": "A Windows live gate proves shell echo, command submission, keyboard reset, resize readback, cursor visibility, and CJK/wide-glyph redraw using PTY input logs plus visible buffer/pixel evidence.",
|
||||
"commands": [],
|
||||
"testFiles": [],
|
||||
"assertionRefs": [],
|
||||
"evidenceRuns": [],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 90,
|
||||
"scope": "Windows focused Electron ConPTY gate"
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "not-started",
|
||||
"evidence": "Windows live E2E remains a known weak area; gate must soak before blocking."
|
||||
"status": "unknown",
|
||||
"evidence": "The built-daemon issue #8048 harness passed locally once and is wired into Windows PR CI; it needs repeated CI history before promotion."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "missing",
|
||||
"evidence": "Needs proof against stale keyboard-protocol mode, shell resolution failure, dropped resize, and CJK repaint regression."
|
||||
"status": "partial",
|
||||
"evidence": "The ConPTY double-close and cold-restore re-anchor assertions were each observed failing before the fix and passing afterward. Keyboard protocol, shell resolution, resize, and CJK repaint still need red/green proof."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
@@ -1026,8 +1069,8 @@
|
||||
"Split shell parity, keyboard reset, and CJK repaint into smaller gates if a combined gate is flaky."
|
||||
],
|
||||
"knownGaps": [
|
||||
"No manifest command yet.",
|
||||
"Real IME composition may require a separate lower-layer/native-text-forwarding gate."
|
||||
"Real IME composition may require a separate lower-layer/native-text-forwarding gate.",
|
||||
"The built-daemon harness proves process/session liveness but not renderer pixels; visible shell input, resize, cursor, and CJK repaint remain uncovered."
|
||||
],
|
||||
"demotionRule": "Cannot promote while Windows E2E is flaky, silently skipped, or screenshot-only."
|
||||
},
|
||||
@@ -1178,6 +1221,9 @@
|
||||
"surfaces": [
|
||||
"daemon stream",
|
||||
"main PTY batching",
|
||||
"runtime path provenance",
|
||||
"runtime serve-sim detection",
|
||||
"runtime terminal wait detection",
|
||||
"renderer ACK",
|
||||
"xterm scheduler",
|
||||
"hidden output"
|
||||
@@ -1197,20 +1243,27 @@
|
||||
"macos"
|
||||
],
|
||||
"coveredProviders": [],
|
||||
"coverageNotes": "Local macOS evidence over the main-process pending-output caps that exist on main@1282f5c2d. Daemon stream write(false)/drain contracts, cross-session drain priority, and bounded queued tails arrive with the pending perf slice; live flood/latency artifacts remain gaps.",
|
||||
"coverageNotes": "Local macOS evidence covers the existing main-process pending-output caps plus deterministic runtime path-provenance history reuse, idle-path serve-sim detection, and saturated-tail wait detection. Daemon stream write(false)/drain contracts, cross-session drain priority, and bounded queued tails arrive with the pending perf slice; live flood/latency artifacts remain gaps.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/pull/6836",
|
||||
"https://github.com/stablyai/orca/pull/6858",
|
||||
"https://github.com/stablyai/orca/pull/7002",
|
||||
"https://github.com/stablyai/orca/pull/7054"
|
||||
],
|
||||
"invariant": "High-volume terminal output must stay bounded across daemon socket writes, main-to-renderer in-flight bytes, renderer scheduler queues, and hidden-output restore without starving focused input.",
|
||||
"oracle": "The current executable slice injects daemon socket backpressure and main-process renderer backlog pressure, then asserts write(false)/drain ordering, bounded queued daemon bytes, per-PTY and total pending-output caps, preserved sequenced-tail metadata, active-pending protection ahead of background trimming, and ACK-gated in-flight bounds. The live Electron perf oracle adds hidden-output floods, renderer scheduler queue depth, dropped-output-zero normal scenarios, and active key latency budgets before promotion.",
|
||||
"invariant": "High-volume terminal output must stay bounded across daemon socket writes, main runtime metadata, detectors, and tail checks, main-to-renderer in-flight bytes, renderer scheduler queues, and hidden-output restore without starving focused input.",
|
||||
"oracle": "The current executable slice injects daemon socket backpressure and main-process renderer backlog pressure, then asserts write(false)/drain ordering, bounded queued daemon bytes, unchanged path-provenance history reuse for pathless output, idle serve-sim detection with split-JSON continuity, ordinary terminal-wait detection without a joined-tail allocation and with full prompt-family continuity, per-PTY and total pending-output caps, preserved sequenced-tail metadata, active-pending protection ahead of background trimming, and ACK-gated in-flight bounds. The live Electron perf oracle adds hidden-output floods, renderer scheduler queue depth, dropped-output-zero normal scenarios, and active key latency budgets before promotion.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty.test.ts"
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-path-candidate-history.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/emulator/serve-sim-state-watcher.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/orca-runtime-tail-wait-memo.test.ts"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/main/ipc/pty.test.ts"
|
||||
"src/main/ipc/pty.test.ts",
|
||||
"src/main/runtime/orca-runtime-path-candidate-history.test.ts",
|
||||
"src/main/emulator/serve-sim-state-watcher.test.ts",
|
||||
"src/main/runtime/orca-runtime.test.ts",
|
||||
"src/main/runtime/orca-runtime-tail-wait-memo.test.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
@@ -1220,6 +1273,37 @@
|
||||
"active PTY pending output is prioritized during renderer backpressure",
|
||||
"combined pending output exceeding the interactive size limit is batched"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/orca-runtime-path-candidate-history.test.ts",
|
||||
"assertions": [
|
||||
"reuses path-candidate history across repeated pathless PTY output",
|
||||
"copies history when new output adds a path candidate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/emulator/serve-sim-state-watcher.test.ts",
|
||||
"assertions": [
|
||||
"does not buffer repeated brace-free PTY output while waiting for metadata",
|
||||
"detects serve-sim metadata split across PTY chunks and releases the partial object"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/orca-runtime-tail-wait-memo.test.ts",
|
||||
"assertions": [
|
||||
"does not rebuild or repeatedly scan an ordinary saturated tail",
|
||||
"memoized stamping matches recompute reference: blocked prompt split across chunks",
|
||||
"stays equivalent across tail eviction beyond the retained cap"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/orca-runtime.test.ts",
|
||||
"assertions": [
|
||||
"returns a blocked wait result for Codex update prompts",
|
||||
"returns a blocked wait result for Codex workspace trust prompts",
|
||||
"returns a blocked wait result for generic Codex interactive prompts",
|
||||
"resolves tui-idle when a stale Codex prompt is followed by the ready header"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
@@ -1231,6 +1315,33 @@
|
||||
"result": "passed",
|
||||
"durationSeconds": 1.5,
|
||||
"summary": "1 test file(s) passed, 209 tests passed on main@1282f5c2d in a clean checkout."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-10",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-path-candidate-history.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 3.49,
|
||||
"summary": "1 test file passed, 2 tests passed on the exact rebased tree based on main@dc468f0ded."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-10",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/emulator/serve-sim-state-watcher.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 0.37,
|
||||
"summary": "1 test file passed, 6 tests passed on pushed commit f0e3a185593b."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-10",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/orca-runtime-tail-wait-memo.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 21.33,
|
||||
"summary": "2 test files passed, 609 tests passed on pushed commit 4fb14eac3897."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
@@ -1243,11 +1354,11 @@
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "partial",
|
||||
"evidence": "Tests assert daemon stream writes pause after socket write(false), later stream data queues behind the pressured socket, flush-immediate output from another session is prioritized ahead of unrelated queued background backlog on drain while preserving per-session order, queued lines resume on drain, global cleanup clears clients that only have backpressured writes, queued daemon stream bytes are bounded by keeping priority output and the newest tail under sustained pressure, main pending renderer output is capped per PTY and in total, total-pressure trimming prefers background pending output before active pending output, trimmed pending tails preserve seq/rawLength metadata, and ACK-gated in-flight output remains bounded. Existing renderer tests cover replay/backlog slices. Needs intentional-break proof and broader hidden-output/input-latency perf artifacts before promotion."
|
||||
"evidence": "Tests assert daemon stream writes pause after socket write(false), later stream data queues behind the pressured socket, flush-immediate output from another session is prioritized ahead of unrelated queued background backlog on drain while preserving per-session order, queued lines resume on drain, global cleanup clears clients that only have backpressured writes, queued daemon stream bytes are bounded by keeping priority output and the newest tail under sustained pressure, pathless runtime output reuses unchanged path-provenance history with zero old-candidate byte scans, ordinary output leaves the niche serve-sim detector idle while split JSON still emits and releases its partial buffer, ordinary saturated terminal tails retain no rebuilt wait text or repeated phrase scans while every blocked/ready prompt family remains live, main pending renderer output is capped per PTY and in total, total-pressure trimming prefers background pending output before active pending output, trimmed pending tails preserve seq/rawLength metadata, and ACK-gated in-flight output remains bounded. Intentionally restoring the old path-history shape failed the focused scale test after 774ms and 4,096 replacements; restoring the old serve-sim buffer failed its scale test after 180ms with 4,096 buffer writes; disabling the ordinary-tail branch failed with a retained 253,999-character waitText. The fixed shapes passed their focused suites. Existing renderer tests cover replay/backlog slices. Needs broader hidden-output/input-latency perf artifacts before promotion."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "Suggested ceilings: renderer in-flight <=8MB total, <=512KB per PTY plus active reserve, renderer queued chars <=2MB, dropped backlogs 0, hidden restore <=1000ms, active key median/worst <=75ms/300ms in perf scenarios."
|
||||
"evidence": "Suggested ceilings: renderer in-flight <=8MB total, <=512KB per PTY plus active reserve, renderer queued chars <=2MB, dropped backlogs 0, hidden restore <=1000ms, active key median/worst <=75ms/300ms in perf scenarios. With 1,024 retained provenance candidates, 4,096 pathless chunks dropped from 78.84ms and 4,096 array replacements to 1.74ms and zero replacements. Brace-free 1 KiB output dropped from 20.32/79.12/318.23ms to 0.31/0.64/2.35ms across 4,096/16,384/65,536 chunks. Repeated ordinary 252,000-character tail checks dropped from 96.34/464.20/941.79/3,796.70ms to 3.21/15.68/31.34/129.72ms across 100/500/1,000/4,096 updates."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Split daemon stream backpressure into a deterministic provider/IPC contract if full E2E is flaky.",
|
||||
@@ -1256,6 +1367,9 @@
|
||||
],
|
||||
"knownGaps": [
|
||||
"Current command asserts daemon stream write(false)/drain/cleanup at the batcher contract layer and main pending renderer caps at the IPC contract layer.",
|
||||
"Runtime provenance coverage is deterministic and does not include a live high-throughput provider artifact.",
|
||||
"Serve-sim detector coverage is deterministic and does not include a live external simulator helper.",
|
||||
"Terminal-wait scale evidence is deterministic and does not yet include a live saturated-tail event-loop artifact.",
|
||||
"Current command does not prove renderer parse pressure, scheduler queue depth, event-loop delay, or active key latency.",
|
||||
"Live hidden-output pressure, active input latency, and full Electron perf artifacts remain unproved."
|
||||
],
|
||||
@@ -1748,6 +1862,258 @@
|
||||
],
|
||||
"demotionRule": "Cannot promote if standard-key behavior is inferred without PTY byte evidence."
|
||||
},
|
||||
{
|
||||
"id": "terminal-input.windows-modified-enter-routing",
|
||||
"title": "Windows modified Enter routes agent-compatible bytes to the active pane",
|
||||
"maturity": "experimental",
|
||||
"protection": "partial",
|
||||
"owner": "terminal-input",
|
||||
"layer": "main-provider-renderer-unit-and-windows-electron",
|
||||
"surfaces": [
|
||||
"terminal input",
|
||||
"Windows modified Enter",
|
||||
"agent identity",
|
||||
"split panes",
|
||||
"SSH",
|
||||
"WSL",
|
||||
"remote runtime"
|
||||
],
|
||||
"platforms": [
|
||||
"macos",
|
||||
"linux",
|
||||
"windows"
|
||||
],
|
||||
"providers": [
|
||||
"local",
|
||||
"daemon",
|
||||
"ssh",
|
||||
"wsl",
|
||||
"remote-runtime"
|
||||
],
|
||||
"coveredPlatforms": [
|
||||
"macos"
|
||||
],
|
||||
"coveredProviders": [],
|
||||
"coverageNotes": "Deterministic main/renderer tests run on macOS and exercise simulated Windows plus POSIX/Linux process-confirmation behavior. They cover exact local-ConPTY membership, detached-child rejection, unchanged SSH/WSL/remote fallback, process-confirmed Droid routing and launch-triggered confirmation, typed-alias and forged-OSC isolation, shells without OSC 133, split/detach ownership with preserved shell classification, stale PTY exit/rebind rejection, command-generation revocation, daemon v21 warm-reattach identity, unavailable inspection, and lazy subprocess/RPC callback counts. The Electron byte test runs only on Windows; live Windows and Linux PTY evidence remains uncollected. Test failures report exact expected bytes; no product telemetry or raw terminal logging is added.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/issues/7620",
|
||||
"https://github.com/stablyai/orca/pull/7668"
|
||||
],
|
||||
"invariant": "Shift+Enter sends CSI-u only when the active pane is a genuine local Windows ConPTY with allowlisted Droid ownership or fresh routing-trusted Droid process evidence. Every new command and PTY replacement revokes stale routing until current evidence settles. All other Windows panes preserve Esc+CR; non-Windows panes remain CSI-u; SSH/WSL/remote behavior is unchanged; split or reused panes cannot inherit sibling or prior-PTY authority; unrelated keys perform no agent or ConPTY lookup.",
|
||||
"oracle": "Main/renderer tests assert exact bytes for local and non-local Windows plus non-Windows policy cells. Fresh Windows process evidence authorizes bytes only after a recognized global candidate is intersected with the exact ConPTY console process list; detached descendants, helper failure/timeout/root-only fallback, stale PTY exit/rebind results, typed text, and hook/OSC output fail closed. A new OSC 133;C immediately publishes a routing-neutral generation, including during D-to-C races. In cmd.exe/Git Bash/custom shells without OSC 133, accepted inferred commands start the same fresh generation; accepted submit/interrupt, title exit, focus, and visibility revoke trusted Droid bytes while one bounded confirmation runs. Detach preserves the live PTY's source shell override so WSL/native classification cannot change with the current default. Ordinary fast shell commands use cached/no-scan paths; routing-relevant confirmation bypasses cached process snapshots and keeps the bounded three-read ladder. Daemon protocol v21 persists only an allowlisted launchAgent; warm reattach uses it as a display/confirmation hint and restores routing only after current process proof. The Windows Electron test focuses the real xterm textarea and records exact renderer-to-main PTY writes.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/daemon-foreground-confirmation-protocol.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/daemon-server.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/main/daemon/pty-subprocess-foreground-scan-cadence.test.ts src/main/daemon/pty-subprocess.test.ts src/main/ipc/pty.test.ts src/main/providers/agent-foreground-process.test.ts src/main/providers/local-pty-provider.test.ts src/main/providers/windows-conpty-process-membership.test.ts src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts src/renderer/src/components/terminal-pane/terminal-windows-shift-enter.test.ts src/renderer/src/store/slices/store-cascades.test.ts src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts src/shared/agent-process-recognition.test.ts src/shared/process-table-snapshot.test.ts tests/e2e/terminal-foreground-confirmation.unit.test.ts",
|
||||
"pnpm run test:e2e -- tests/e2e/terminal-shortcuts.spec.ts"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/main/daemon/daemon-foreground-confirmation-protocol.test.ts",
|
||||
"src/main/daemon/daemon-pty-adapter.test.ts",
|
||||
"src/main/daemon/daemon-pty-router.test.ts",
|
||||
"src/main/daemon/daemon-server.test.ts",
|
||||
"src/main/daemon/degraded-daemon-pty-provider.test.ts",
|
||||
"src/main/daemon/pty-subprocess-foreground-scan-cadence.test.ts",
|
||||
"src/main/daemon/pty-subprocess.test.ts",
|
||||
"src/main/ipc/pty.test.ts",
|
||||
"src/main/providers/agent-foreground-process.test.ts",
|
||||
"src/main/providers/local-pty-provider.test.ts",
|
||||
"src/main/providers/windows-conpty-process-membership.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/pty-connection.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/pty-transport.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts",
|
||||
"src/renderer/src/components/terminal-pane/terminal-windows-shift-enter.test.ts",
|
||||
"src/renderer/src/store/slices/store-cascades.test.ts",
|
||||
"src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts",
|
||||
"src/shared/agent-process-recognition.test.ts",
|
||||
"src/shared/process-table-snapshot.test.ts",
|
||||
"tests/e2e/terminal-foreground-confirmation.unit.test.ts",
|
||||
"tests/e2e/terminal-shortcuts.spec.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/shared/process-table-snapshot.test.ts",
|
||||
"assertions": [
|
||||
"a fresh request bypasses a same-tick TTL cache and any scan already in flight before the request",
|
||||
"same-turn fresh requests and ordinary cache misses share one later scan while requests arriving after it starts queue behind it"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/providers/agent-foreground-process.test.ts",
|
||||
"assertions": [
|
||||
"successful root-present Windows enumeration and failed, blank, malformed, or root-missing enumeration remain distinct evidence states",
|
||||
"an observed shell root with no children is authoritative while a snapshot omitting that root is unavailable",
|
||||
"an attached Droid candidate is authorized only when exact ConPTY membership contains it, while a detached Start-Process descendant is rejected",
|
||||
"a Windows process-table scan with no recognized agent performs zero ConPTY membership helper forks",
|
||||
"fresh POSIX scan failure or a snapshot missing the PTY root is unavailable rather than shell proof",
|
||||
"the compatibility process-name API still preserves its existing fallback contract for other consumers"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/providers/windows-conpty-process-membership.test.ts",
|
||||
"assertions": [
|
||||
"the fixed node-pty helper returns validated integer console membership containing the PTY root",
|
||||
"root-only fallback, malformed/missing-root messages, spawn errors, and a silent helper timeout all fail closed",
|
||||
"the bounded timeout kills the child helper exactly once"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/providers/local-pty-provider.test.ts",
|
||||
"assertions": [
|
||||
"a delayed foreground confirmation is discarded after the owning PTY exits"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/daemon/daemon-pty-adapter.test.ts",
|
||||
"assertions": [
|
||||
"new daemon sessions and warm reattach results carry the same minimal allowlisted launchAgent"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/daemon/daemon-server.test.ts",
|
||||
"assertions": [
|
||||
"daemon protocol v21 persists allowlisted launchAgent across reattach and drops unknown identity strings"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/daemon/pty-subprocess.test.ts",
|
||||
"assertions": [
|
||||
"Windows daemon foreground lookup can return PowerShell synchronously before async process-tree enrichment resolves the agent",
|
||||
"the enriched agent result is served by a later foreground read",
|
||||
"fresh confirmation awaits a post-request scan instead of serving cached PowerShell and returns null when inspection is unavailable"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/terminal-foreground-confirmation.unit.test.ts",
|
||||
"assertions": [
|
||||
"the real daemon handle and pane tracker keep restored Droid identity when confirmation resolves after the old cache window",
|
||||
"one fresh command-finish scan confirms real shell exit, while unavailable inspection performs exactly three bounded reads and never claims shell"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts",
|
||||
"assertions": [
|
||||
"Windows defaults and non-Droid agents preserve Esc+CR while explicit Droid capability selects CSI-u",
|
||||
"SSH, WSL, and remote Windows-client panes preserve Esc+CR without consulting the Droid capability",
|
||||
"non-Windows Shift+Enter remains CSI-u and unrelated keys invoke neither agent nor ConPTY callbacks"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/terminal-windows-shift-enter.test.ts",
|
||||
"assertions": [
|
||||
"only routing-trusted current process identity overrides launch evidence and confirmed shell foreground clears stale identity",
|
||||
"a newer untrusted or neutral command generation blocks stale launch ownership until confirmation",
|
||||
"only allowlisted fresh process or pane-scoped launch ownership selects Droid's CSI-u capability",
|
||||
"forged hook or OSC-derived Droid status cannot change the Windows byte encoding",
|
||||
"after split and original-pane close, a sole surviving sibling cannot inherit the original pane's Droid launch identity"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.test.ts",
|
||||
"assertions": [
|
||||
"manual and restored local agent identity is published from bounded foreground-process reads",
|
||||
"a delayed read cannot publish after the pane rebinds to a replacement PTY id",
|
||||
"every new command publishes a routing-neutral generation before confirmation",
|
||||
"typed agent text only widens the confirmation window and never becomes published or routable identity",
|
||||
"unavailable typed-command confirmation stays on legacy routing through the three-read ladder and then fails closed",
|
||||
"ordinary fast OSC 133;C-to-D command pairs publish shell without a fresh confirmation scan",
|
||||
"command-start and visible launch recovery use at most three reads across the bounded 350 ms, 1.2 s, and 6 s ladder",
|
||||
"null and rejected reads participate in the same bounded known-identity ladder",
|
||||
"command-finished accepts one fresh recognized shell scan when agent evidence exists, while unavailable final evidence retires routing without claiming shell"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/pty-connection.test.ts",
|
||||
"assertions": [
|
||||
"after detach remount, pane-key launch/process identity forces a leaked nested OSC 133;D through foreground confirmation",
|
||||
"a still-running Droid process republishes Droid instead of being overwritten with shell-foreground evidence",
|
||||
"typing droid does not route CSI-u until fresh process evidence confirms Droid",
|
||||
"null and rejected command-start reads never promote typed Droid text to process evidence",
|
||||
"typed Droid input cannot replace live Antigravity process or pane-scoped launch identity inside another TUI",
|
||||
"typed Droid input on SSH or remote-runtime panes never writes local process evidence",
|
||||
"Droid routing survives a fresh command-finish scan that still sees Droid and retires after one fresh shell result",
|
||||
"a D-to-C transition immediately routes legacy bytes while the new command generation is pending",
|
||||
"an identityless visible-read versus OSC 133;D race cancels stale sampling and fails closed to shell",
|
||||
"a manually typed Droid in a shell without OSC 133 gains routing only after accepted-input fresh confirmation",
|
||||
"accepted no-OSC exit or interrupt input immediately revokes trusted Droid bytes and confirms the returned shell",
|
||||
"unavailable command-finish evidence resolves deferred cleanup, returns legacy routing, and does not assert shell foreground",
|
||||
"daemon-returned allowlisted launchAgent requests warm-reattach confirmation without restoring stale byte authority or the original args/env payload, while missing identity fails closed",
|
||||
"an unknown persisted wrapper cannot spoof launch identity and confirmed shell state prevents repeat focus scans"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/terminal-pane/pty-transport.test.ts",
|
||||
"assertions": [
|
||||
"allowlisted daemon launchAgent reaches PtyConnectResult on reattach",
|
||||
"unknown launch identity is dropped at the renderer transport boundary"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts",
|
||||
"assertions": [
|
||||
"pane-to-tab detach moves only the detached leaf's launch and process identity to its new pane key",
|
||||
"pane-to-tab detach preserves the source PTY shell override across native PowerShell and WSL classifications",
|
||||
"the source hook snapshot is retired rather than copied to a key the running PTY cannot emit",
|
||||
"the detached Droid keeps CSI-u while the source sibling keeps its own legacy encoding"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/shared/agent-process-recognition.test.ts",
|
||||
"assertions": [
|
||||
"Droid's process name resolves to the allowlisted Droid agent identity without substring matching"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/terminal-shortcuts.spec.ts",
|
||||
"assertions": [
|
||||
"on Windows the focused terminal sends CSI-u bytes for a Droid pane and Esc+CR bytes after switching that same pane to Antigravity identity",
|
||||
"the byte oracle observes the real renderer-to-main PTY write path rather than terminal DOM state"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-07-10",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/daemon-foreground-confirmation-protocol.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/daemon-server.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/main/daemon/pty-subprocess-foreground-scan-cadence.test.ts src/main/daemon/pty-subprocess.test.ts src/main/ipc/pty.test.ts src/main/providers/agent-foreground-process.test.ts src/main/providers/local-pty-provider.test.ts src/main/providers/windows-conpty-process-membership.test.ts src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/components/terminal-pane/pty-transport.test.ts src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts src/renderer/src/components/terminal-pane/terminal-shortcut-policy.test.ts src/renderer/src/components/terminal-pane/terminal-windows-shift-enter.test.ts src/renderer/src/store/slices/store-cascades.test.ts src/renderer/src/store/slices/terminal-pane-detach-agent-identity.test.ts src/shared/agent-process-recognition.test.ts src/shared/process-table-snapshot.test.ts tests/e2e/terminal-foreground-confirmation.unit.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 8.71,
|
||||
"summary": "Twenty-two focused test files passed (1206 tests) on current main, covering fresh-scan ordering/deduplication, exact ConPTY membership and detached-child rejection, stale PTY guards, daemon v21 launch ownership with current-process confirmation, no-OSC command/exit recovery, preserved detach shell classification, unavailable evidence, routing-neutral command generations, exact shortcut bytes, and the composed daemon-plus-tracker contract; the Windows Electron byte test remains platform-gated and is not counted as local macOS evidence."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 90,
|
||||
"scope": "focused renderer units plus the Windows Electron terminal-shortcut spec"
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "unknown",
|
||||
"evidence": "The deterministic units are stable locally; the Windows-only Electron byte path needs CI or nightly soak history before promotion."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "partial",
|
||||
"evidence": "Reverting to the previous unconditional Windows Esc+CR branch fails the Droid byte assertion, while resolving launch metadata from any key other than the active pane fails the split-survivor isolation assertion. Saved intentional-break artifacts and live Windows history are still missing."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "Callback-count tests prove unrelated ordinary keys invoke neither ConPTY nor agent resolution. Windows Shift+Enter performs two store reads: one local-ConPTY classification with an O(tabs-in-worktree) tab lookup, then two pane-keyed O(1) ownership lookups. Typed text performs no routing write. Ordinary fast OSC 133;C-to-D shell commands perform no fresh confirmation RPC or process scan. An accepted submit/interrupt while trusted Droid is current, a typed allowlisted agent command, title exit, focus, visibility transition, or an idle burst after trusted Droid Shift+Enter can schedule the bounded confirmation ladder; each event cancels/deduplicates prior work and no timer recurs after settling. Windows confirmation performs one fresh process-table scan and forks exact ConPTY membership only after a recognized agent candidate appears; no recognized candidate makes zero helper calls. Same-turn fresh requests share one scan and later arrivals queue at most one successor. Recovery remains three bounded one-shot reads (350 ms, 1.2 s, 6 s). No polling loop, provider fanout, persistent listener, hidden-pane wake loop, or per-printable-key work is added."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Run the exact PTY-byte Electron oracle on Windows without silent platform skips.",
|
||||
"Collect stable runtime and flake history for local and daemon Windows ConPTY while retaining deterministic unchanged-provider assertions for SSH, WSL, and remote runtime.",
|
||||
"Attach saved intentional-break evidence for Droid capability removal and split-sibling identity leakage."
|
||||
],
|
||||
"knownGaps": [
|
||||
"No Windows runner or live Linux PTY evidence is attached; the Electron byte test skips on macOS and Linux, while Linux behavior is covered only by deterministic POSIX unit paths.",
|
||||
"Droid inside SSH, WSL, or remote-runtime intentionally keeps the pre-existing Windows-client Esc+CR behavior because local foreground confirmation is unavailable; this PR narrows the exception to issue #7620's local ConPTY surface.",
|
||||
"The Electron test injects allowlisted pane identity rather than launching the real Droid and Antigravity binaries.",
|
||||
"If both Windows process-enumeration backends remain unavailable through the bounded ladder, routing fails closed to the legacy Esc+CR path; no live Droid artifact covers that degraded host configuration.",
|
||||
"Protocol v21 restarts older daemons because the minimal persisted launchAgent is a new contract; sessions preserved by pre-v21 daemons cannot supply warm-reattach launch ownership and fail closed until current process confirmation or restart.",
|
||||
"No live daemon, SSH, WSL, remote-runtime, paired-web, or restore/replay byte artifact has been collected.",
|
||||
"A running PTY keeps its spawn-time ORCA_PANE_KEY after pane-to-tab detach. This change transfers launch/process routing evidence and retires the stale hook snapshot, but does not redesign future hook reattribution across tab IDs."
|
||||
],
|
||||
"demotionRule": "Keep non-blocking or demote to protection none if the Windows byte test silently skips, flakes without an actionable artifact, routes bytes using tab/title/terminal-output heuristics, or adds any ordinary-key lookup, IPC, polling, scan, or lifecycle leak."
|
||||
},
|
||||
{
|
||||
"id": "terminal-input.agent-prompt-injection",
|
||||
"title": "Orchestration agent prompts arrive as bracketed paste before submit",
|
||||
|
||||
@@ -3,7 +3,8 @@ import path from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import process from 'node:process'
|
||||
|
||||
import ts from 'typescript'
|
||||
// TypeScript 7 is a native CLI; AST consumers still need the legacy JavaScript API.
|
||||
import ts from 'typescript-api'
|
||||
|
||||
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts'])
|
||||
const SKIP_PATH_PARTS = new Set(['.git', 'dist', 'node_modules', 'out', '__snapshots__', 'assets'])
|
||||
|
||||
@@ -18,6 +18,7 @@ if (reportPaths.length === 0) {
|
||||
const BUDGETS = {
|
||||
maxMedianKeyLatencyMs: 75,
|
||||
maxWorstKeyLatencyMs: 300,
|
||||
maxRevisitLatencyMs: 300,
|
||||
maxTimerDriftMs: 150,
|
||||
maxScrollLatencyMs: 150,
|
||||
maxRestoreLatencyMs: 1000,
|
||||
@@ -80,6 +81,12 @@ function validateRow(row) {
|
||||
BUDGETS.maxWorstKeyLatencyMs,
|
||||
'ms'
|
||||
)
|
||||
addBudgetCheck(
|
||||
'revisit latency',
|
||||
parseMs(row.revisit, 'revisit', row, failures),
|
||||
BUDGETS.maxRevisitLatencyMs,
|
||||
'ms'
|
||||
)
|
||||
addBudgetCheck(
|
||||
'timer drift',
|
||||
parseMs(row.maxTimerDrift, 'maxTimerDrift', row, failures),
|
||||
@@ -113,6 +120,14 @@ function validateRow(row) {
|
||||
parseCount(row.rendererDroppedBacklogs, 'rendererDroppedBacklogs', row, failures),
|
||||
BUDGETS.maxRendererDroppedBacklogs
|
||||
)
|
||||
// Why: parked-memory rows carry heap/view-count metrics with no latency
|
||||
// budget; recognize them so memory-only scenarios pass the gate instead of
|
||||
// tripping the "no recognized budget metrics" guard.
|
||||
for (const fieldName of ['heapUsedMB', 'liveTerminals', 'livePaneManagers']) {
|
||||
if (parseCount(row[fieldName], fieldName, row, failures) != null) {
|
||||
checkedMetricCount += 1
|
||||
}
|
||||
}
|
||||
if (checkedMetricCount === 0) {
|
||||
failures.push(`${row.source} ${row.scenario}: no recognized budget metrics found`)
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ describe('check-terminal-perf-report-budgets', () => {
|
||||
'frames=180',
|
||||
'median=2.9ms',
|
||||
'worst=5.9ms',
|
||||
'revisit=42.0ms',
|
||||
'maxTimerDrift=12.1ms',
|
||||
'scroll=149.9ms',
|
||||
'restore=642.0ms',
|
||||
@@ -82,6 +83,7 @@ describe('check-terminal-perf-report-budgets', () => {
|
||||
'frames=60',
|
||||
'median=76.0ms',
|
||||
'worst=301.0ms',
|
||||
'revisit=301.0ms',
|
||||
'maxTimerDrift=151.0ms',
|
||||
'scroll=151.0ms',
|
||||
'restore=1001.0ms',
|
||||
@@ -96,6 +98,7 @@ describe('check-terminal-perf-report-budgets', () => {
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain('median typing latency 76ms exceeded budget 75ms')
|
||||
expect(result.stderr).toContain('worst typing latency 301ms exceeded budget 300ms')
|
||||
expect(result.stderr).toContain('revisit latency 301ms exceeded budget 300ms')
|
||||
expect(result.stderr).toContain('timer drift 151ms exceeded budget 150ms')
|
||||
expect(result.stderr).toContain('scroll latency 151ms exceeded budget 150ms')
|
||||
expect(result.stderr).toContain('restore latency 1001ms exceeded budget 1000ms')
|
||||
@@ -116,6 +119,31 @@ describe('check-terminal-perf-report-budgets', () => {
|
||||
expect(result.stderr).toContain('no recognized budget metrics found')
|
||||
})
|
||||
|
||||
it('accepts revisit-only marker rows as budgeted perf evidence', () => {
|
||||
const reportPath = writeReport('panes=19 revisit=25.7ms heldAckChars=2097184')
|
||||
|
||||
const output = execFileSync(process.execPath, [scriptPath, reportPath], {
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf8'
|
||||
})
|
||||
|
||||
expect(output).toContain('Terminal perf budget check passed for 1 annotation row(s).')
|
||||
})
|
||||
|
||||
it('accepts parked-memory rows that carry only heap and view-count metrics', () => {
|
||||
const reportPath = writeReport(
|
||||
'panes=8 parkedTabs=8 heapUsedMB=87.8 liveTerminals=1 livePaneManagers=1',
|
||||
'opencode-parked-memory'
|
||||
)
|
||||
|
||||
const output = execFileSync(process.execPath, [scriptPath, reportPath], {
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf8'
|
||||
})
|
||||
|
||||
expect(output).toContain('Terminal perf budget check passed for 1 annotation row(s).')
|
||||
})
|
||||
|
||||
it('fails OpenCode annotation rows that contain no budget metrics', () => {
|
||||
const reportPath = writeReport('panes=1 frames=60')
|
||||
|
||||
|
||||
@@ -127,6 +127,23 @@ describe('computer-use e2e workflow', () => {
|
||||
expect(daemonSmokeIndex).toBeGreaterThan(buildIndex)
|
||||
})
|
||||
|
||||
it('runs the Windows workspace-close daemon repro after the main build', () => {
|
||||
const workflow = parse(
|
||||
readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8')
|
||||
)
|
||||
const steps = workflow.jobs['native-smoke'].steps
|
||||
const buildIndex = steps.findIndex((step) => step.run === 'pnpm build:electron-vite')
|
||||
const reproIndex = steps.findIndex(
|
||||
(step) => step.run === 'node config/scripts/windows-daemon-workspace-close-repro.mjs'
|
||||
)
|
||||
|
||||
expect(reproIndex).toBeGreaterThan(buildIndex)
|
||||
expect(steps[reproIndex].if).toBe("runner.os == 'Windows'")
|
||||
expect(workflow.on.pull_request.paths).toContain(
|
||||
'config/scripts/windows-daemon-workspace-close-repro.mjs'
|
||||
)
|
||||
})
|
||||
|
||||
it('re-runs the native-smoke job when the daemon bundle graph changes', () => {
|
||||
const workflow = parse(
|
||||
readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8')
|
||||
@@ -136,6 +153,7 @@ describe('computer-use e2e workflow', () => {
|
||||
expect(triggerPaths).toEqual(
|
||||
expect.arrayContaining([
|
||||
'config/scripts/daemon-boot-smoke.mjs',
|
||||
'config/scripts/windows-daemon-workspace-close-repro.mjs',
|
||||
'electron.vite.config.ts',
|
||||
'build-plugins/**',
|
||||
'src/main/daemon/**'
|
||||
|
||||
@@ -0,0 +1,483 @@
|
||||
import { mkdirSync, writeFileSync } from 'node:fs'
|
||||
import {
|
||||
budgetFailures,
|
||||
collectTerminalPerfRows,
|
||||
compareScenarios,
|
||||
escapeHtml,
|
||||
formatLargeValue,
|
||||
formatMs,
|
||||
readJsonReport,
|
||||
scenarioTitle
|
||||
} from './terminal-perf-report-rows.mjs'
|
||||
import { basename, dirname } from 'node:path'
|
||||
|
||||
const DEFAULT_OUTPUT_PATH = 'test-results/terminal-perf-impact-report.html'
|
||||
|
||||
// Why: every tracked metric is lower-is-better, so delta coloring and the
|
||||
// regression table share one direction rule.
|
||||
const MS_METRICS = [
|
||||
{ key: 'medianMs', label: 'Typing median', chart: true },
|
||||
{ key: 'worstMs', label: 'Typing worst', chart: true },
|
||||
{ key: 'scrollMs', label: 'Active scroll', chart: true },
|
||||
{ key: 'restoreMs', label: 'Restore', chart: true },
|
||||
{ key: 'revisitMs', label: 'Revisit marker', chart: true },
|
||||
{ key: 'maxTimerDriftMs', label: 'Timer drift', chart: false }
|
||||
]
|
||||
|
||||
const COUNT_METRICS = [
|
||||
{ key: 'rendererPeakQueuedChars', label: 'Renderer peak queued chars' },
|
||||
{ key: 'mainPeakInFlightChars', label: 'Main in-flight chars' },
|
||||
{ key: 'mainPeakPendingChars', label: 'Main pending chars' },
|
||||
{ key: 'hiddenSkippedChars', label: 'Hidden skipped chars' },
|
||||
{ key: 'rendererDroppedBacklogs', label: 'Renderer dropped backlogs' },
|
||||
// Why: parked-memory scenarios are table-only — heap/view counts have no
|
||||
// ms trend story, so they stay out of the charts.
|
||||
{ key: 'heapUsedMB', label: 'Renderer JS heap (MB)' },
|
||||
{ key: 'liveTerminals', label: 'Live xterm instances' },
|
||||
{ key: 'livePaneManagers', label: 'Live pane managers' }
|
||||
]
|
||||
|
||||
const SERIES_COLORS = {
|
||||
medianMs: '#2563eb',
|
||||
worstMs: '#dc2626',
|
||||
scrollMs: '#d97706',
|
||||
restoreMs: '#7c3aed',
|
||||
revisitMs: '#0d9488'
|
||||
}
|
||||
|
||||
const LABELED_INPUT_RE = /^([\w .#@()+-]+)=(.+)$/
|
||||
|
||||
export function parseHtmlReportArgs(argv, env = process.env) {
|
||||
const args = [...argv]
|
||||
if (args[0] === '--') {
|
||||
args.shift()
|
||||
}
|
||||
|
||||
const inputs = []
|
||||
let outputPath = env.ORCA_E2E_TERMINAL_PERF_HTML_REPORT_PATH || DEFAULT_OUTPUT_PATH
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index]
|
||||
if (arg === '--output' || arg === '-o') {
|
||||
const next = args[index + 1]
|
||||
if (!next || next.startsWith('-')) {
|
||||
throw new Error(`${arg} requires a path`)
|
||||
}
|
||||
outputPath = next
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
if (arg.startsWith('--output=')) {
|
||||
outputPath = arg.slice('--output='.length)
|
||||
continue
|
||||
}
|
||||
const labeled = arg.match(LABELED_INPUT_RE)
|
||||
if (labeled) {
|
||||
inputs.push({ label: labeled[1], path: labeled[2] })
|
||||
} else {
|
||||
inputs.push({ label: basename(arg).replace(/\.json$/i, ''), path: arg })
|
||||
}
|
||||
}
|
||||
|
||||
if (inputs.length === 0) {
|
||||
throw new Error(
|
||||
'Usage: node config/scripts/generate-terminal-perf-html-report.mjs [label=]<playwright-json>... --output <report.html>'
|
||||
)
|
||||
}
|
||||
return { inputs, outputPath }
|
||||
}
|
||||
|
||||
// ── Trend data ────────────────────────────────────────────────────────────
|
||||
|
||||
function buildMatrix(revisions) {
|
||||
const scenarios = new Map()
|
||||
for (const revision of revisions) {
|
||||
for (const row of revision.rows) {
|
||||
if (!scenarios.has(row.scenario)) {
|
||||
scenarios.set(row.scenario, new Map())
|
||||
}
|
||||
scenarios.get(row.scenario).set(revision.label, row)
|
||||
}
|
||||
}
|
||||
const orderedScenarios = [...scenarios.keys()].sort(compareScenarios)
|
||||
return { scenarios, orderedScenarios }
|
||||
}
|
||||
|
||||
function niceCeil(value) {
|
||||
if (value <= 0) {
|
||||
return 1
|
||||
}
|
||||
const magnitude = 10 ** Math.floor(Math.log10(value))
|
||||
for (const step of [1, 2, 2.5, 5, 10]) {
|
||||
if (value <= step * magnitude) {
|
||||
return step * magnitude
|
||||
}
|
||||
}
|
||||
return 10 * magnitude
|
||||
}
|
||||
|
||||
// ── Rendering ─────────────────────────────────────────────────────────────
|
||||
|
||||
function renderTrendChart({ scenario, byRevision, revisions, title }) {
|
||||
const metrics = MS_METRICS.filter(
|
||||
(metric) =>
|
||||
metric.chart &&
|
||||
revisions.some((revision) => byRevision.get(revision.label)?.[metric.key] != null)
|
||||
)
|
||||
if (metrics.length === 0) {
|
||||
return ''
|
||||
}
|
||||
const width = 560
|
||||
const height = 230
|
||||
const pad = { left: 52, right: 14, top: 30, bottom: 38 }
|
||||
const plotW = width - pad.left - pad.right
|
||||
const plotH = height - pad.top - pad.bottom
|
||||
const maxValue = Math.max(
|
||||
1,
|
||||
...metrics.flatMap((metric) =>
|
||||
revisions.map((revision) => byRevision.get(revision.label)?.[metric.key] ?? 0)
|
||||
)
|
||||
)
|
||||
const yMax = niceCeil(maxValue * 1.15)
|
||||
const xFor = (index) =>
|
||||
pad.left + (revisions.length === 1 ? plotW / 2 : (plotW * index) / (revisions.length - 1))
|
||||
const yFor = (value) => pad.top + plotH - (plotH * value) / yMax
|
||||
|
||||
const parts = []
|
||||
parts.push(
|
||||
`<svg viewBox="0 0 ${width} ${height}" role="img" aria-label="${escapeHtml(title)}" class="trend-chart">`
|
||||
)
|
||||
parts.push(`<text x="${pad.left}" y="16" class="chart-title">${escapeHtml(title)}</text>`)
|
||||
// Horizontal gridlines + y labels
|
||||
const ticks = 4
|
||||
for (let tick = 0; tick <= ticks; tick += 1) {
|
||||
const value = (yMax * tick) / ticks
|
||||
const y = yFor(value)
|
||||
parts.push(
|
||||
`<line x1="${pad.left}" y1="${y}" x2="${width - pad.right}" y2="${y}" class="gridline"/>`
|
||||
)
|
||||
parts.push(
|
||||
`<text x="${pad.left - 6}" y="${y + 3}" class="axis-label" text-anchor="end">${value % 1 === 0 ? value : value.toFixed(1)}</text>`
|
||||
)
|
||||
}
|
||||
// X labels
|
||||
revisions.forEach((revision, index) => {
|
||||
parts.push(
|
||||
`<text x="${xFor(index)}" y="${height - pad.bottom + 16}" class="axis-label" text-anchor="middle">${escapeHtml(revision.label)}</text>`
|
||||
)
|
||||
})
|
||||
// Series
|
||||
for (const metric of metrics) {
|
||||
const color = SERIES_COLORS[metric.key] ?? '#475569'
|
||||
const points = revisions
|
||||
.map((revision, index) => ({ index, value: byRevision.get(revision.label)?.[metric.key] }))
|
||||
.filter((point) => point.value != null)
|
||||
if (points.length === 0) {
|
||||
continue
|
||||
}
|
||||
const path = points
|
||||
.map(
|
||||
(point, order) =>
|
||||
`${order === 0 ? 'M' : 'L'}${xFor(point.index).toFixed(1)},${yFor(point.value).toFixed(1)}`
|
||||
)
|
||||
.join(' ')
|
||||
parts.push(`<path d="${path}" fill="none" stroke="${color}" stroke-width="2"/>`)
|
||||
for (const point of points) {
|
||||
const x = xFor(point.index)
|
||||
const y = yFor(point.value)
|
||||
parts.push(`<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="3" fill="${color}"/>`)
|
||||
parts.push(
|
||||
`<text x="${x.toFixed(1)}" y="${(y - 7).toFixed(1)}" class="point-label" text-anchor="middle" fill="${color}">${point.value % 1 === 0 ? point.value : point.value.toFixed(1)}</text>`
|
||||
)
|
||||
}
|
||||
}
|
||||
parts.push('</svg>')
|
||||
|
||||
const legend = metrics
|
||||
.map((metric) => {
|
||||
const color = SERIES_COLORS[metric.key] ?? '#475569'
|
||||
return `<span class="legend-item"><span class="legend-swatch" style="background:${color}"></span>${escapeHtml(metric.label)}</span>`
|
||||
})
|
||||
.join('')
|
||||
return `<figure class="chart-card" data-scenario="${escapeHtml(scenario)}">${parts.join('')}<figcaption class="legend">${legend} <span class="legend-unit">ms — lower is better</span></figcaption></figure>`
|
||||
}
|
||||
|
||||
function deltaCell(baseline, latest, { lowerIsBetter = true, zeroBudget = false } = {}) {
|
||||
if (baseline == null || latest == null) {
|
||||
return '<td class="delta">—</td>'
|
||||
}
|
||||
const diff = latest - baseline
|
||||
const pct = baseline === 0 ? null : (diff / baseline) * 100
|
||||
let cls = 'neutral'
|
||||
if (zeroBudget) {
|
||||
cls = latest > 0 ? 'worse' : 'better'
|
||||
} else if (pct != null && Math.abs(pct) >= 5) {
|
||||
cls = diff < 0 === lowerIsBetter ? 'better' : 'worse'
|
||||
} else if (baseline === 0 && diff !== 0) {
|
||||
cls = diff < 0 === lowerIsBetter ? 'better' : 'worse'
|
||||
}
|
||||
const pctLabel =
|
||||
pct == null ? (diff === 0 ? '±0%' : 'new') : `${pct >= 0 ? '+' : ''}${pct.toFixed(0)}%`
|
||||
const diffLabel = `${diff >= 0 ? '+' : ''}${Math.abs(diff) >= 100 ? Math.round(diff) : diff.toFixed(1)}`
|
||||
return `<td class="delta ${cls}">${escapeHtml(pctLabel)} <span class="delta-abs">(${escapeHtml(diffLabel)})</span></td>`
|
||||
}
|
||||
|
||||
function renderScenarioTable({ scenario, byRevision, revisions, title }) {
|
||||
const metricRows = []
|
||||
const allMetrics = [...MS_METRICS, ...COUNT_METRICS]
|
||||
for (const metric of allMetrics) {
|
||||
const values = revisions.map((revision) => byRevision.get(revision.label)?.[metric.key])
|
||||
if (values.every((value) => value == null)) {
|
||||
continue
|
||||
}
|
||||
const isMs = MS_METRICS.includes(metric)
|
||||
const format = isMs ? formatMs : formatLargeValue
|
||||
const cells = values
|
||||
.map((value) => `<td>${value == null ? '—' : escapeHtml(format(value))}</td>`)
|
||||
.join('')
|
||||
const baseline = values.find((value) => value != null)
|
||||
const latest = values.toReversed().find((value) => value != null)
|
||||
metricRows.push(
|
||||
`<tr><th scope="row">${escapeHtml(metric.label)}</th>${cells}${deltaCell(baseline, latest, {
|
||||
zeroBudget: metric.key === 'rendererDroppedBacklogs'
|
||||
})}</tr>`
|
||||
)
|
||||
}
|
||||
if (metricRows.length === 0) {
|
||||
return ''
|
||||
}
|
||||
const headers = revisions.map((revision) => `<th>${escapeHtml(revision.label)}</th>`).join('')
|
||||
return `<section class="scenario-block">
|
||||
<h3>${escapeHtml(title)} <span class="scenario-id">${escapeHtml(scenario)}</span></h3>
|
||||
<table class="trend-table">
|
||||
<thead><tr><th>Metric</th>${headers}<th>Δ first → last</th></tr></thead>
|
||||
<tbody>${metricRows.join('')}</tbody>
|
||||
</table>
|
||||
</section>`
|
||||
}
|
||||
|
||||
function renderHeadline(revisions, matrix) {
|
||||
if (revisions.length < 2) {
|
||||
return ''
|
||||
}
|
||||
const first = revisions[0]
|
||||
const last = revisions.at(-1)
|
||||
const cards = []
|
||||
for (const scenario of matrix.orderedScenarios) {
|
||||
const byRevision = matrix.scenarios.get(scenario)
|
||||
const baseRow = byRevision.get(first.label)
|
||||
const lastRow = byRevision.get(last.label)
|
||||
if (!baseRow || !lastRow || baseRow.medianMs == null || lastRow.medianMs == null) {
|
||||
continue
|
||||
}
|
||||
const diff = lastRow.medianMs - baseRow.medianMs
|
||||
const pct = baseRow.medianMs === 0 ? 0 : (diff / baseRow.medianMs) * 100
|
||||
const cls = Math.abs(pct) < 5 ? 'neutral' : diff < 0 ? 'better' : 'worse'
|
||||
cards.push(`<div class="card ${cls}">
|
||||
<div class="card-title">${escapeHtml(scenarioTitle(scenario, lastRow))}</div>
|
||||
<div class="card-value">${escapeHtml(formatMs(baseRow.medianMs))} → ${escapeHtml(formatMs(lastRow.medianMs))}</div>
|
||||
<div class="card-sub">typing median, ${escapeHtml(first.label)} → ${escapeHtml(last.label)} (${pct >= 0 ? '+' : ''}${pct.toFixed(0)}%)</div>
|
||||
</div>`)
|
||||
}
|
||||
if (cards.length === 0) {
|
||||
return ''
|
||||
}
|
||||
return `<section><h2>Baseline vs latest</h2><div class="cards">${cards.join('')}</div></section>`
|
||||
}
|
||||
|
||||
function renderBudgets(latestRevision) {
|
||||
const failures = []
|
||||
for (const row of latestRevision.rows) {
|
||||
for (const failure of budgetFailures(row)) {
|
||||
failures.push(`${row.scenario}: ${failure}`)
|
||||
}
|
||||
}
|
||||
const status =
|
||||
failures.length === 0 ? '<span class="pass">Pass</span>' : '<span class="fail">Fail</span>'
|
||||
const failureList =
|
||||
failures.length === 0
|
||||
? ''
|
||||
: `<ul>${failures.map((failure) => `<li>${escapeHtml(failure)}</li>`).join('')}</ul>`
|
||||
return `<section><h2>Budget status — ${escapeHtml(latestRevision.label)}</h2>
|
||||
<p>${latestRevision.rows.length} scenario rows checked: ${status}</p>${failureList}</section>`
|
||||
}
|
||||
|
||||
function renderInputsMeta(revisions) {
|
||||
const items = revisions
|
||||
.map((revision) => {
|
||||
const stats = revision.stats
|
||||
const statsLabel = stats
|
||||
? ` — ${stats.expected ?? 0} passed, ${stats.unexpected ?? 0} failed, ${stats.flaky ?? 0} flaky`
|
||||
: ''
|
||||
const failNote =
|
||||
stats && stats.unexpected > 0
|
||||
? ' <span class="meta-warn">(failed assertions at this revision; metrics still recorded)</span>'
|
||||
: ''
|
||||
return `<li><strong>${escapeHtml(revision.label)}</strong> — ${revision.rows.length} scenario rows (${escapeHtml(revision.path)})${escapeHtml(statsLabel)}${failNote}</li>`
|
||||
})
|
||||
.join('')
|
||||
return `<ol class="inputs">${items}</ol>`
|
||||
}
|
||||
|
||||
function renderRawDetails(revisions) {
|
||||
return revisions
|
||||
.map((revision) => {
|
||||
const rows = revision.rows
|
||||
.map(
|
||||
(row) =>
|
||||
`<tr><td>${escapeHtml(row.scenario)}</td><td>${row.panes ?? '—'}</td><td>${escapeHtml(formatMs(row.medianMs))}</td><td>${escapeHtml(formatMs(row.worstMs))}</td><td>${escapeHtml(formatMs(row.scrollMs))}</td><td>${escapeHtml(formatMs(row.restoreMs))}</td><td>${escapeHtml(formatMs(row.revisitMs))}</td><td>${escapeHtml(formatLargeValue(row.rendererPeakQueuedChars))}</td><td>${escapeHtml(formatLargeValue(row.hiddenSkippedChars))}</td><td>${row.rendererDroppedBacklogs ?? '—'}</td></tr>`
|
||||
)
|
||||
.join('')
|
||||
return `<details><summary>Raw rows — ${escapeHtml(revision.label)}</summary>
|
||||
<table class="trend-table">
|
||||
<thead><tr><th>Scenario</th><th>Panes</th><th>Median</th><th>Worst</th><th>Scroll</th><th>Restore</th><th>Revisit</th><th>Renderer peak</th><th>Hidden skipped</th><th>Drops</th></tr></thead>
|
||||
<tbody>${rows}</tbody></table></details>`
|
||||
})
|
||||
.join('')
|
||||
}
|
||||
|
||||
const PAGE_CSS = `
|
||||
:root { color-scheme: light; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 24px auto; max-width: 1240px; padding: 0 16px; color: #0f172a; background: #f8fafc; }
|
||||
h1 { font-size: 24px; margin-bottom: 4px; }
|
||||
h2 { font-size: 18px; margin: 28px 0 10px; }
|
||||
h3 { font-size: 15px; margin: 18px 0 6px; }
|
||||
.meta { color: #64748b; font-size: 13px; }
|
||||
.inputs { font-size: 13px; color: #334155; padding-left: 20px; }
|
||||
.meta-warn { color: #b45309; }
|
||||
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 10px; }
|
||||
.card { background: #fff; border: 1px solid #e2e8f0; border-left-width: 4px; border-radius: 8px; padding: 10px 12px; }
|
||||
.card.better { border-left-color: #16a34a; }
|
||||
.card.worse { border-left-color: #dc2626; }
|
||||
.card.neutral { border-left-color: #94a3b8; }
|
||||
.card-title { font-size: 12px; color: #64748b; }
|
||||
.card-value { font-size: 18px; font-weight: 600; margin: 2px 0; }
|
||||
.card-sub { font-size: 11px; color: #94a3b8; }
|
||||
.charts { display: grid; grid-template-columns: repeat(auto-fill, minmax(560px, 1fr)); gap: 14px; }
|
||||
.chart-card { margin: 0; background: #fff; border: 1px solid #e2e8f0; border-radius: 8px; padding: 8px; }
|
||||
.trend-chart { width: 100%; height: auto; }
|
||||
.chart-title { font-size: 13px; font-weight: 600; fill: #0f172a; }
|
||||
.gridline { stroke: #e2e8f0; stroke-width: 1; }
|
||||
.axis-label { font-size: 10px; fill: #64748b; }
|
||||
.point-label { font-size: 10px; font-weight: 600; }
|
||||
.legend { font-size: 11px; color: #475569; margin-top: 2px; display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
|
||||
.legend-item { display: inline-flex; align-items: center; gap: 4px; }
|
||||
.legend-swatch { width: 10px; height: 10px; border-radius: 2px; display: inline-block; }
|
||||
.legend-unit { color: #94a3b8; margin-left: auto; }
|
||||
.scenario-block { background: #fff; border: 1px solid #e2e8f0; border-radius: 8px; padding: 10px 14px; margin: 10px 0; }
|
||||
.scenario-id { font-size: 11px; color: #94a3b8; font-weight: 400; margin-left: 6px; }
|
||||
table.trend-table { border-collapse: collapse; width: 100%; font-size: 12px; }
|
||||
table.trend-table th, table.trend-table td { border-bottom: 1px solid #e2e8f0; padding: 5px 8px; text-align: right; white-space: nowrap; }
|
||||
table.trend-table th:first-child, table.trend-table td:first-child { text-align: left; }
|
||||
table.trend-table thead th { color: #475569; font-weight: 600; background: #f1f5f9; }
|
||||
td.delta.better { color: #15803d; font-weight: 600; }
|
||||
td.delta.worse { color: #b91c1c; font-weight: 600; }
|
||||
td.delta.neutral { color: #64748b; }
|
||||
.delta-abs { font-weight: 400; color: #94a3b8; }
|
||||
.pass { color: #15803d; font-weight: 700; }
|
||||
.fail { color: #b91c1c; font-weight: 700; }
|
||||
details { margin: 8px 0; }
|
||||
summary { cursor: pointer; font-size: 13px; color: #334155; }
|
||||
`
|
||||
|
||||
function renderHtml({ generatedAt, revisions }) {
|
||||
const matrix = buildMatrix(revisions)
|
||||
const charts =
|
||||
revisions.length >= 2
|
||||
? matrix.orderedScenarios
|
||||
.map((scenario) => {
|
||||
const byRevision = matrix.scenarios.get(scenario)
|
||||
const anyRow = [...byRevision.values()][0]
|
||||
return renderTrendChart({
|
||||
scenario,
|
||||
byRevision,
|
||||
revisions,
|
||||
title: scenarioTitle(scenario, anyRow)
|
||||
})
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('')
|
||||
: ''
|
||||
const tables = matrix.orderedScenarios
|
||||
.map((scenario) => {
|
||||
const byRevision = matrix.scenarios.get(scenario)
|
||||
const anyRow = [...byRevision.values()][0]
|
||||
return renderScenarioTable({
|
||||
scenario,
|
||||
byRevision,
|
||||
revisions,
|
||||
title: scenarioTitle(scenario, anyRow)
|
||||
})
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('')
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>Terminal Performance Over Time</title>
|
||||
<style>${PAGE_CSS}</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Terminal Performance Over Time</h1>
|
||||
<p class="meta">Generated ${escapeHtml(generatedAt)} from ${revisions.length} benchmark run(s), ordered oldest (baseline) to newest. All metrics: lower is better.</p>
|
||||
${renderInputsMeta(revisions)}
|
||||
${renderHeadline(revisions, matrix)}
|
||||
${charts ? `<section><h2>Trends across revisions</h2><div class="charts">${charts}</div></section>` : ''}
|
||||
<section><h2>Metric detail by scenario</h2>${tables}</section>
|
||||
${renderBudgets(revisions.at(-1))}
|
||||
<section><h2>Raw data</h2>${renderRawDetails(revisions)}</section>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
}
|
||||
|
||||
export function generateTerminalPerfHtmlReport({
|
||||
inputs,
|
||||
inputPaths,
|
||||
outputPath,
|
||||
now = new Date()
|
||||
}) {
|
||||
// Why: older callers (the scale report gate) pass bare inputPaths.
|
||||
const resolvedInputs =
|
||||
inputs ??
|
||||
(inputPaths ?? []).map((path) => ({
|
||||
label: basename(path).replace(/\.json$/i, ''),
|
||||
path
|
||||
}))
|
||||
const revisions = resolvedInputs.map(({ label, path }) => {
|
||||
const report = readJsonReport(path)
|
||||
return {
|
||||
label,
|
||||
path,
|
||||
stats: report.stats ?? null,
|
||||
rows: collectTerminalPerfRows(report, label)
|
||||
}
|
||||
})
|
||||
const totalRows = revisions.reduce((sum, revision) => sum + revision.rows.length, 0)
|
||||
if (totalRows === 0) {
|
||||
throw new Error('No opencode terminal perf annotations found in the provided reports')
|
||||
}
|
||||
const html = renderHtml({ generatedAt: now.toISOString(), revisions })
|
||||
mkdirSync(dirname(outputPath), { recursive: true })
|
||||
writeFileSync(outputPath, html)
|
||||
const latestFailures = revisions
|
||||
.at(-1)
|
||||
.rows.reduce((sum, row) => sum + budgetFailures(row).length, 0)
|
||||
return { outputPath, rowCount: totalRows, budgetFailureCount: latestFailures }
|
||||
}
|
||||
|
||||
const isMain = process.argv[1] && import.meta.filename === process.argv[1]
|
||||
if (isMain) {
|
||||
try {
|
||||
const { inputs, outputPath } = parseHtmlReportArgs(process.argv.slice(2))
|
||||
const result = generateTerminalPerfHtmlReport({ inputs, outputPath })
|
||||
console.log(
|
||||
`Terminal perf HTML report saved to ${result.outputPath} (${result.rowCount} rows, ${result.budgetFailureCount} budget failures).`
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error))
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
generateTerminalPerfHtmlReport,
|
||||
parseHtmlReportArgs
|
||||
} from './generate-terminal-perf-html-report.mjs'
|
||||
|
||||
const tempDirs = []
|
||||
|
||||
function makeTempDir() {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'orca-terminal-perf-html-'))
|
||||
tempDirs.push(dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
function writeReport(
|
||||
annotationDescription,
|
||||
annotationType = 'opencode-scale-same-workspace-25',
|
||||
reportName = 'report.json'
|
||||
) {
|
||||
const dir = makeTempDir()
|
||||
const reportPath = join(dir, reportName)
|
||||
writeFileSync(
|
||||
reportPath,
|
||||
JSON.stringify({
|
||||
suites: [
|
||||
{
|
||||
specs: [
|
||||
{
|
||||
tests: [
|
||||
{
|
||||
annotations: [
|
||||
{
|
||||
type: annotationType,
|
||||
description: annotationDescription
|
||||
},
|
||||
{
|
||||
type: 'browser-unrelated',
|
||||
description: 'median=999.0ms'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
return reportPath
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
while (tempDirs.length > 0) {
|
||||
rmSync(tempDirs.pop(), { force: true, recursive: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe('generate-terminal-perf-html-report', () => {
|
||||
it('parses labeled and bare input paths plus output flags', () => {
|
||||
expect(parseHtmlReportArgs(['--', 'a.json', 'b.json', '--output', 'out.html'])).toEqual({
|
||||
inputs: [
|
||||
{ label: 'a', path: 'a.json' },
|
||||
{ label: 'b', path: 'b.json' }
|
||||
],
|
||||
outputPath: 'out.html'
|
||||
})
|
||||
expect(parseHtmlReportArgs(['main=runs/0-main.json', '#5038 final=runs/4-final.json'])).toEqual(
|
||||
{
|
||||
inputs: [
|
||||
{ label: 'main', path: 'runs/0-main.json' },
|
||||
{ label: '#5038 final', path: 'runs/4-final.json' }
|
||||
],
|
||||
outputPath: 'test-results/terminal-perf-impact-report.html'
|
||||
}
|
||||
)
|
||||
expect(
|
||||
parseHtmlReportArgs(['a.json'], { ORCA_E2E_TERMINAL_PERF_HTML_REPORT_PATH: 'env.html' })
|
||||
).toEqual({
|
||||
inputs: [{ label: 'a', path: 'a.json' }],
|
||||
outputPath: 'env.html'
|
||||
})
|
||||
expect(() => parseHtmlReportArgs(['--output'])).toThrow('--output requires a path')
|
||||
expect(() => parseHtmlReportArgs([])).toThrow('Usage:')
|
||||
})
|
||||
|
||||
it('writes a single-run report with scenario tables and budget status', () => {
|
||||
const reportPath = writeReport(
|
||||
[
|
||||
'panes=25',
|
||||
'frames=60',
|
||||
'median=12.4ms',
|
||||
'worst=44.8ms',
|
||||
'revisit=28.6ms',
|
||||
'scroll=61.0ms',
|
||||
'restore=320.0ms',
|
||||
'maxTimerDrift=8.0ms',
|
||||
'rendererPeakQueuedChars=2048',
|
||||
'mainPeakInFlightChars=4096',
|
||||
'heldAckChars=1024',
|
||||
'hiddenSkippedChars=512',
|
||||
'rendererDroppedBacklogs=0'
|
||||
].join(' ')
|
||||
)
|
||||
const outputPath = join(makeTempDir(), 'report.html')
|
||||
|
||||
const result = generateTerminalPerfHtmlReport({
|
||||
inputPaths: [reportPath],
|
||||
outputPath,
|
||||
now: new Date('2026-06-09T10:00:00.000Z')
|
||||
})
|
||||
|
||||
const html = readFileSync(outputPath, 'utf8')
|
||||
expect(result).toEqual({ budgetFailureCount: 0, outputPath, rowCount: 1 })
|
||||
expect(html).toContain('<!doctype html>')
|
||||
expect(html).toContain('Terminal Performance Over Time')
|
||||
expect(html).toContain('2026-06-09T10:00:00.000Z')
|
||||
expect(html).toContain('Same workspace panes — 25 panes')
|
||||
expect(html).toContain('opencode-scale-same-workspace-25')
|
||||
expect(html).toContain('28.6ms')
|
||||
expect(html).toContain('Pass')
|
||||
// Why: one run has no over-time story; the trend section must not render.
|
||||
expect(html).not.toContain('Trends across revisions')
|
||||
expect(html).not.toContain('browser-unrelated')
|
||||
})
|
||||
|
||||
it('renders parked-memory heap and live view counts as table metrics', () => {
|
||||
const reportPath = writeReport(
|
||||
'panes=8 parkedTabs=8 heapUsedMB=142.5 liveTerminals=1 livePaneManagers=1',
|
||||
'opencode-parked-memory'
|
||||
)
|
||||
const outputPath = join(makeTempDir(), 'report.html')
|
||||
|
||||
const result = generateTerminalPerfHtmlReport({ inputPaths: [reportPath], outputPath })
|
||||
|
||||
const html = readFileSync(outputPath, 'utf8')
|
||||
// Why: heapUsedMB has no budget — a memory row alone must not fail gates.
|
||||
expect(result.budgetFailureCount).toBe(0)
|
||||
expect(html).toContain('Parked hidden terminal memory — 8 panes')
|
||||
expect(html).toContain('Renderer JS heap (MB)')
|
||||
expect(html).toContain('142.5')
|
||||
expect(html).toContain('Live xterm instances')
|
||||
expect(html).toContain('Live pane managers')
|
||||
})
|
||||
|
||||
it('marks over-budget rows as failures for the latest run', () => {
|
||||
const reportPath = writeReport(
|
||||
[
|
||||
'panes=100',
|
||||
'median=80.0ms',
|
||||
'worst=301.0ms',
|
||||
'revisit=301.0ms',
|
||||
'rendererPeakQueuedChars=2097153',
|
||||
'rendererDroppedBacklogs=1'
|
||||
].join(' '),
|
||||
'opencode-scale-cross-workspace-100'
|
||||
)
|
||||
const outputPath = join(makeTempDir(), 'report.html')
|
||||
|
||||
const result = generateTerminalPerfHtmlReport({ inputPaths: [reportPath], outputPath })
|
||||
|
||||
const html = readFileSync(outputPath, 'utf8')
|
||||
expect(result.budgetFailureCount).toBe(5)
|
||||
expect(html).toContain('Fail')
|
||||
expect(html).toContain('medianMs 80 > 75')
|
||||
expect(html).toContain('Cross-workspace hidden panes')
|
||||
})
|
||||
|
||||
it('renders ordered revisions with trend charts and baseline deltas', () => {
|
||||
const mainReport = writeReport(
|
||||
'panes=25 median=50.0ms worst=120.0ms rendererDroppedBacklogs=0',
|
||||
'opencode-scale-same-workspace-25',
|
||||
'main.json'
|
||||
)
|
||||
const middleReport = writeReport(
|
||||
'panes=25 median=30.0ms worst=140.0ms rendererDroppedBacklogs=0',
|
||||
'opencode-scale-same-workspace-25',
|
||||
'backpressure.json'
|
||||
)
|
||||
const finalReport = writeReport(
|
||||
'panes=25 median=20.0ms worst=100.0ms rendererDroppedBacklogs=0',
|
||||
'opencode-scale-same-workspace-25',
|
||||
'final.json'
|
||||
)
|
||||
const outputPath = join(makeTempDir(), 'report.html')
|
||||
|
||||
const result = generateTerminalPerfHtmlReport({
|
||||
inputs: [
|
||||
{ label: 'main', path: mainReport },
|
||||
{ label: 'backpressure', path: middleReport },
|
||||
{ label: 'final', path: finalReport }
|
||||
],
|
||||
outputPath
|
||||
})
|
||||
|
||||
const html = readFileSync(outputPath, 'utf8')
|
||||
expect(result.rowCount).toBe(3)
|
||||
expect(html).toContain('Baseline vs latest')
|
||||
expect(html).toContain('Trends across revisions')
|
||||
expect(html).toContain('trend-chart')
|
||||
expect(html).toContain('>main<')
|
||||
expect(html).toContain('>backpressure<')
|
||||
expect(html).toContain('>final<')
|
||||
// Why: median 50 -> 20 is a 60% improvement and must read as better.
|
||||
expect(html).toContain('delta better')
|
||||
expect(html).toContain('-60%')
|
||||
expect(html).toContain('50.0ms → 20.0ms')
|
||||
})
|
||||
|
||||
it('renders missing scenarios at older revisions as gaps, not zeros', () => {
|
||||
const mainReport = writeReport(
|
||||
'panes=25 median=50.0ms rendererDroppedBacklogs=0',
|
||||
'opencode-scale-same-workspace-25',
|
||||
'main.json'
|
||||
)
|
||||
const finalReport = makeTempDir()
|
||||
const finalPath = join(finalReport, 'final.json')
|
||||
writeFileSync(
|
||||
finalPath,
|
||||
JSON.stringify({
|
||||
suites: [
|
||||
{
|
||||
specs: [
|
||||
{
|
||||
tests: [
|
||||
{
|
||||
annotations: [
|
||||
{
|
||||
type: 'opencode-scale-same-workspace-25',
|
||||
description: 'panes=25 median=40.0ms rendererDroppedBacklogs=0'
|
||||
},
|
||||
{
|
||||
type: 'opencode-revisit-pressure',
|
||||
description: 'panes=19 median=3.0ms revisit=4.4ms rendererDroppedBacklogs=0'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
const outputPath = join(makeTempDir(), 'report.html')
|
||||
|
||||
generateTerminalPerfHtmlReport({
|
||||
inputs: [
|
||||
{ label: 'main', path: mainReport },
|
||||
{ label: 'final', path: finalPath }
|
||||
],
|
||||
outputPath
|
||||
})
|
||||
|
||||
const html = readFileSync(outputPath, 'utf8')
|
||||
expect(html).toContain('Revisit under pressure')
|
||||
expect(html).toContain('<td>—</td>')
|
||||
})
|
||||
|
||||
it('fails when reports contain no terminal perf annotations', () => {
|
||||
const reportPath = writeReport('median=12.0ms', 'browser-unrelated')
|
||||
|
||||
expect(() =>
|
||||
generateTerminalPerfHtmlReport({
|
||||
inputPaths: [reportPath],
|
||||
outputPath: join(makeTempDir(), 'report.html')
|
||||
})
|
||||
).toThrow('No opencode terminal perf annotations found')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { parse } from 'yaml'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
describe('Git binary compatibility PR gate', () => {
|
||||
it('runs the real-binary contract at each compatibility boundary', () => {
|
||||
const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8'))
|
||||
const step = workflow.jobs.verify.steps.find(
|
||||
(candidate) => candidate.name === 'Verify Git binary compatibility matrix'
|
||||
)
|
||||
|
||||
expect(step?.run).toContain('git-2.25.5.tar.gz')
|
||||
expect(step?.run).toContain('41662c52fc16fec4963bfc41075e71f8ead6b5e386797eb6f9a1111ff95a8ddf')
|
||||
expect(step?.run).toContain('ORCA_GIT_COMPAT_BINARY="$source/git"')
|
||||
expect(step?.run).toContain('alpine/git:edge-2.38.1|2.38.1')
|
||||
expect(step?.run).toContain('alpine/git:v2.49.1|2.49.1')
|
||||
expect(step?.run).toContain('ORCA_GIT_COMPAT_IMAGE="$image"')
|
||||
expect(step?.run).toContain('src/shared/git-binary-compatibility.test.ts')
|
||||
})
|
||||
})
|
||||
@@ -4,7 +4,8 @@ import { createHash } from 'node:crypto'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import process from 'node:process'
|
||||
|
||||
import ts from 'typescript'
|
||||
// TypeScript 7 is a native CLI; AST consumers still need the legacy JavaScript API.
|
||||
import ts from 'typescript-api'
|
||||
|
||||
import { collectLocalizationCandidates } from './audit-localization-coverage.mjs'
|
||||
|
||||
|
||||
@@ -25,7 +25,11 @@ export function rcNumberFromTag(base, tag) {
|
||||
}
|
||||
|
||||
const suffix = tag.slice(prefix.length)
|
||||
return /^\d+$/.test(suffix) ? Number(suffix) : null
|
||||
// Why the optional .identifier: suffixed side-branch RCs (v1.2.3-rc.4.perf)
|
||||
// must advance the shared rc counter, or the next suffixed cut recomputes
|
||||
// an existing tag and the workflow refuses to re-cut over it.
|
||||
const match = /^(\d+)(?:\.[0-9A-Za-z]+)?$/.exec(suffix)
|
||||
return match ? Number(match[1]) : null
|
||||
}
|
||||
|
||||
export function rcNumberFromReleaseSubject(base, subject) {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Entry point for the multi-workspace typing-latency bench
|
||||
* (tests/e2e/terminal-multi-workspace-typing-latency.spec.ts).
|
||||
*
|
||||
* Usage:
|
||||
* pnpm bench:multi-workspace-typing [-- --panes 8 --rate-kbps 512 \
|
||||
* --keys 48 --cadence-ms 250 --cpu-workers 4 --label before-fix]
|
||||
*
|
||||
* Results land in tools/benchmarks/results/multi-workspace-typing-*.json.
|
||||
* Run once per build/config with distinct --label values, then diff the
|
||||
* totalMs/inputHalfMs/echoHalfMs percentiles.
|
||||
*/
|
||||
import { spawn } from 'node:child_process'
|
||||
|
||||
const npxCommand = process.platform === 'win32' ? 'npx.cmd' : 'npx'
|
||||
|
||||
const knobByFlag = {
|
||||
'--panes': 'ORCA_TYPING_BENCH_LOAD_PANES',
|
||||
'--rate-kbps': 'ORCA_TYPING_BENCH_RATE_KBPS',
|
||||
'--keys': 'ORCA_TYPING_BENCH_KEYS',
|
||||
'--cadence-ms': 'ORCA_TYPING_BENCH_KEY_CADENCE_MS',
|
||||
'--cpu-workers': 'ORCA_TYPING_BENCH_CPU_WORKERS',
|
||||
'--label': 'ORCA_TYPING_BENCH_LABEL'
|
||||
}
|
||||
|
||||
const env = { ...process.env, ORCA_TYPING_BENCH: '1' }
|
||||
const passthroughArgs = []
|
||||
const argv = process.argv.slice(2)
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
if (argv[i] === '--') {
|
||||
continue
|
||||
}
|
||||
const knob = knobByFlag[argv[i]]
|
||||
if (knob) {
|
||||
env[knob] = argv[++i]
|
||||
} else {
|
||||
passthroughArgs.push(argv[i])
|
||||
}
|
||||
}
|
||||
|
||||
const child = spawn(
|
||||
npxCommand,
|
||||
[
|
||||
'playwright',
|
||||
'test',
|
||||
'tests/e2e/terminal-multi-workspace-typing-latency.spec.ts',
|
||||
'--config',
|
||||
'tests/playwright.config.ts',
|
||||
'--project',
|
||||
'electron-headless',
|
||||
'--workers=1',
|
||||
...passthroughArgs
|
||||
],
|
||||
{ stdio: 'inherit', env }
|
||||
)
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
if (signal) {
|
||||
process.kill(process.pid, signal)
|
||||
return
|
||||
}
|
||||
process.exit(code ?? 1)
|
||||
})
|
||||
@@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
const DEFAULT_REPORT_PATH = 'test-results/terminal-scale-perf-report.json'
|
||||
const DEFAULT_HTML_REPORT_PATH = 'test-results/terminal-perf-impact-report.html'
|
||||
|
||||
export function parseReportGateArgs(argv, env = process.env) {
|
||||
const forwardedArgs = [...argv]
|
||||
@@ -114,7 +115,20 @@ export function runTerminalScalePerfReportGate({
|
||||
spawnSyncImpl,
|
||||
env
|
||||
)
|
||||
return exitCode(budgetResult)
|
||||
const budgetExitCode = exitCode(budgetResult)
|
||||
if (budgetExitCode !== 0) {
|
||||
return budgetExitCode
|
||||
}
|
||||
|
||||
const htmlReportPath = env.ORCA_E2E_TERMINAL_PERF_HTML_REPORT_PATH || DEFAULT_HTML_REPORT_PATH
|
||||
const htmlResult = runNodeScript(
|
||||
'config/scripts/generate-terminal-perf-html-report.mjs',
|
||||
[reportPath, '--output', htmlReportPath],
|
||||
'inherit',
|
||||
spawnSyncImpl,
|
||||
env
|
||||
)
|
||||
return exitCode(htmlResult)
|
||||
}
|
||||
|
||||
if (process.argv[1] === import.meta.filename) {
|
||||
|
||||
@@ -77,7 +77,8 @@ describe('run-terminal-scale-perf-report-gate', () => {
|
||||
expect(calls.map((call) => call.args[0])).toEqual([
|
||||
'config/scripts/run-terminal-scale-perf-e2e.mjs',
|
||||
'config/scripts/summarize-terminal-perf-report.mjs',
|
||||
'config/scripts/check-terminal-perf-report-budgets.mjs'
|
||||
'config/scripts/check-terminal-perf-report-budgets.mjs',
|
||||
'config/scripts/generate-terminal-perf-html-report.mjs'
|
||||
])
|
||||
expect(calls[0].args).toEqual([
|
||||
'config/scripts/run-terminal-scale-perf-e2e.mjs',
|
||||
@@ -92,6 +93,12 @@ describe('run-terminal-scale-perf-report-gate', () => {
|
||||
'config/scripts/check-terminal-perf-report-budgets.mjs',
|
||||
reportPath
|
||||
])
|
||||
expect(calls[3].args).toEqual([
|
||||
'config/scripts/generate-terminal-perf-html-report.mjs',
|
||||
reportPath,
|
||||
'--output',
|
||||
'test-results/terminal-perf-impact-report.html'
|
||||
])
|
||||
})
|
||||
|
||||
it('uses the report path from env when no flag is provided', () => {
|
||||
@@ -107,6 +114,28 @@ describe('run-terminal-scale-perf-report-gate', () => {
|
||||
expect(calls[1].args).toEqual(['config/scripts/summarize-terminal-perf-report.mjs', reportPath])
|
||||
})
|
||||
|
||||
it('uses the HTML report path from env when provided', () => {
|
||||
const reportPath = tempReportPath()
|
||||
const { calls, spawnSyncImpl } = makeSpawnSync()
|
||||
|
||||
const status = runTerminalScalePerfReportGate({
|
||||
env: {
|
||||
...process.env,
|
||||
ORCA_E2E_TERMINAL_PERF_HTML_REPORT_PATH: 'tmp/terminal-report.html',
|
||||
ORCA_E2E_TERMINAL_PERF_REPORT_PATH: reportPath
|
||||
},
|
||||
spawnSyncImpl
|
||||
})
|
||||
|
||||
expect(status).toBe(0)
|
||||
expect(calls[3].args).toEqual([
|
||||
'config/scripts/generate-terminal-perf-html-report.mjs',
|
||||
reportPath,
|
||||
'--output',
|
||||
'tmp/terminal-report.html'
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves the report when Playwright clears the target report directory', () => {
|
||||
const reportPath = tempReportPath()
|
||||
const { spawnSyncImpl } = makeSpawnSync({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import ts from 'typescript'
|
||||
// TypeScript 7 is a native CLI; AST consumers still need the legacy JavaScript API.
|
||||
import ts from 'typescript-api'
|
||||
|
||||
const STYLED_SCROLLBAR_CLASSES = new Set(
|
||||
'scrollbar-sleek scrollbar-editor worktree-sidebar-scrollbar'.split(' ')
|
||||
|
||||
@@ -25,6 +25,7 @@ function printMarkdownTable(rows) {
|
||||
['Frames', 'frames'],
|
||||
['Median', 'median'],
|
||||
['Worst', 'worst'],
|
||||
['Revisit', 'revisit'],
|
||||
['Scroll', 'scroll'],
|
||||
['Restore', 'restore'],
|
||||
['Max Drift', 'maxTimerDrift'],
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
const BUDGETS = {
|
||||
medianMs: 75,
|
||||
worstMs: 300,
|
||||
revisitMs: 300,
|
||||
maxTimerDriftMs: 150,
|
||||
scrollMs: 150,
|
||||
restoreMs: 1000,
|
||||
rendererQueuedChars: 2 * 1024 * 1024,
|
||||
rendererPeakQueuedChars: 2 * 1024 * 1024,
|
||||
rendererDroppedBacklogs: 0
|
||||
}
|
||||
|
||||
const SCENARIO_LABELS = [
|
||||
['opencode-scale-same-workspace', 'Same workspace panes'],
|
||||
['opencode-scale-cross-workspace', 'Cross-workspace hidden panes'],
|
||||
['opencode-scale-pressure', 'ACK-backpressured PTYs'],
|
||||
['opencode-scale-hidden-pressure', 'Hidden real PTYs'],
|
||||
['opencode-cross-workspace-typing', 'Cross-workspace typing'],
|
||||
['opencode-main-pressure', 'Main renderer pressure'],
|
||||
['opencode-hidden-pressure', 'Hidden pressure'],
|
||||
['opencode-revisit-pressure', 'Revisit under pressure'],
|
||||
// Why: the prefix also matches opencode-parked-memory-disabled, so both
|
||||
// parked-memory scenarios group under one label.
|
||||
['opencode-parked-memory', 'Parked hidden terminal memory']
|
||||
]
|
||||
|
||||
export function readJsonReport(path) {
|
||||
const raw = readFileSync(path, 'utf8')
|
||||
const start = raw.indexOf('{')
|
||||
const end = raw.lastIndexOf('}')
|
||||
if (start === -1 || end <= start) {
|
||||
throw new Error(`${path}: no JSON object found`)
|
||||
}
|
||||
return JSON.parse(raw.slice(start, end + 1))
|
||||
}
|
||||
|
||||
function parseAnnotationDescription(description) {
|
||||
const values = {}
|
||||
for (const part of description.split(/\s+/)) {
|
||||
const index = part.indexOf('=')
|
||||
if (index === -1) {
|
||||
continue
|
||||
}
|
||||
values[part.slice(0, index)] = part.slice(index + 1)
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
export function collectTerminalPerfRows(report, source) {
|
||||
const rows = []
|
||||
const visitSuite = (suite) => {
|
||||
for (const spec of suite.specs ?? []) {
|
||||
for (const test of spec.tests ?? []) {
|
||||
for (const annotation of test.annotations ?? []) {
|
||||
if (!annotation.type.startsWith('opencode-')) {
|
||||
continue
|
||||
}
|
||||
rows.push(
|
||||
normalizeRow({
|
||||
source,
|
||||
scenario: annotation.type,
|
||||
...parseAnnotationDescription(annotation.description ?? '')
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const child of suite.suites ?? []) {
|
||||
visitSuite(child)
|
||||
}
|
||||
}
|
||||
for (const suite of report.suites ?? []) {
|
||||
visitSuite(suite)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
function parseMs(value) {
|
||||
const match = String(value ?? '').match(/^(-?\d+(?:\.\d+)?)ms$/)
|
||||
return match ? Number(match[1]) : null
|
||||
}
|
||||
|
||||
function parseCount(value) {
|
||||
if (value == null || value === '') {
|
||||
return null
|
||||
}
|
||||
const count = Number(value)
|
||||
return Number.isFinite(count) ? count : null
|
||||
}
|
||||
|
||||
function normalizeRow(row) {
|
||||
return {
|
||||
...row,
|
||||
group: scenarioGroup(row.scenario),
|
||||
panes: parseCount(row.panes),
|
||||
frames: parseCount(row.frames),
|
||||
medianMs: parseMs(row.median),
|
||||
worstMs: parseMs(row.worst),
|
||||
revisitMs: parseMs(row.revisit),
|
||||
maxTimerDriftMs: parseMs(row.maxTimerDrift),
|
||||
scrollMs: parseMs(row.scroll),
|
||||
restoreMs: parseMs(row.restore),
|
||||
rendererQueuedChars: parseCount(row.rendererQueuedChars),
|
||||
rendererPeakQueuedChars: parseCount(row.rendererPeakQueuedChars),
|
||||
rendererDroppedBacklogs: parseCount(row.rendererDroppedBacklogs),
|
||||
mainPeakPendingChars: parseCount(row.mainPeakPendingChars),
|
||||
mainPeakInFlightChars: parseCount(row.mainPeakInFlightChars),
|
||||
heldAckChars: parseCount(row.heldAckChars),
|
||||
hiddenSkippedChars: parseCount(row.hiddenSkippedChars),
|
||||
// Why: parked-memory annotations report a fractional MB heap figure plus
|
||||
// live renderer view counts; Number() keeps the MB float intact.
|
||||
heapUsedMB: parseCount(row.heapUsedMB),
|
||||
liveTerminals: parseCount(row.liveTerminals),
|
||||
livePaneManagers: parseCount(row.livePaneManagers)
|
||||
}
|
||||
}
|
||||
|
||||
export function scenarioGroup(scenario) {
|
||||
for (const [prefix, label] of SCENARIO_LABELS) {
|
||||
if (scenario.startsWith(prefix)) {
|
||||
return label
|
||||
}
|
||||
}
|
||||
return 'Other terminal scenarios'
|
||||
}
|
||||
|
||||
function scenarioSortKey(scenario) {
|
||||
const prefixIndex = SCENARIO_LABELS.findIndex(([prefix]) => scenario.startsWith(prefix))
|
||||
const paneMatch = scenario.match(/-(\d+)$/)
|
||||
return [
|
||||
prefixIndex === -1 ? SCENARIO_LABELS.length : prefixIndex,
|
||||
paneMatch ? Number(paneMatch[1]) : 0,
|
||||
scenario
|
||||
]
|
||||
}
|
||||
|
||||
export function compareScenarios(a, b) {
|
||||
const ka = scenarioSortKey(a)
|
||||
const kb = scenarioSortKey(b)
|
||||
if (ka[0] !== kb[0]) {
|
||||
return ka[0] - kb[0]
|
||||
}
|
||||
if (ka[1] !== kb[1]) {
|
||||
return ka[1] - kb[1]
|
||||
}
|
||||
return ka[2] < kb[2] ? -1 : ka[2] > kb[2] ? 1 : 0
|
||||
}
|
||||
|
||||
export function scenarioTitle(scenario, row) {
|
||||
const group = scenarioGroup(scenario)
|
||||
if (row?.panes != null) {
|
||||
return `${group} — ${row.panes} panes`
|
||||
}
|
||||
return group
|
||||
}
|
||||
|
||||
export function budgetFailures(row) {
|
||||
const failures = []
|
||||
for (const [key, budget] of Object.entries(BUDGETS)) {
|
||||
const value = row[key]
|
||||
if (value == null) {
|
||||
continue
|
||||
}
|
||||
if (value > budget) {
|
||||
failures.push(`${key} ${value} > ${budget}`)
|
||||
}
|
||||
}
|
||||
return failures
|
||||
}
|
||||
|
||||
export function formatMs(value) {
|
||||
if (value == null) {
|
||||
return '—'
|
||||
}
|
||||
return `${value.toFixed(1)}ms`
|
||||
}
|
||||
|
||||
export function formatLargeValue(value) {
|
||||
if (value == null) {
|
||||
return '—'
|
||||
}
|
||||
if (value >= 1024 * 1024) {
|
||||
return `${(value / (1024 * 1024)).toFixed(2)}M`
|
||||
}
|
||||
if (value >= 1024) {
|
||||
return `${Math.round(value / 1024)}k`
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
export function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
}
|
||||
@@ -3,7 +3,8 @@ import path from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import process from 'node:process'
|
||||
|
||||
import ts from 'typescript'
|
||||
// TypeScript 7 is a native CLI; AST consumers still need the legacy JavaScript API.
|
||||
import ts from 'typescript-api'
|
||||
|
||||
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts'])
|
||||
const SKIP_PATH_PARTS = new Set(['.git', 'dist', 'node_modules', 'out', '__snapshots__', 'assets'])
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* Reproduces issue #8048 against the built daemon on Windows.
|
||||
*
|
||||
* A witness PowerShell stays alive while victim sessions receive the same
|
||||
* graceful-then-immediate kill pair emitted when Orca closes a workspace.
|
||||
* The daemon PID and witness session must survive every iteration.
|
||||
*/
|
||||
import { fork } from 'node:child_process'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { connect } from 'node:net'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
|
||||
const projectDir = resolve(import.meta.dirname, '../..')
|
||||
const entryPath = join(projectDir, 'out', 'main', 'daemon-entry.js')
|
||||
const iterations = Number(process.env.ORCA_WINDOWS_DAEMON_CLOSE_ITERATIONS ?? 25)
|
||||
const requestTimeoutMs = 15_000
|
||||
|
||||
function log(message) {
|
||||
process.stdout.write(`[windows-daemon-workspace-close] ${message}\n`)
|
||||
}
|
||||
|
||||
function readProtocolVersion() {
|
||||
const source = readFileSync(join(projectDir, 'src/main/daemon/types.ts'), 'utf8')
|
||||
const match = source.match(/PROTOCOL_VERSION\s*=\s*(\d+)/)
|
||||
if (!match) {
|
||||
throw new Error('Could not read the daemon protocol version')
|
||||
}
|
||||
return Number(match[1])
|
||||
}
|
||||
|
||||
function createRpcClient(socketPath, tokenPath) {
|
||||
const socket = connect(socketPath)
|
||||
const pending = new Map()
|
||||
let buffer = ''
|
||||
let requestId = 0
|
||||
let helloResolve
|
||||
let helloReject
|
||||
const hello = new Promise((resolveHello, rejectHello) => {
|
||||
helloResolve = resolveHello
|
||||
helloReject = rejectHello
|
||||
})
|
||||
|
||||
const rejectPending = (error) => {
|
||||
helloReject(error)
|
||||
for (const { reject, timer } of pending.values()) {
|
||||
clearTimeout(timer)
|
||||
reject(error)
|
||||
}
|
||||
pending.clear()
|
||||
}
|
||||
|
||||
socket.on('error', rejectPending)
|
||||
socket.on('close', () => rejectPending(new Error('Daemon control socket closed')))
|
||||
socket.on('data', (chunk) => {
|
||||
buffer += chunk.toString('utf8')
|
||||
let newline = buffer.indexOf('\n')
|
||||
while (newline !== -1) {
|
||||
const line = buffer.slice(0, newline)
|
||||
buffer = buffer.slice(newline + 1)
|
||||
const message = JSON.parse(line)
|
||||
if (message.type === 'hello') {
|
||||
if (message.ok) {
|
||||
helloResolve()
|
||||
} else {
|
||||
helloReject(new Error(message.error ?? 'Daemon rejected hello'))
|
||||
}
|
||||
} else if (message.id) {
|
||||
const request = pending.get(message.id)
|
||||
if (request) {
|
||||
pending.delete(message.id)
|
||||
clearTimeout(request.timer)
|
||||
if (message.ok) {
|
||||
request.resolve(message.payload)
|
||||
} else {
|
||||
request.reject(new Error(message.error ?? 'Daemon request failed'))
|
||||
}
|
||||
}
|
||||
}
|
||||
newline = buffer.indexOf('\n')
|
||||
}
|
||||
})
|
||||
|
||||
const connected = new Promise((resolveConnected, rejectConnected) => {
|
||||
socket.once('connect', resolveConnected)
|
||||
socket.once('error', rejectConnected)
|
||||
}).then(() => {
|
||||
socket.write(
|
||||
`${JSON.stringify({
|
||||
type: 'hello',
|
||||
version: readProtocolVersion(),
|
||||
token: readFileSync(tokenPath, 'utf8').trim(),
|
||||
clientId: randomUUID(),
|
||||
role: 'control'
|
||||
})}\n`
|
||||
)
|
||||
return hello
|
||||
})
|
||||
|
||||
return {
|
||||
async request(type, payload) {
|
||||
await connected
|
||||
const id = `repro-${++requestId}`
|
||||
return new Promise((resolveRequest, rejectRequest) => {
|
||||
const timer = setTimeout(() => {
|
||||
pending.delete(id)
|
||||
rejectRequest(new Error(`Daemon request ${type} timed out`))
|
||||
}, requestTimeoutMs)
|
||||
pending.set(id, { resolve: resolveRequest, reject: rejectRequest, timer })
|
||||
socket.write(`${JSON.stringify({ id, type, ...(payload ? { payload } : {}) })}\n`)
|
||||
})
|
||||
},
|
||||
close() {
|
||||
socket.destroy()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function waitForReady(child, stderr) {
|
||||
return new Promise((resolveReady, rejectReady) => {
|
||||
const timer = setTimeout(
|
||||
() => rejectReady(new Error(`Daemon readiness timed out.\n${stderr()}`)),
|
||||
requestTimeoutMs
|
||||
)
|
||||
child.on('message', (message) => {
|
||||
if (message?.type === 'ready') {
|
||||
clearTimeout(timer)
|
||||
resolveReady()
|
||||
}
|
||||
})
|
||||
child.once('exit', (code, signal) => {
|
||||
clearTimeout(timer)
|
||||
rejectReady(
|
||||
new Error(`Daemon exited before readiness (code=${code}, signal=${signal}).\n${stderr()}`)
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function stopChild(child) {
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
return
|
||||
}
|
||||
const exited = new Promise((resolveExit) => child.once('exit', resolveExit))
|
||||
child.kill('SIGTERM')
|
||||
await Promise.race([
|
||||
exited,
|
||||
new Promise((resolveTimeout) =>
|
||||
setTimeout(() => {
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
child.kill('SIGKILL')
|
||||
}
|
||||
resolveTimeout()
|
||||
}, 5_000)
|
||||
)
|
||||
])
|
||||
}
|
||||
|
||||
function isProcessAlive(pid) {
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
return true
|
||||
} catch (error) {
|
||||
if (error?.code === 'ESRCH') {
|
||||
return false
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForVictimExit(rpc, sessionId, pid) {
|
||||
const deadline = Date.now() + requestTimeoutMs
|
||||
while (Date.now() < deadline) {
|
||||
const { sessions } = await rpc.request('listSessions')
|
||||
const sessionAlive = sessions.some((session) => session.sessionId === sessionId)
|
||||
if (!sessionAlive && !isProcessAlive(pid)) {
|
||||
return sessions
|
||||
}
|
||||
await new Promise((resolveDelay) => setTimeout(resolveDelay, 25))
|
||||
}
|
||||
throw new Error(`Victim ${sessionId} or OS pid ${pid} was not reaped`)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (process.platform !== 'win32') {
|
||||
log('SKIP: Windows ConPTY is required')
|
||||
return
|
||||
}
|
||||
if (!existsSync(entryPath)) {
|
||||
throw new Error(`Missing ${entryPath}; run pnpm build:electron-vite first`)
|
||||
}
|
||||
|
||||
const scratch = mkdtempSync(join(tmpdir(), 'orca-windows-daemon-close-'))
|
||||
const socketPath = `\\\\.\\pipe\\orca-daemon-close-${process.pid}-${randomUUID()}`
|
||||
const tokenPath = join(scratch, 'daemon.token')
|
||||
const daemonLogPath = join(scratch, 'daemon.log')
|
||||
const child = fork(
|
||||
entryPath,
|
||||
['--socket', socketPath, '--token', tokenPath, '--log-file', daemonLogPath],
|
||||
{
|
||||
stdio: ['ignore', 'ignore', 'pipe', 'ipc'],
|
||||
windowsHide: true,
|
||||
env: { ...process.env, ORCA_USER_DATA_PATH: scratch }
|
||||
}
|
||||
)
|
||||
const daemonPid = child.pid
|
||||
let stderr = ''
|
||||
child.stderr?.on('data', (chunk) => {
|
||||
stderr += chunk.toString('utf8')
|
||||
})
|
||||
let rpc
|
||||
|
||||
try {
|
||||
await waitForReady(child, () => stderr)
|
||||
rpc = createRpcClient(socketPath, tokenPath)
|
||||
const witnessId = `repro-witness@@${randomUUID().slice(0, 8)}`
|
||||
await rpc.request('createOrAttach', {
|
||||
sessionId: witnessId,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: projectDir,
|
||||
shellOverride: 'powershell.exe'
|
||||
})
|
||||
|
||||
for (let index = 0; index < iterations; index += 1) {
|
||||
const victimId = `repro-victim-${index}@@${randomUUID().slice(0, 8)}`
|
||||
const victim = await rpc.request('createOrAttach', {
|
||||
sessionId: victimId,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: projectDir,
|
||||
shellOverride: 'powershell.exe'
|
||||
})
|
||||
if (!Number.isInteger(victim.pid) || victim.pid <= 0) {
|
||||
throw new Error(`Victim ${victimId} did not return a valid OS pid`)
|
||||
}
|
||||
|
||||
// Why: sending both RPCs before awaiting either preserves the renderer
|
||||
// unmount/worktree-sweep overlap that produced issue #8048.
|
||||
const graceful = rpc.request('kill', { sessionId: victimId, immediate: false })
|
||||
const forced = rpc.request('kill', { sessionId: victimId, immediate: true })
|
||||
await Promise.all([graceful, forced])
|
||||
|
||||
const sessions = await waitForVictimExit(rpc, victimId, victim.pid)
|
||||
if (child.pid !== daemonPid || child.exitCode !== null) {
|
||||
throw new Error(`Daemon PID ${daemonPid} exited while closing victim ${index}`)
|
||||
}
|
||||
if (!sessions.some((session) => session.sessionId === witnessId && session.isAlive)) {
|
||||
throw new Error(`Witness PTY disappeared while closing victim ${index}`)
|
||||
}
|
||||
}
|
||||
|
||||
await rpc.request('kill', { sessionId: witnessId, immediate: true })
|
||||
log(
|
||||
`PASS: ${iterations} victim sessions/PIDs were reaped while daemon ${daemonPid} and the witness PTY survived`
|
||||
)
|
||||
} catch (error) {
|
||||
const daemonLog = existsSync(daemonLogPath) ? readFileSync(daemonLogPath, 'utf8') : ''
|
||||
throw new Error(`${error.message}\nstderr:\n${stderr}\ndaemon.log:\n${daemonLog}`)
|
||||
} finally {
|
||||
rpc?.close()
|
||||
await stopChild(child)
|
||||
rmSync(scratch, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`[windows-daemon-workspace-close] FAIL: ${error.message}\n`)
|
||||
process.exitCode = 1
|
||||
})
|
||||
@@ -40,8 +40,9 @@
|
||||
],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "Node",
|
||||
// TypeScript 7 removed node10 resolution; Node16 preserves CommonJS emit for this package.
|
||||
"module": "Node16",
|
||||
"moduleResolution": "Node16",
|
||||
"rootDir": "../src",
|
||||
"outDir": "../out"
|
||||
}
|
||||
|
||||
@@ -10,10 +10,10 @@
|
||||
"../src/main/ipc/worktree-logic.ts",
|
||||
"../src/main/ipc/worktree-linked-work-item-metadata.ts",
|
||||
"../src/main/ipc/worktree-metadata-merge.ts",
|
||||
"../src/main/ipc/worktree-path-comparison.ts",
|
||||
"../src/main/wsl.ts"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"baseUrl": null,
|
||||
"paths": {
|
||||
"@renderer/*": ["../src/renderer/src/*"],
|
||||
"@/*": ["../src/renderer/src/*"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Kill all sessions also closes terminal tabs
|
||||
|
||||
GitHub: https://github.com/stablyai/orca/issues/8001
|
||||
GitHub: https://github.com/stablyai/orca/issues/8001
|
||||
Branch: `bug-kill-all-sessions-doesnt-kill-terminals`
|
||||
|
||||
## Problem
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# Git Compatibility Policy
|
||||
|
||||
## Scope
|
||||
|
||||
Orca executes the user's Git binary on three kinds of execution host: native,
|
||||
WSL, and SSH. Each host can have a different Git version, so compatibility
|
||||
state must be scoped to the host that actually runs the command.
|
||||
|
||||
Git 2.25 is the core-workflow compatibility baseline for command selection. It
|
||||
is the oldest line that covers Orca's baseline use of porcelain v2, `branch
|
||||
--show-current`, `restore`, and sparse checkout. Optional features that need a
|
||||
newer Git must degrade safely and cache the missing capability. Orca does not
|
||||
currently block older Git at startup, but new command construction should not
|
||||
assume features introduced after this baseline.
|
||||
|
||||
## Capability Rules
|
||||
|
||||
When a newer Git feature materially improves correctness or performance:
|
||||
|
||||
1. Keep a baseline-compatible command or parser as the fallback.
|
||||
2. Detect rejection with a narrow predicate for that option or subcommand.
|
||||
3. Run the preferred command through `GitCapabilityCache` so a rejection is
|
||||
remembered for the native host, WSL distro, or SSH provider that produced it.
|
||||
4. Retry after the cache interval so an in-place Git upgrade self-heals without
|
||||
restarting Orca.
|
||||
5. Test the first fallback, later calls that skip the rejected probe, concurrent
|
||||
probe coalescing, and execution-host isolation where applicable.
|
||||
|
||||
Do not branch only on a parsed `git --version`. Vendor builds can backport
|
||||
features, and wrappers can report a host version that differs from the binary
|
||||
used inside WSL or SSH. A behavior probe plus a precise fallback is the final
|
||||
authority.
|
||||
|
||||
## Current Capabilities
|
||||
|
||||
| Capability | Preferred behavior | Compatibility behavior |
|
||||
| ----------------------- | ------------------------------------------------- | --------------------------------------------------------------------------------------- |
|
||||
| `worktree-list-z` | NUL-delimited worktree paths | Line-block parser for Git before `worktree list -z` |
|
||||
| `rev-parse-path-format` | Absolute repo metadata paths | Resolve legacy relative output against the scanned repo |
|
||||
| `for-each-ref-exclude` | Exclude remote HEAD before the output limit | Request extra refs, then filter remote HEAD in Orca |
|
||||
| `merge-tree-write-tree` | Derive real-merge conflicts and no-op tree proofs | Omit the conflict summary and keep conservative branch cleanup behavior before Git 2.38 |
|
||||
| `merge-tree-merge-base` | Supply the already-resolved merge base | Use the older two-commit `merge-tree --write-tree` form |
|
||||
|
||||
## Why Not `simple-git`
|
||||
|
||||
`simple-git` is a process wrapper around the installed Git binary. Its custom
|
||||
options and `raw` API pass arguments through to Git, so it cannot make a newer
|
||||
flag work on an older binary or choose Orca's semantic fallback automatically.
|
||||
It provides version reporting and subprocess queueing, but Orca already needs
|
||||
its own WSL/SSH routing, cancellation, tracing, redaction, process cleanup, and
|
||||
bounded output handling. Replacing the runner would move—not remove—the
|
||||
capability problem.
|
||||
|
||||
## CI Contract
|
||||
|
||||
PR checks run the capability contract against real Git 2.25.5, 2.38.1, and
|
||||
2.49.1 binaries. This spans the core-workflow baseline, the transitional
|
||||
`merge-tree --write-tree` behavior before `--merge-base`, and current Git.
|
||||
|
||||
Keep the unit tests alongside that matrix. They cover concurrent probes,
|
||||
native/WSL/SSH/relay isolation, and error-stream shapes that a single real
|
||||
binary invocation cannot exercise deterministically.
|
||||
@@ -0,0 +1,115 @@
|
||||
# Terminal Hidden View Parking
|
||||
|
||||
Status: Shipped — Phase 1 of the terminal model/view architecture, kill switch
|
||||
`terminalHiddenViewParking` (default on). See
|
||||
[`terminal-model-view-contract.md`](./terminal-model-view-contract.md) for the
|
||||
invariants this design extends and the full phase list.
|
||||
|
||||
## Problem
|
||||
|
||||
Hidden terminal panes keep a full renderer xterm instance alive (buffer,
|
||||
scrollback, DOM, addons). At many-worktree scale this is the dominant renderer
|
||||
memory cost, and it forces every hidden byte through renderer-side write/skip
|
||||
decisions. The main-process model (daemon + runtime headless emulators) already
|
||||
ingests every byte and can serve restorable snapshots, so the renderer view for
|
||||
a long-hidden pane is redundant state.
|
||||
|
||||
A previous attempt shipped and was reverted the same day. The post-mortem
|
||||
finding: parking unmounted the pane component, which also tore down the
|
||||
renderer's PTY byte parsers — and those parsers are the only source of bell
|
||||
notifications, title-transition agent-complete notifications, and tab titles.
|
||||
A parked worktree whose agent finished would never notify. This design keeps
|
||||
those side effects alive while parked.
|
||||
|
||||
## Design
|
||||
|
||||
### Park policy (renderer)
|
||||
|
||||
A pure policy module decides which hidden terminal tabs may park:
|
||||
|
||||
- Cold-park hysteresis: a tab must be hidden for 30s before parking.
|
||||
- Hot-retain working set: recently visible worktrees/tabs are retained
|
||||
(5 minutes, bounded count) so quick tab switches never pay a re-hydrate.
|
||||
- Eligibility excludes: visible panes, hidden-measuring startup probes,
|
||||
activity-portal panes, tabs with pending startup commands or pending
|
||||
activation spawns, floating-panel tabs, and any tab whose PTY is not
|
||||
snapshot-backed (remote-runtime `remote:` PTYs and SSH PTYs are excluded).
|
||||
- Kill switch: `settings.terminalHiddenViewParking === false` disables parking
|
||||
entirely.
|
||||
|
||||
### Park mechanics
|
||||
|
||||
Parking a tab unmounts its `TerminalPane` React subtree (the overlay layer
|
||||
renders null for parked tabs). This is the same teardown that tab-group moves
|
||||
already exercise: transports detach but the PTY session, daemon model, and tab
|
||||
state all survive. The xterm instance, its buffers, DOM, and WebGL/addon
|
||||
resources are released.
|
||||
|
||||
### Parked watcher (the piece the reverted attempt lacked)
|
||||
|
||||
While a tab is parked, a pane-less watcher
|
||||
(`parked-terminal-byte-watcher.ts`) keeps the pane's side effects alive. Its
|
||||
consumption mode is decided once at watcher start:
|
||||
|
||||
- **Main side-effect authority on (default):** the watcher is purely
|
||||
fact-driven — it registers exactly one `pty:sideEffect` fact consumer and
|
||||
parses no bytes. Titles, agent working/idle/exited transitions, BEL
|
||||
attention, and PR links arrive as main-tracker facts and drive the same
|
||||
policy callbacks a mounted pane uses. With the hidden-delivery gate also on,
|
||||
the watcher marks the PTY hidden so main stops renderer byte delivery
|
||||
entirely; the DECSET 2031 color-scheme subscribe arrives as main's
|
||||
`2031-subscribe` fact and the watcher replies out-of-band via
|
||||
`transport.sendInput`.
|
||||
- **Kill switch off:** the watcher subscribes to raw bytes through the
|
||||
dispatcher sidecar mechanism (the same mechanism background agent launches
|
||||
use) and runs the transport-level byte parsers with no xterm — OSC 0/1/2
|
||||
titles (all-titles ordering, live-path normalization), the title-transition
|
||||
agent tracker (completion notification, prompt-cache timer), the OSC-aware
|
||||
stateful BEL detector, the GitHub PR link scan, and a dedicated DECSET 2031
|
||||
byte responder (`parked-terminal-mode2031-responder.ts`, whose
|
||||
`subscribeToPtyData` registration doubles as the delivery-interest signal).
|
||||
|
||||
The two modes drive one shared policy-callback block, so flipping the kill
|
||||
switch never changes notification semantics. Main's synthetic
|
||||
agent-title/permission frames feed the main tracker directly and arrive as
|
||||
facts; the legacy synthetic `pty:data` copy exists only in kill-switch-off
|
||||
mode.
|
||||
|
||||
Out of scope while parked: OSC 52 clipboard writes. Terminal queries inside
|
||||
hidden-dropped chunks are answered by main's model responder
|
||||
([`terminal-query-authority.md`](./terminal-query-authority.md)); in
|
||||
kill-switch-off byte mode only the 2031 reply is answered and Command Code
|
||||
output is not scraped, matching the pre-gate status quo.
|
||||
|
||||
### Reveal
|
||||
|
||||
Revealing a parked tab remounts the pane subtree and rides the existing
|
||||
reattach path: fresh xterm via `openTerminal` (unicode provider activation
|
||||
before any write), daemon model snapshot > relay replay > cold restore
|
||||
precedence, replay-guarded so snapshot-embedded queries never answer, then
|
||||
`POST_REPLAY_REATTACH_RESET` hygiene, fit, and PTY resize. The watcher is
|
||||
disposed before the pane handlers re-register.
|
||||
|
||||
## Invariants
|
||||
|
||||
1. PTY reads never stop; parking only changes renderer-side view lifetime.
|
||||
2. Bell, agent-completion, title, and PR-link side effects keep working while
|
||||
parked (watcher parity tests).
|
||||
3. Reveal shows model-correct output (visual gates: hidden TUI restore, long
|
||||
table, rendering golden) and accepts input immediately.
|
||||
4. Sleep/wake, pane close, and PTY restart while parked must not leak watchers
|
||||
or strand parked state.
|
||||
5. Memory: parked tabs hold no xterm buffers; renderer memory scales with
|
||||
visible panes.
|
||||
|
||||
## Relation to later phases (all shipped)
|
||||
|
||||
Side-effect authority in main (Phase 3) replaced the watcher's byte parsing
|
||||
with the `pty:sideEffect` fact consumer; the hidden-delivery gate (Phase 4)
|
||||
stops hidden byte delivery in main, moving the parked 2031 reply from the
|
||||
byte sidecar to the `2031-subscribe` fact; the model query responder
|
||||
(Phase 5) answers queries in hidden-dropped chunks. The watcher's byte-parser
|
||||
mode survives only behind the kill switches. Parking still excludes
|
||||
remote-runtime and SSH PTYs (no local snapshot to restore from); the watcher
|
||||
would return as a byte parser only if remote-runtime tabs — whose bytes never
|
||||
transit local main — ever became parkable.
|
||||
@@ -0,0 +1,218 @@
|
||||
# Terminal Model/View Contract
|
||||
|
||||
## Goal
|
||||
|
||||
Terminal output should have one authoritative model path and many disposable
|
||||
views. A renderer xterm is the fast interactive view, but it must not be the
|
||||
only place hidden, remote, mobile, SSH, or CLI-visible terminal state exists.
|
||||
|
||||
This contract defines the boundary the shipped terminal stack implements — and
|
||||
that future terminal work must preserve — without changing the query-response
|
||||
behavior that real shells and TUIs depend on. See [Architecture
|
||||
Status](#architecture-status) for the shipped phases.
|
||||
|
||||
## Terms
|
||||
|
||||
- **PTY stream:** Ordered bytes read from a local PTY, daemon PTY, SSH relay PTY,
|
||||
or remote runtime PTY.
|
||||
- **Terminal model:** Main/runtime-owned state derived from PTY bytes. Today this
|
||||
is mostly the headless emulator plus retained read transcript state.
|
||||
- **Terminal view:** A renderer xterm, mobile subscriber, remote desktop
|
||||
subscriber, or CLI read page consuming model state and live output.
|
||||
- **Snapshot:** A bounded model serialization that can restore a view without
|
||||
replaying an unbounded byte log.
|
||||
- **Transcript:** The retained output contract for `orca terminal read`; it is
|
||||
line/cursor oriented and distinct from a screen snapshot.
|
||||
|
||||
## Non-Negotiable Invariants
|
||||
|
||||
1. PTY reads do not stop to protect renderer performance. Backpressure may bound
|
||||
delivery to views, but terminal state, notifications, titles, and agent
|
||||
status keep advancing from the PTY stream.
|
||||
2. Active visible terminal input/output stays on the lowest-latency path. Bulk
|
||||
hidden or background output must not delay keystroke-sized foreground redraws.
|
||||
3. Hidden views do not own unbounded output memory. Main's hidden-delivery
|
||||
gate drops renderer-bound bytes for hidden-marked PTYs after model
|
||||
ingestion and emits an out-of-band restore marker
|
||||
(`pty:modelRestoreNeeded`) so the view restores from the model on reveal.
|
||||
With the gate's kill switches off, hidden bytes ride a bounded renderer
|
||||
queue whose overflow latches the same model restore.
|
||||
4. Returning to a hidden or slept terminal must show model-correct output. A
|
||||
stale or replaced view may be cleared and replayed from a snapshot, but it
|
||||
must not show a warning fallback when model recovery is available.
|
||||
5. Snapshots and live bytes have ordering metadata. A view restore must not
|
||||
duplicate bytes already included in the snapshot or drop bytes that arrived
|
||||
after it. Main buffer snapshots report the pending-delivery start sequence
|
||||
(`pendingDeliveryStartSeq`) so the renderer reconciles live chunks racing a
|
||||
restore without misreading foreign sequence domains as duplicates.
|
||||
6. Terminal query authority is singular and structural: the party that
|
||||
writes a chunk into a live terminal answers its queries. Visible renderer
|
||||
and remote views keep xterm authority. Chunks dropped by the
|
||||
hidden-delivery gate are answered exactly once by the main model
|
||||
responder, from runtime-emulator state plus renderer-pushed view
|
||||
attributes. Replayed, seeded, or snapshot bytes are answered by no one.
|
||||
The daemon emulator never answers. (Amended by Phase 5 — see
|
||||
[`terminal-query-authority.md`](./terminal-query-authority.md).)
|
||||
7. The transcript contract stays separate from screen restore. `orca terminal
|
||||
read` must preserve bounded previews, cursor pagination, partial-line rules,
|
||||
truncation flags, and total counts even if view snapshots change shape.
|
||||
8. Local, daemon, SSH, remote runtime, mobile, and CLI paths must either satisfy
|
||||
the same model/view contract or explicitly report that model recovery is
|
||||
unavailable.
|
||||
|
||||
## Current Owners
|
||||
|
||||
| Responsibility | Current owner |
|
||||
| --- | --- |
|
||||
| PTY byte source and local/SSH delivery | `src/main/ipc/pty.ts` |
|
||||
| Hidden-delivery gate (hidden marks, delivery interest, drop accounting, restore markers) | `src/main/ipc/pty-hidden-delivery-gate.ts`, drop sites in `src/main/ipc/pty.ts` and `src/main/ssh/ssh-relay-session.ts` |
|
||||
| Side-effect parsing and the `pty:sideEffect` facts channel | `src/shared/terminal-output-side-effects.ts` driven from `OrcaRuntimeService.onPtyData`; renderer policy in `src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts` |
|
||||
| Model query responder and view-attribute bridge | `src/main/runtime/terminal-model-query-authority.ts`, `src/main/daemon/terminal-view-attribute-responder.ts`, `src/main/runtime/terminal-view-attribute-store.ts` |
|
||||
| Hidden view parking policy and parked watcher | `src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts`, `parked-terminal-byte-watcher.ts` |
|
||||
| Daemon PTY state and headless snapshots | `src/main/daemon/headless-emulator.ts` |
|
||||
| Runtime headless state, retained reads, mobile/session tabs | `src/main/runtime/orca-runtime.ts` |
|
||||
| Remote terminal subscribe/multiplex/ACK semantics | `src/main/runtime/rpc/methods/terminal.ts` |
|
||||
| Renderer xterm view and hidden restore behavior | `src/renderer/src/components/terminal-pane/pty-connection.ts` |
|
||||
| Remote desktop runtime xterm transport | `src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts` |
|
||||
|
||||
## Snapshot Contract
|
||||
|
||||
A model snapshot must include:
|
||||
|
||||
- terminal dimensions used to produce the snapshot;
|
||||
- enough ANSI state to rehydrate xterm before snapshot content;
|
||||
- bounded screen and scrollback content;
|
||||
- title and cwd metadata when known;
|
||||
- source metadata that distinguishes headless/model snapshots from renderer
|
||||
fallback snapshots;
|
||||
- monotonic ordering metadata for live-output reconciliation when available.
|
||||
|
||||
A snapshot must not:
|
||||
|
||||
- include unbounded transcript history;
|
||||
- answer terminal queries while replaying into the model;
|
||||
- overwrite newer live view output with older model output;
|
||||
- hide that recovery was unavailable for a PTY surface.
|
||||
|
||||
## View Contract
|
||||
|
||||
A renderer or remote view may:
|
||||
|
||||
- write active visible output immediately;
|
||||
- budget visible inactive output;
|
||||
- stop receiving hidden output entirely while main's hidden-delivery gate owns
|
||||
the bytes (model restore on reveal);
|
||||
- request fresh snapshots for restore, mobile subscription, or explicit remote
|
||||
snapshot recovery.
|
||||
|
||||
A view must:
|
||||
|
||||
- keep live-output buffers bounded while a snapshot is in flight;
|
||||
- apply generation or sequence checks before replaying a snapshot;
|
||||
- refresh/repaint after replay when xterm/WebGL needs an explicit paint;
|
||||
- keep side effects such as title, bell, cwd, and agent status flowing from the
|
||||
PTY/model path (the `pty:sideEffect` facts channel) even while renderer byte
|
||||
delivery is budgeted, gated, or parked.
|
||||
|
||||
## Transcript Contract
|
||||
|
||||
The retained read transcript is not a screen dump. It must preserve:
|
||||
|
||||
- uncursored bounded latest preview behavior;
|
||||
- cursor reads over completed retained lines;
|
||||
- `oldestCursor`, `nextCursor`, `latestCursor`, and `returnedLineCount`;
|
||||
- partial-line duplication rules;
|
||||
- `truncated`, `limited`, and total count metadata;
|
||||
- bounded memory for long partial lines and large output bursts.
|
||||
|
||||
Snapshot optimizations must be tested against this transcript contract instead
|
||||
of assuming xterm scrollback serialization can replace it.
|
||||
|
||||
## Required Contract Tests
|
||||
|
||||
Before moving more runtime behavior behind the model/view boundary, add or
|
||||
extend tests that prove:
|
||||
|
||||
- headless snapshots rehydrate rich alternate-screen TUI state;
|
||||
- the daemon emulator never answers DA, DSR, OSC 11, or theme-sensitive
|
||||
queries (the `session.test.ts` pins are permanent);
|
||||
- the main runtime responder answers queries only from live chunks the
|
||||
hidden-delivery gate dropped — never delivered, replayed, seeded, or
|
||||
remote-subscribed chunks;
|
||||
- hidden renderer overflow restores from model state without duplicate live
|
||||
output;
|
||||
- sleep/wake and worktree revisit restore from model-correct state;
|
||||
- SSH-backed PTYs follow the same snapshot and ordering semantics as local PTYs;
|
||||
- remote runtime multiplex output remains ACK bounded and can request recovery
|
||||
snapshots;
|
||||
- mobile subscribers receive bounded snapshots without unbounded pending live
|
||||
output;
|
||||
- retained terminal reads remain pageable and bounded after large output.
|
||||
|
||||
Current coverage is spread across:
|
||||
|
||||
- `src/main/daemon/headless-emulator.test.ts`
|
||||
- `src/main/daemon/session.test.ts`
|
||||
- `src/main/ipc/pty.test.ts` (hidden-gate drops, restore markers,
|
||||
`pendingDeliveryStartSeq`)
|
||||
- `src/main/ipc/pty-hidden-delivery-gate.test.ts`
|
||||
- `src/main/runtime/mobile-subscribe-integration.test.ts`
|
||||
- `src/main/runtime/rpc/terminal-subscribe-buffer.test.ts`
|
||||
- `src/main/runtime/rpc/terminal-multiplex.test.ts`
|
||||
- `src/main/runtime/orca-runtime.test.ts`
|
||||
- `src/main/runtime/terminal-query-responder.test.ts`
|
||||
- `src/shared/terminal-output-side-effects.test.ts`
|
||||
- `src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts`
|
||||
- `src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts`
|
||||
- `src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts`
|
||||
- `src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts`
|
||||
- `src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts`
|
||||
- `tests/e2e/terminal-hidden-tui-visual-restore.spec.ts`
|
||||
- `tests/e2e/terminal-hidden-view-parking.spec.ts`
|
||||
- `tests/e2e/terminal-parked-memory.spec.ts`
|
||||
- `tests/e2e/terminal-sleep-wake-restore.spec.ts`
|
||||
- `tests/e2e/terminal-output-scheduler.spec.ts`
|
||||
- `tests/e2e/artificial-opencode-terminal-load.spec.ts`
|
||||
|
||||
## Architecture Status
|
||||
|
||||
All six phases of the terminal model/view architecture are shipped; the kill
|
||||
switches noted in parentheses default on:
|
||||
|
||||
1. **Hidden view parking** — "Park hidden terminal views behind a byte
|
||||
watcher": hidden terminal tabs unmount their xterm after a cold-park
|
||||
hysteresis; a pane-less watcher keeps bell/title/agent/PR side effects
|
||||
alive while parked (`terminalHiddenViewParking`). See
|
||||
[`terminal-hidden-view-parking.md`](./terminal-hidden-view-parking.md).
|
||||
2. **Parked memory benchmarks** — "Benchmark parked hidden terminal memory":
|
||||
renderer heap and live-terminal counts gate parking in the perf suite
|
||||
(`tests/e2e/terminal-parked-memory.spec.ts`).
|
||||
3. **Side-effect authority in main** — "Track terminal titles in main with
|
||||
all-titles ordering", "Move terminal side-effect authority to a main facts
|
||||
channel", "Complete terminal side-effect facts coverage", "Finish terminal
|
||||
side-effect authority migration": every local/daemon/SSH PTY byte is
|
||||
side-effect-parsed once in main and delivered as `pty:sideEffect` facts
|
||||
(`terminalMainSideEffectAuthority`). See
|
||||
[`terminal-side-effect-authority.md`](./terminal-side-effect-authority.md).
|
||||
4. **Hidden delivery gate** — "Gate PTY delivery to hidden terminal views":
|
||||
main drops renderer-bound bytes for hidden-marked PTYs after model
|
||||
ingestion; delivery-interest registrations exempt sidecar byte consumers,
|
||||
out-of-band restore markers latch model restore, and
|
||||
`pendingDeliveryStartSeq` reconciles live output racing a restore
|
||||
(`terminalHiddenDeliveryGate`).
|
||||
5. **Model query authority** — "Answer hidden terminal queries from the
|
||||
model", "Bridge renderer view attributes to the model responder", "Align
|
||||
query authority contract and spawn-time ownership": hidden-dropped queries
|
||||
are answered by the runtime emulator plus renderer-pushed view attributes,
|
||||
and hidden-at-spawn PTYs are marked before byte one
|
||||
(`terminalModelQueryAuthority`). See
|
||||
[`terminal-query-authority.md`](./terminal-query-authority.md).
|
||||
6. **Skip grammar deletion** — "Delete the hidden renderer skip grammar": the
|
||||
renderer's per-chunk hidden-skip eligibility grammar and the 10s codex
|
||||
startup query window are deleted; the kill-switch-off fallback is the
|
||||
bounded background queue with overflow-latched model restore.
|
||||
|
||||
Treat every hidden/slept/revisited TUI glitch as a contract failure, not as a
|
||||
local repaint quirk. Renderer fallback paths retire only when their kill
|
||||
switches do, and only after the equivalent model path has platform and TUI
|
||||
golden coverage.
|
||||
@@ -0,0 +1,326 @@
|
||||
# Terminal Query Authority
|
||||
|
||||
Status: Shipped — Phase 5 of the terminal model/view architecture, kill
|
||||
switch `terminalModelQueryAuthority` (default on). Builds on
|
||||
[`terminal-model-view-contract.md`](./terminal-model-view-contract.md) (this
|
||||
phase **amends invariant 6**),
|
||||
[`terminal-side-effect-authority.md`](./terminal-side-effect-authority.md)
|
||||
(Phase 3), and the Phase-4 hidden-delivery gate
|
||||
(`src/main/ipc/pty-hidden-delivery-gate.ts`).
|
||||
|
||||
## Problem
|
||||
|
||||
Phase 4 drops renderer-bound bytes for hidden-gated PTYs after model ingestion
|
||||
(`src/main/ipc/pty.ts:1426,1515`, `src/main/ssh/ssh-relay-session.ts:931`).
|
||||
Queries embedded in dropped bytes get no reply: DA1 (ConPTY 1.22+ blocks
|
||||
waiting for it — `terminal-conpty-device-attributes.ts:22`), CPR probes hang
|
||||
TUIs, OSC 10/11 leaves `claude /theme` blind while hidden. The pre-Phase-4
|
||||
hidden skip latch had the same hole (only mode 2031 and the 10s codex startup
|
||||
window answered), so this is not a regression — it is the long-standing gap
|
||||
this phase closes.
|
||||
|
||||
Contract invariant 6 ("the model must never answer queries") was written
|
||||
against a real bug: the daemon emulator replying ahead of the renderer with
|
||||
default-xterm values (the OSC-11 default-black-background race,
|
||||
`headless-emulator.ts:86-97`, pinned by `session.test.ts:163-190`). The danger
|
||||
was never "the model answers" — it was **two answerers for the same bytes**,
|
||||
one of them with wrong values. Phase 5 keeps the singularity and fixes the
|
||||
values.
|
||||
|
||||
## Decision: the delivery decision is the reply decision
|
||||
|
||||
Main answers a query **iff main dropped the chunk that carried it**. The same
|
||||
per-chunk hidden-gate predicate (`shouldDropHiddenRendererPtyData`) that
|
||||
decides renderer delivery decides reply ownership, evaluated once,
|
||||
synchronously, at ingestion:
|
||||
|
||||
- Visible/unmarked PTY → chunk delivered → renderer xterm auto-replies via
|
||||
`Terminal.onData` → `transport.sendInput`, unchanged.
|
||||
- Hidden-marked, no delivery interest → chunk dropped → main answers from the
|
||||
runtime headless emulator, via the provider input path (`provider.write`,
|
||||
same path as `pty:write`; daemon shell-ready write gating and the SSH relay
|
||||
write apply unchanged).
|
||||
- Replayed/seeded/snapshot bytes → answered by no one (replay guards on both
|
||||
sides).
|
||||
|
||||
This is structurally exactly-one-responder: a chunk is delivered or dropped,
|
||||
never both, and each side only answers bytes it actually parsed live. The
|
||||
mark/unmark ordering, unhide-before-restore, and restore-marker IPC all exist
|
||||
from Phase 4 and are reused, not duplicated.
|
||||
|
||||
Rejected alternatives:
|
||||
|
||||
- **Fact-based renderer replies per query class** (the mode-2031 pattern
|
||||
generalized): needs a main-side detection grammar per query, a fact round
|
||||
trip per reply, and the renderer cannot answer CPR/DECRPM anyway — the
|
||||
emulator is the only state for a hidden pane. The 2031 fact stays because it
|
||||
is subscription registration, not a state query.
|
||||
- **Emulator always answers**: re-creates the OSC-11 double-reply race for
|
||||
visible panes. Never.
|
||||
|
||||
## Mechanism: forwarded emulator onData, not a new grammar
|
||||
|
||||
`HeadlessEmulator` has `onData` wiring behind a per-write capture flag.
|
||||
For static and model-state queries, xterm core **is** the query grammar: the
|
||||
runtime emulator runs the same xterm version with equivalent options as the
|
||||
renderer pane, so main's reply set equals the visible renderer's by
|
||||
construction — verified empirically against the bundled headless build:
|
||||
DA1/DA2, DSR 5n, CPR, DECRPM (including unknown-mode `0`), DECRQSS (including
|
||||
DECSCUSR from cursor options), XTVERSION, kitty `CSI ? u` all reply; XTWINOPS
|
||||
(`windowOptions` stays default-off) and XTGETTCAP stay silent, matching
|
||||
visible behavior today. The headless build has **no theme service**: OSC
|
||||
4/10/11/12 queries and DSR ?996n return nothing even with the `theme` option
|
||||
set, so the view-attribute class is answered by responder-registered parser
|
||||
handlers instead (below) — never by core defaults.
|
||||
|
||||
Forwarding predicate, captured per chunk in `OrcaRuntimeService.onPtyData` and
|
||||
attached to the emulator `writeChain` link (the mark can flip between
|
||||
ingestion and an async write; the decision must not be re-read at reply time):
|
||||
|
||||
1. gate enabled (`terminalMainSideEffectAuthority` and
|
||||
`terminalHiddenDeliveryGate` both on) AND new kill switch
|
||||
`terminalModelQueryAuthority !== false`;
|
||||
2. the chunk was hidden-dropped for this PTY (`shouldDropHiddenRendererPtyData`
|
||||
— same module state, same tick as the drop sites);
|
||||
3. the write is live PTY data — never `seedHeadlessTerminal`,
|
||||
`maybeHydrateHeadlessFromRenderer`, option pushes, or any snapshot replay
|
||||
(main-side replay guard, mirror of the renderer's `replay-guard.ts`);
|
||||
4. no remote view subscriber is attached to the PTY (runtime terminal-RPC
|
||||
subscriber records / `mobileSubscribers`): a mobile/web/remote-desktop
|
||||
xterm receiving the multiplexed stream answers with view authority, exactly
|
||||
like a visible local pane. Legacy JSON `terminal.subscribe` streams **do**
|
||||
register as view subscribers and suppress, even when the consumer is a
|
||||
read-only watcher — deliberately conservative, because the stream may feed
|
||||
an older live xterm view and a withheld reply (the pre-Phase-5 status quo)
|
||||
is strictly safer than a double reply. Consumers that never register a
|
||||
stream (CLI `terminal.read`, automation observers) do not suppress — they
|
||||
also do not answer; that bounded no-reply case matches today's behavior.
|
||||
|
||||
Everything the emulator emits outside a forwarding window is discarded, which
|
||||
also swallows unsolicited core emissions (e.g. native 997 color-scheme pushes
|
||||
triggered by option mutations).
|
||||
|
||||
## Reply classes
|
||||
|
||||
| Class | Queries | Answer source |
|
||||
| --- | --- | --- |
|
||||
| Static | DA1 `CSI c` (ConPTY override below), DA2, DSR 5n, XTVERSION, DECRQM unknown → `0`, kitty `CSI ? u` | xterm core constants + kitty flag state |
|
||||
| Model-state | CPR `6n`/`?6n`, DECRPM mode table (?1 ?6 ?7 ?25 mouse ?1004 ?1006 ?1016 ?1049 ?2004 ?2026, insert), DECRQSS DECSTBM/DECSCA/SGR, kitty flags | emulator buffer/mode state — for a hidden pane it is the only state, hence authoritative |
|
||||
| View-attribute | OSC 4/10/11/12 `;?` queries, DSR ?996n | responder parser handlers + renderer attribute push (below); **silent until first push** |
|
||||
| View-attribute (via options) | DECRQSS DECSCUSR, DECRQM 12 | xterm core, from pushed `cursorStyle`/`cursorBlink` emulator options |
|
||||
| Silent | XTWINOPS, XTGETTCAP, ?15n/?25n/?26n/?53n | nobody, visible or hidden |
|
||||
| Mode 2031 | DECSET 2031 subscribe | unchanged in Phase 5: main emits the `2031-subscribe` fact, the renderer replies (`handleHiddenMode2031SubscribeFact`, `pty-connection.ts`; parked watcher fact callback). Emulator-native 2031/997 output is suppressed by the forwarding guard |
|
||||
|
||||
### View-attribute bridge
|
||||
|
||||
Renderer→main push, `pty:terminalViewAttributes` — one global snapshot,
|
||||
not per-PTY: the composed terminal `ITheme` (from
|
||||
`applyTerminalAppearance`, `terminal-appearance.ts`),
|
||||
`terminalCursorStyle`, `terminalCursorBlink`, and the resolved color-scheme
|
||||
mode (`resolveTerminalColorSchemeMode` — the same source as the existing
|
||||
hidden 2031 reply). Pushed on renderer startup and on every theme/settings
|
||||
apply.
|
||||
|
||||
Main consumes it two ways:
|
||||
|
||||
- `cursorStyle`/`cursorBlink` are applied to every runtime emulator's options
|
||||
inside the replay guard; xterm core then answers DECRQSS DECSCUSR and
|
||||
DECRQM 12 with renderer-true values (verified working headless).
|
||||
- Palette and color-scheme replies come from responder-registered parser
|
||||
handlers on the emulator (`registerOscHandler` 4/10/11/12,
|
||||
`registerCsiHandler` for DSR ?996n), because the headless core cannot
|
||||
answer them. The OSC handlers see SET payloads too, so runtime OSC
|
||||
4/10/11/12 mutations (and 104/110/111/112 resets) from the byte stream are
|
||||
tracked per PTY and layered over the pushed base palette — matching what
|
||||
the renderer's theme service reports for a visible pane.
|
||||
|
||||
Staleness rules: replies use the last push; a theme flip is stale for at most
|
||||
one IPC hop (subscribed TUIs are corrected by the 2031/997 flip push).
|
||||
**Before the first push main answers no view-attribute query** — a fabricated
|
||||
default would resurrect the default-black OSC-11 bug; silence is the
|
||||
documented hidden status quo.
|
||||
|
||||
### Kitty keyboard flags
|
||||
|
||||
`vtExtensions.kittyKeyboard: true` is enabled in `HeadlessEmulator`, matching
|
||||
`buildDefaultTerminalOptions` (`pane-terminal-options.ts:50`). Risk is low:
|
||||
for the write-only daemon use, keyboard state never alters serialization; the
|
||||
change only makes the emulator parse `CSI =/>/< u` pushes instead of ignoring
|
||||
them, and lets the responder answer `CSI ? u` with the flags the hidden app
|
||||
actually pushed. Snapshot parity: add `kittyKeyboardFlags` to `TerminalModes`
|
||||
for emulator re-seed parity only. `rehydrateSequences` must **not** push kitty
|
||||
flags into a renderer xterm — `POST_REPLAY_REATTACH_RESET`'s deliberate kitty
|
||||
reset (stale CSI-u Ctrl+C hazard, `terminal-replay-cursor-state.test.ts`)
|
||||
stays authoritative. Slice 3 wires the re-seed consumer: the daemon
|
||||
warm-reattach snapshot threads `modes.kittyKeyboardFlags` through the spawn
|
||||
result into `seedHeadlessTerminal`, which applies them to the fresh runtime
|
||||
emulator via its own `CSI = flags ; 1 u` parse (outside any forwarding
|
||||
window), so hidden `CSI ? u` reports the flags the hidden app actually
|
||||
pushed. Paths without a snapshot (cold restore spawns a fresh shell) answer
|
||||
`?0u`; protocol-conformant programs re-push.
|
||||
|
||||
### ConPTY DA1 variant
|
||||
|
||||
The provider kind is known main-side: mirror `isLocalNativeWindowsPty`
|
||||
(`windows-pty-compatibility.ts:59`) from the spawn record (local/daemon
|
||||
provider, `win32`, not WSL). For such PTYs register a CSI `c` override on the
|
||||
emulator parser (the main-side twin of
|
||||
`installConptyDeviceAttributesHandler`) replying `CSI ?61;4c`, still gated by
|
||||
the forwarding predicate. The override is installed at emulator creation and
|
||||
retrofitted when the spawn mark lands (daemon stream data can create the
|
||||
emulator before the awaited spawn response marks the PTY). ConPTY blocking on
|
||||
a missing DA1 is a spawn-time hazard; the hidden-at-spawn loss window is
|
||||
closed by the slice-3 `initiallyHidden` spawn flag (races section).
|
||||
|
||||
## Suppression: when main never replies
|
||||
|
||||
- Visible or unmarked PTY (chunk was delivered).
|
||||
- Renderer delivery interest registered (chunk was delivered to a sidecar).
|
||||
- Remote-runtime (`remote:`) PTYs — never markable
|
||||
(`isHiddenDeliveryGateManagedPty`), bytes never transit local main.
|
||||
- Remote view subscriber attached (mobile/web/remote desktop owns replies).
|
||||
- Seed/hydration/snapshot writes into the emulator, and option pushes.
|
||||
- Kill switches off — no marks exist, and `terminalModelQueryAuthority` is an
|
||||
independent off switch for the responder alone.
|
||||
- The **daemon** emulator: never, under any setting. The responder lives in
|
||||
main's runtime only; `session.test.ts:163-190` stays pinned verbatim.
|
||||
|
||||
## Transition races
|
||||
|
||||
Worst cases, per direction:
|
||||
|
||||
- **visible→hidden**: chunks delivered between the visibility flip and the
|
||||
mark landing in main are hidden-skipped by the renderer write path without
|
||||
query scanning. No reply, no duplicate — identical to the pre-Phase-4 hidden
|
||||
skip behavior, bounded by one renderer→main IPC hop. After the mark lands,
|
||||
main answers everything it drops.
|
||||
- **hidden→visible**: unmark consumes the drop latch and emits the restore
|
||||
marker; the snapshot replay is replay-guarded, so queries main already
|
||||
answered are never re-answered from the snapshot; post-unmark live chunks
|
||||
are answered by xterm once (restore-queued live chunks reply late, not
|
||||
twice).
|
||||
- **Split queries across the drop/deliver boundary**: neither parser saw the
|
||||
whole sequence → no reply; the restore marker resets renderer cross-chunk
|
||||
state and replay hygiene resets the parser. At-most-once holds.
|
||||
|
||||
Safe-side rule per class: duplicates are structurally impossible (one decision
|
||||
point per chunk); where the race costs anything it costs a missing reply.
|
||||
That is acceptable for state queries (DSR/CPR/DECRPM — TUIs re-probe or
|
||||
tolerate silence, as they did for every hidden pane before this phase). The
|
||||
one blocking-on-no-reply sequence, ConPTY DA1, only fires at spawn. A visible
|
||||
pane answers it from the renderer xterm. A PTY spawned hidden previously had
|
||||
no answerer until the renderer's hidden mark landed in main (one IPC hop
|
||||
after spawn). Slice 3 closes that window with the `initiallyHidden`
|
||||
spawn-record flag: the renderer declares hidden-at-spawn on `pty:spawn`
|
||||
(never for remote-runtime transports), and main marks the PTY hidden before
|
||||
the first byte — pre-spawn for
|
||||
daemon-host sessions whose id is minted up front, immediately after
|
||||
`provider.spawn` resolves otherwise — so the gate and responder own queries
|
||||
from byte one. The pane's first visibility sync then re-marks or unmarks
|
||||
through the existing Phase-4 machinery (unmark emits the restore marker for
|
||||
any spawn-window drops).
|
||||
|
||||
## Invariants
|
||||
|
||||
1. Exactly one party may answer any query, chosen by the chunk's delivery
|
||||
decision: delivered → the consuming live view's xterm; dropped → main's
|
||||
model responder; replayed/seeded → no one. The decision is captured once,
|
||||
synchronously, at ingestion.
|
||||
2. Main answers only from live PTY bytes parsed by the runtime emulator —
|
||||
never from snapshot, seed, hydration, or option-push writes.
|
||||
3. View-attribute answers are renderer-true or absent: no reply is ever
|
||||
fabricated from emulator defaults (the OSC-11 lesson).
|
||||
4. The daemon emulator stays write-only; daemon subprocess query writes stay
|
||||
zero (`session.test.ts` pins are permanent).
|
||||
5. Reply parity is structural for static and model-state classes: same xterm
|
||||
core, equivalent options, no hand-rolled grammar — the only overrides are
|
||||
the documented ConPTY DA1 variant and the view-attribute parser handlers
|
||||
the headless core cannot serve.
|
||||
6. Remote views keep view authority; main yields whenever a remote view
|
||||
subscriber is attached.
|
||||
|
||||
**Contract amendment** — `terminal-model-view-contract.md` invariant 6 is
|
||||
replaced by:
|
||||
|
||||
> 6. Terminal query authority is singular and structural: the party that
|
||||
> writes a chunk into a live terminal answers its queries. Visible renderer
|
||||
> and remote views keep xterm authority. Chunks dropped by the
|
||||
> hidden-delivery gate are answered exactly once by the main model
|
||||
> responder, from runtime-emulator state plus renderer-pushed view
|
||||
> attributes. Replayed, seeded, or snapshot bytes are answered by no one.
|
||||
> The daemon emulator never answers.
|
||||
|
||||
The contract's test bullet "headless tracking does not answer DA, DSR, OSC 11,
|
||||
or theme-sensitive queries" splits into: daemon emulator never answers
|
||||
(unchanged pins) / runtime responder answers only hidden-dropped chunks. The
|
||||
side-effect authority matrix row "DECSET 2031 reply — query authority stays
|
||||
with the view (contract invariant 6)" gains a pointer here; its reply path is
|
||||
otherwise untouched in this phase.
|
||||
|
||||
## Test strategy
|
||||
|
||||
- Responder unit tests beside `orca-runtime.test.ts`: marked vs unmarked vs
|
||||
interest-suppressed; each reply class; seed/hydrate silence; remote-
|
||||
subscriber suppression; ConPTY DA1 variant; kill-switch off; mark flip
|
||||
between ingestion and async emulator write (captured decision wins).
|
||||
- Parity harness: shared query byte fixtures through a renderer-configured
|
||||
xterm (onData capture) and through the responder; assert byte-identical
|
||||
replies for static + model-state classes, and for view-attribute classes
|
||||
after an attribute push.
|
||||
- `session.test.ts:163-190`: assertions stay; the comment is updated to name
|
||||
the main responder (not "the renderer") as the hidden answerer.
|
||||
- E2E: hidden `claude /theme` reports the configured theme; hidden TUI
|
||||
blocked on CPR/DA unblocks while gated; reveal shows no stray reply
|
||||
fragments (`?1;2c`, `rgb:` …) on the prompt; Windows ConPTY golden and
|
||||
`terminal-hidden-view-parking.spec.ts` stay green.
|
||||
|
||||
## Cut-offs (shipped as three stacked slices)
|
||||
|
||||
1. **Responder core.** Emulator onData wiring + per-write capture + main
|
||||
replay guard; kitty flag enable (+ `TerminalModes.kittyKeyboardFlags`);
|
||||
static + model-state classes; ConPTY DA1 override; remote-subscriber
|
||||
suppression; `terminalModelQueryAuthority` switch; unit + parity tests.
|
||||
Main-only — no renderer change. Ships the DA1/CPR/DECRPM unblock.
|
||||
2. **View-attribute bridge.** `pty:terminalViewAttributes` push, cursor
|
||||
option application under the guard, responder OSC/DSR parser handlers with
|
||||
per-PTY palette-mutation tracking, silent-until-push rule, `/theme` e2e.
|
||||
3. **Contract alignment.** Invariant-6 amendment in the contract doc, test
|
||||
bullet split, `session.test.ts` comment, side-effect matrix pointer, and
|
||||
the Phase 6 prerequisites below recorded as accepted.
|
||||
|
||||
## Phase 6 (delete skip grammar + startup window): prerequisites from this design
|
||||
|
||||
Phase 6 is shipped: the renderer hidden-skip eligibility grammar and the 10s
|
||||
codex startup renderer-query window are deleted. Kill-switch-off hidden panes
|
||||
fall back to the pre-grammar path — hidden bytes ride the bounded background
|
||||
scheduler queue; overflow latches the model-snapshot restore — and never run
|
||||
a per-chunk content scan.
|
||||
|
||||
Accepted and shipped in slice 3 (except where noted):
|
||||
|
||||
- **Mark-before-first-byte** (shipped): panes spawned without a visible view
|
||||
are hidden-marked at spawn via the `initiallyHidden` flag on `pty:spawn`
|
||||
(spawn-record flag, not a renderer round trip) so startup queries —
|
||||
including ConPTY's blocking DA1 — are main-owned from byte zero. Phase 6
|
||||
removed the codex exclusion with the window: codex spawns are main-owned
|
||||
from byte zero too, the responder answering their startup probes.
|
||||
- **Attributes before spawn** (shipped): the renderer pushes composed view
|
||||
attributes once at app start (right after settings load, before terminal
|
||||
reconnect/spawn), so spawn-time view-attribute queries no longer fall into
|
||||
the silent-until-push rule. Per-pane appearance applies keep re-publishing
|
||||
through the same deduped publisher.
|
||||
- **Daemon shell-ready write gating** (verified): responder replies through
|
||||
`ptyController.write` → daemon `Session.write` are QUEUED pre-ready, never
|
||||
dropped, and the queue flushes at the shell-ready marker or the 15s
|
||||
`SHELL_READY_TIMEOUT_MS` bound (`session.ts`). The codex window was removed
|
||||
with hosted ConPTY golden coverage, unit DA1 parity, and the kill switches
|
||||
as the safety net; explicit spawn-time e2e on Windows daemon PTYs remains
|
||||
worth adding.
|
||||
- With the skip grammar deleted, every chunk is either written to a live
|
||||
xterm or dropped — the delivered-but-skipped no-reply gap disappears and
|
||||
the only remaining loss window is the mark IPC race.
|
||||
- **2031 consolidation** (optional follow-up): move the subscription registry
|
||||
into the responder (the headless core cannot serve 997 pushes any more than
|
||||
it can ?996n) and push 997 flips from the attribute cache, retiring the
|
||||
`2031-subscribe` fact reply, the parked responder, and the parked-tab
|
||||
theme-flip gap.
|
||||
@@ -0,0 +1,252 @@
|
||||
# Terminal Side-Effect Authority
|
||||
|
||||
Status: Shipped — Phase 3 of the terminal model/view architecture, kill switch
|
||||
`terminalMainSideEffectAuthority` (default on). Builds on
|
||||
[`terminal-model-view-contract.md`](./terminal-model-view-contract.md) and
|
||||
[`terminal-hidden-view-parking.md`](./terminal-hidden-view-parking.md) (Phase 1).
|
||||
|
||||
## Problem
|
||||
|
||||
Main parses every local/daemon/SSH PTY byte before renderer delivery
|
||||
(`OrcaRuntimeService.onPtyData` in `src/main/runtime/orca-runtime.ts`:
|
||||
side-effect tracker, OSC 9999 agent status, headless emulator, tails, URL
|
||||
watchers; SSH feeds the same path from `wireUpPtyEvents` in
|
||||
`src/main/ssh/ssh-relay-session.ts`). Before this phase, the side effects
|
||||
users see — bell unread/notifications, title transitions, agent-complete
|
||||
notifications, command lifecycle, PR links — were derived a second time by
|
||||
renderer byte parsers. That duplication forced Phase 1's watcher to parse
|
||||
bytes, forced main to fabricate synthetic OSC title frames over `pty:data`
|
||||
just so renderer parsers could see them, and blocked Phase 4 from ever
|
||||
stopping hidden byte delivery. Phase 3 made main the side-effect parser for
|
||||
every PTY whose bytes transit local main; the renderer byte parsers
|
||||
(`createPtyOutputProcessor` in `pty-transport.ts`, the parked watcher's byte
|
||||
mode) survive only for remote-runtime PTYs and the kill-switch-off fallback.
|
||||
|
||||
## Authority Matrix
|
||||
|
||||
"Main" means parsed once in `onPtyData` and delivered as derived facts.
|
||||
Remote-runtime PTYs (`remote:`) never transit local main; the renderer
|
||||
(`remote-runtime-pty-transport.ts:74`) stays their parser permanently.
|
||||
|
||||
| Side effect | local-daemon | SSH | remote-runtime |
|
||||
| --- | --- | --- | --- |
|
||||
| OSC 9999 agent status | main (parsed in `onPtyData`, emitted as `agentStatus:set`) | main | renderer (`shouldOwnAgentStatusInRenderer`, `pty-connection.ts`) |
|
||||
| OSC 0/1/2 titles + working/idle/exited tracker + 3s stale-title timer | main | main | renderer |
|
||||
| BEL attention (OSC-aware stateful detector) | main | main | renderer |
|
||||
| OSC 133;D command-finished exit code | main | main | renderer |
|
||||
| GitHub PR-link scan | main | main | renderer |
|
||||
| Command Code output scrape | main (per-PTY detector beside the tracker → `command-code-working`/`command-code-done` facts; the renderer pane keeps the done settle timer — it must consult the live status row) | main | renderer |
|
||||
| DECSET 2031 color-scheme reply | renderer view/watcher — the 2031 fact reply path is untouched by Phase 5; general query authority is now per-chunk structural ownership, see [`terminal-query-authority.md`](./terminal-query-authority.md) (contract invariant 6 as amended) | same | renderer |
|
||||
| DECSET 2004 paste readiness (`agent-paste-draft.ts`) | renderer — input pacing, not a model side effect | renderer | renderer |
|
||||
|
||||
## Main-Side Tracker
|
||||
|
||||
- The side-effect core shared with the renderer processor lives in
|
||||
`src/shared/terminal-output-side-effects.ts`: all-titles ordering via
|
||||
`extractAllOscTitles` (coalesced working→idle transitions are why last-title
|
||||
is insufficient — issue #1083), `normalizeTerminalTitle`, the literal
|
||||
`cursor agent` title drop (`CURSOR_NATIVE_TITLE_LOWER`,
|
||||
`src/shared/agent-detection.ts`), the `createAgentStatusTracker`
|
||||
transitions, the stale-working-title 3s timer
|
||||
(`STALE_WORKING_TITLE_TIMEOUT_MS`), and the stateful BEL detector
|
||||
(`src/shared/terminal-bell-detector.ts`).
|
||||
- One tracker per PTY on `OrcaRuntimeService`, lazily created like
|
||||
`agentStatusOscProcessorsByPtyId`; disposed in `onPtyExit` (cancels the
|
||||
stale-title timer).
|
||||
- It replaced the chunk-level last-title extraction in `onPtyData`: titles
|
||||
feed in byte order, so `lastOscTitle`/`lastAgentStatus`, tui-idle waiters,
|
||||
and pending-message delivery see intermediate transitions instead of only
|
||||
the chunk's last title. PTY/leaf records keep the **raw** last title
|
||||
(worktree `ps` and mobile tab titles expect raw); emitted facts carry
|
||||
`(normalizedTitle, rawTitle)` like `onTitleChange`.
|
||||
- No deferred drain in main — the renderer's setTimeout(0) batching
|
||||
(`sideEffectDrainTimer`, `pty-transport.ts`) protects xterm paint, which
|
||||
does not exist in main. Main applies synchronously and batches the IPC per
|
||||
flush.
|
||||
- The stats `AgentDetector` (`src/main/stats/agent-detector.ts`) keeps its own
|
||||
last-title scan, untouched: synthetic titles must never reach it.
|
||||
|
||||
## Event Transport: `pty:sideEffect`
|
||||
|
||||
One batched main→renderer channel (`window.api.pty.onSideEffect`,
|
||||
`src/preload/index.ts`). It is **not** routed through the pty dispatcher:
|
||||
the renderer fact-consumer registry
|
||||
(`terminal-side-effect-facts-handler.ts`) subscribes directly via
|
||||
`window.api.pty.onSideEffect` — one channel subscription per renderer, with
|
||||
exactly one registered fact consumer per PTY. Events are **facts, not
|
||||
decisions**: `title`, `bell`, `agent-working`, `agent-idle` (with title),
|
||||
`agent-exited`, `command-finished` (exit code), `pr-link`. Each carries
|
||||
`ptyId`, main-known attribution (worktreeId/tabId/paneKey from runtime leaf
|
||||
records, same resolution as `emitTerminalAgentStatusEvents`), and the PTY
|
||||
`outputSequence`.
|
||||
|
||||
Ordering rules:
|
||||
|
||||
1. Per-PTY in-order; facts from one chunk are emitted in byte order (status
|
||||
payloads, then titles in sequence, then bell — the renderer drain's order).
|
||||
2. Deliberately **not** synchronized with `pty:data`: side effects must keep
|
||||
advancing while renderer delivery is ACK-gated (contract invariant 1). A
|
||||
completion title may reach the store before the visible xterm paints the
|
||||
final output; that is acceptable — attention/title state is out-of-band UI
|
||||
state, and today's renderer drain already decouples by many batches under
|
||||
timer throttling.
|
||||
3. No attention replay: facts emitted while no renderer is subscribed are
|
||||
dropped. On transport attach/park-handoff the renderer pulls a title-only
|
||||
snapshot (`pty:sideEffectSnapshot`) marked `replay: true` — this reproduces
|
||||
the eager-buffer behavior where replay restores titles but is barred from
|
||||
bells/completions (`suppressAttentionEvents`, `pty-transport.ts`). The
|
||||
store handler ignores a replay title older (by `outputSequence`) than the
|
||||
last live title fact it applied.
|
||||
|
||||
## Renderer Store Handler (policy stays in the renderer)
|
||||
|
||||
Notification semantics, all preserved across the authority flip:
|
||||
|
||||
- BEL marks worktree+tab unread unconditionally — including the focused pane
|
||||
(`onBell`, `pty-connection.ts`); pane unread only behind
|
||||
`experimentalTerminalAttention`; keydown clears unread
|
||||
(`onTerminalKeyDown`, `pty-connection.ts`).
|
||||
- BEL's OS notification is delayed 250 ms and yields to a pending
|
||||
agent-task-complete (`scheduleTerminalBellNotification`,
|
||||
`pty-connection.ts`).
|
||||
- working→idle starts the Claude cache timer (null settings = not hydrated,
|
||||
treat enabled) and schedules completion with 250 ms grace + 1500 ms max
|
||||
wait + detail-wait store subscription
|
||||
(`AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS` /
|
||||
`AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS`,
|
||||
`agent-task-complete-policy.ts`).
|
||||
- Completion unread is suppressed only for the exact visible foreground pane
|
||||
(`isVisibleForegroundPaneKey`, `use-notification-dispatch.ts`); BEL unread
|
||||
has no such check.
|
||||
- Dispatch-time liveness/staleness guards
|
||||
(`dispatchTerminalNotification`, `use-notification-dispatch.ts`) and main's
|
||||
5 s per-worktree cooldown (`NOTIFICATION_COOLDOWN_MS`,
|
||||
`src/main/ipc/notifications.ts`) remain the final gates.
|
||||
|
||||
These need live renderer store state (PTY/layout maps, pane visibility,
|
||||
settings, `agentStatusByPaneKey`, repo labels), so they stay in the renderer:
|
||||
the pane-independent per-paneKey handler module
|
||||
(`terminal-side-effect-facts-handler.ts`) consumes `pty:sideEffect` and
|
||||
subsumes both `pty-connection.ts`'s callbacks and the parked watcher's
|
||||
callback block (`sideEffectCallbacks`, `parked-terminal-byte-watcher.ts`) —
|
||||
one policy path whether the tab is mounted, hidden, or parked. Main holds
|
||||
**no** notification timers; only the stale-title timer (parser state) lives
|
||||
in main.
|
||||
|
||||
## Synthetic Frame Reroute
|
||||
|
||||
`driveSyntheticTitleFromHook` and the spinner tick (`sendSyntheticTitle`,
|
||||
`src/main/index.ts`) feed `runtime.ingestSyntheticTitleFrame(ptyId, data)`,
|
||||
so synthetic agent-title/BEL frames enter the per-PTY tracker directly —
|
||||
**not** `onPtyData`, so emulator state, tails, transcripts, and stats never
|
||||
see them. The decorative-frame visibility gating
|
||||
(`shouldSendSyntheticTitleFrame`) stands. The legacy synthetic `pty:data`
|
||||
copy survives only in kill-switch-off mode, where renderer parsers still
|
||||
need the bytes. The visible xterm renders nothing from titles, but
|
||||
`pane.terminal.onTitleChange` feeds `registerPtyTitleSource`
|
||||
(`pty-connection.ts`) → renderer serialize-snapshot `lastTitle` (mobile
|
||||
parity); main prefers its own tracked title over renderer snapshot
|
||||
`lastTitle` in both serialize paths. Under main authority synthetic frames
|
||||
no longer produce phantom ACKs for bytes main never metered (`ackPtyData`,
|
||||
`pty-dispatcher.ts`).
|
||||
|
||||
## Migration Switch and Double-Fire Prevention
|
||||
|
||||
Authority is structural per PTY kind — the predicate is "bytes transit local
|
||||
main", exactly the `shouldOwnAgentStatusInRenderer` split
|
||||
(`pty-connection.ts`). One renderer-consulted kill switch
|
||||
(`settings.terminalMainSideEffectAuthority`, default on, mirroring
|
||||
`terminalHiddenViewParking`): when on, IPC transports and the parked watcher
|
||||
do not register byte parsers for local/SSH and the store handler consumes
|
||||
`pty:sideEffect`; when off, renderer parsers register and `pty:sideEffect`
|
||||
events are ignored. Main always parses and emits (its internal consumers need
|
||||
the tracker regardless); main consults the same setting only to keep the
|
||||
legacy synthetic-frame `pty:data` path alive while the switch is off. Exactly
|
||||
one consumer per fact at any time — decided at transport/watcher creation, so
|
||||
no per-chunk race.
|
||||
|
||||
## Sidecar Consumers and Phase 4
|
||||
|
||||
Keep renderer byte access (input pacing / raw-output consumers, not side
|
||||
effects): `agent-paste-draft.ts` (DECSET 2004 readiness),
|
||||
`launch-agent-background-session.ts` (startup-injection pacing, onData
|
||||
passthrough), `automation-session-observer.ts` (onData passthrough), and
|
||||
`parked-terminal-mode2031-responder.ts` (DECSET 2031 theme replies for
|
||||
parked tabs while the delivery gate is off). Their duplicated local OSC 9999
|
||||
store writes are gated off under main authority (the `onAgentStatus`
|
||||
automation callbacks still fire; only the racing `setAgentStatus` store
|
||||
writes drop). The Phase-4 hidden-delivery gate exempts PTYs with an active
|
||||
`subscribeToPtyData` sidecar: registration is auto-surfaced to main as a
|
||||
ref-counted delivery-interest signal (`pty-delivery-interest.ts`). With main
|
||||
authoritative, the parked watcher is purely fact-driven: byte parsing exists
|
||||
only in kill-switch-off mode, and the 2031 reply comes from the
|
||||
`2031-subscribe` fact when the gate is on (the byte responder sidecar only
|
||||
when it is off). The watcher file is deleted outright only when the kill
|
||||
switch retires — it returns as a byte parser only if remote-runtime tabs
|
||||
ever become parkable.
|
||||
|
||||
## Invariants
|
||||
|
||||
1. Every byte is side-effect-parsed exactly once, by exactly one authority,
|
||||
chosen structurally per PTY kind.
|
||||
2. Attention facts never replay: snapshot/eager/attach replays restore title
|
||||
state only.
|
||||
3. Notification policy (grace timers, yielding, suppression, dispatch guards)
|
||||
lives with the renderer store; main emits facts with ordering metadata.
|
||||
4. Side-effect facts keep flowing while renderer byte delivery is
|
||||
backpressured, parked, or stopped by the hidden-delivery gate.
|
||||
5. Synthetic agent frames feed the model tracker, never the emulator, tails,
|
||||
transcripts, or stats.
|
||||
|
||||
## Test Strategy
|
||||
|
||||
- Parity harness (`terminal-title-tracker-parity.test.ts`): shared byte
|
||||
fixtures (agent title cycles incl. coalesced chunks, BEL inside/spanning
|
||||
OSC, CAN/SUB cancellation, cursor-agent literal, stale-title timeout under
|
||||
fake timers, OSC 133;D, split PR URLs) run through the renderer
|
||||
`createPtyOutputProcessor` and the main tracker; assert identical ordered
|
||||
fact sequences.
|
||||
- Unit: main tracker tests beside `orca-runtime.test.ts` (lastOscTitle
|
||||
parity, tui-idle waiter transitions, synthetic ingestion); store-handler
|
||||
tests reusing `parked-terminal-byte-watcher.test.ts` scenarios.
|
||||
- Pinned tests that flip or retire: `pty-connection.test.ts` callback wiring,
|
||||
`parked-terminal-byte-watcher.test.ts` (retires with the watcher);
|
||||
`pty-transport*.test.ts` stay (processor remains for remote + kill switch).
|
||||
- E2E gates that must stay green throughout: `terminal-attention.spec.ts`,
|
||||
`droid-notification.spec.ts`, `terminal-hidden-view-parking.spec.ts`,
|
||||
`terminal-parked-memory.spec.ts`; add main-authority bell/completion cases
|
||||
(parked tab, focused-pane suppression, kill switch off). SSH parity is
|
||||
exercised manually per the SSH test procedure before each slice ships.
|
||||
|
||||
## Cut-Offs (shipped as four stacked slices)
|
||||
|
||||
1. **Shared tracker in main.** Extract the processor core to shared, run the
|
||||
per-PTY tracker in `onPtyData` replacing `extractLastOscTitle`, parity
|
||||
tests. Main-internal consumers only; no IPC or renderer change.
|
||||
2. **Authority flip.** `pty:sideEffect` channel, renderer store handler,
|
||||
titles/bell/tracker authority to main for local+SSH behind the kill
|
||||
switch; parked watcher stops byte parsing for those kinds.
|
||||
3. **Inversion unwind.** Synthetic frames into the tracker, off `pty:data`;
|
||||
OSC 133;D and PR-link facts; mobile `lastTitle` source preference.
|
||||
4. **Long tail.** Command Code scrape to main, sidecar OSC 9999 dedup, parked
|
||||
watcher shrunk to fact-driven mode (deletion waits on kill-switch
|
||||
retirement), Phase 4 delivery-interest registration documented in the gate
|
||||
design.
|
||||
|
||||
## Open Items
|
||||
|
||||
- **Daemon checkpoint `lastTitle` is write-only.** The daemon sleep/periodic
|
||||
checkpoint (`daemon-pty-adapter.checkpointSessions` → daemon
|
||||
`Session.getSnapshot`) persists the daemon emulator's `lastTitle`, which is
|
||||
derived from real PTY bytes only — synthetic hook title frames never reach
|
||||
the daemon process, so that field cannot carry hook-driven titles. Today no
|
||||
restore path reads it back (`ColdRestoreInfo` drops it; reattach snapshots
|
||||
surface only the ANSI payload), so there is nothing to fix. Main-side
|
||||
consumers of the renderer serializer's `lastTitle` (mobile snapshot reads
|
||||
and the headless hydration seed) prefer main's tracked title. If a future
|
||||
consumer starts reading checkpoint `lastTitle`, it must route through the
|
||||
same tracked-title preference.
|
||||
- **Kill-switch retirement.** Once `terminalMainSideEffectAuthority` is
|
||||
removed, the parked watcher's byte-parser mode, the renderer transport
|
||||
parsers for local/SSH, and the legacy synthetic-frame `pty:data` copy all
|
||||
become dead code and the watcher byte path can be deleted outright.
|
||||
@@ -1,5 +1,9 @@
|
||||
# Terminal Main-Owned State
|
||||
|
||||
This document covers the hidden-output recovery slice. The broader terminal
|
||||
model/view boundary is defined in
|
||||
[`reference/terminal-model-view-contract.md`](./reference/terminal-model-view-contract.md).
|
||||
|
||||
## Problem
|
||||
|
||||
Hidden and background terminal panes cannot rely on renderer memory as the only
|
||||
@@ -51,6 +55,13 @@ already reaches `OrcaRuntimeService.onPtyData` before renderer delivery for
|
||||
local, daemon, and SSH PTYs. That path keeps a headless xterm emulator updated
|
||||
and can serialize it.
|
||||
|
||||
Since the hidden-delivery gate shipped (`terminalHiddenDeliveryGate`, default
|
||||
on — see the contract's Architecture Status), main drops hidden renderer-bound
|
||||
bytes after model ingestion and emits an out-of-band restore marker, so a
|
||||
gated hidden pane accumulates no renderer backlog at all. The overflow path
|
||||
below is the fallback for kill-switch-off mode and for hidden PTYs with an
|
||||
active delivery-interest sidecar.
|
||||
|
||||
The renderer scheduler keeps its 2 MB background cap. When the cap is exceeded:
|
||||
|
||||
1. The scheduler replaces the queued backlog with a small warning fallback.
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"expo": {
|
||||
"name": "Orca",
|
||||
"slug": "orca-mobile",
|
||||
"version": "0.0.25",
|
||||
"version": "0.0.26",
|
||||
"orientation": "default",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "automatic",
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useLocalSearchParams } from 'expo-router'
|
||||
import { MobileAgentSessionHistoryPanel } from '../../../../src/agent-history/MobileAgentSessionHistoryPanel'
|
||||
import { firstParam } from '../../../../src/source-control/mobile-source-control-screen-state'
|
||||
|
||||
export default function MobileAgentSessionHistoryScreen() {
|
||||
const params = useLocalSearchParams<{
|
||||
hostId?: string | string[]
|
||||
worktreeId?: string | string[]
|
||||
name?: string | string[]
|
||||
}>()
|
||||
return (
|
||||
<MobileAgentSessionHistoryPanel
|
||||
hostId={firstParam(params.hostId)}
|
||||
worktreeId={firstParam(params.worktreeId)}
|
||||
name={firstParam(params.name)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,20 +1,11 @@
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
SectionList,
|
||||
Pressable,
|
||||
ActivityIndicator,
|
||||
TextInput
|
||||
} from 'react-native'
|
||||
import { View, Text, StyleSheet, SectionList, Pressable, ActivityIndicator } from 'react-native'
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useFocusEffect, useLocalSearchParams, usePathname, useRouter } from 'expo-router'
|
||||
import {
|
||||
Search,
|
||||
X,
|
||||
Pin,
|
||||
GitBranch,
|
||||
List,
|
||||
SlidersHorizontal,
|
||||
Layers,
|
||||
@@ -52,10 +43,12 @@ import { useActiveWorktreeScroll } from '../../../src/hooks/use-active-worktree-
|
||||
import type { RepoIcon } from '../../../../src/shared/repo-icon'
|
||||
import { PickerModal } from '../../../src/components/PickerModal'
|
||||
import { ActionSheetContent } from '../../../src/components/ActionSheetModal'
|
||||
import { buildWorktreeNavigationActions } from '../../../src/agent-history/worktree-navigation-actions'
|
||||
import { ConfirmModal } from '../../../src/components/ConfirmModal'
|
||||
import { BottomDrawer } from '../../../src/components/BottomDrawer'
|
||||
import { ProtocolBlockScreen } from '../../../src/components/ProtocolBlockScreen'
|
||||
import { AuthFailedBanner } from '../../../src/components/AuthFailedBanner'
|
||||
import { MobileSearchField } from '../../../src/components/MobileSearchField'
|
||||
import { WorkspaceDetailPlaceholder } from '../../../src/components/WorkspaceDetailPlaceholder'
|
||||
import { getCachedWorktrees } from '../../../src/cache/worktree-cache'
|
||||
import { setCachedRepos } from '../../../src/cache/repo-cache'
|
||||
@@ -182,6 +175,7 @@ export function HostScreen({
|
||||
const [showGroupPicker, setShowGroupPicker] = useState(false)
|
||||
const [showFilterModal, setShowFilterModal] = useState(false)
|
||||
const [actionTarget, setActionTarget] = useState<Worktree | null>(null)
|
||||
const [hostCapabilities, setHostCapabilities] = useState<string[]>([])
|
||||
const [confirmDelete, setConfirmDelete] = useState<Worktree | null>(null)
|
||||
const [confirmRemoveHost, setConfirmRemoveHost] = useState(false)
|
||||
const [routeActionState, setRouteActionState] = useState(() =>
|
||||
@@ -513,6 +507,10 @@ export function HostScreen({
|
||||
// format is in place to flip a switch in a future release.
|
||||
useEffect(() => {
|
||||
if (connState !== 'connected' || !client) {
|
||||
// Why: drop the prior host's capabilities while disconnected/switching so
|
||||
// a capability-gated action (e.g. Agent Session History) can't linger for
|
||||
// a host that doesn't support it.
|
||||
setHostCapabilities([])
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
@@ -524,9 +522,13 @@ export function HostScreen({
|
||||
return
|
||||
}
|
||||
if (!response.ok) {
|
||||
setHostCapabilities([])
|
||||
return
|
||||
}
|
||||
const status = (response as RpcSuccess).result as DesktopStatus
|
||||
const status = (response as RpcSuccess).result as DesktopStatus & {
|
||||
capabilities?: string[]
|
||||
}
|
||||
setHostCapabilities(status.capabilities ?? [])
|
||||
const verdict = evaluateCompat({
|
||||
desktopProtocolVersion: status.protocolVersion,
|
||||
desktopMinCompatibleMobileVersion: status.minCompatibleMobileVersion
|
||||
@@ -1125,22 +1127,16 @@ export function HostScreen({
|
||||
{/* Search bar */}
|
||||
{showSearch && (
|
||||
<View style={styles.searchBar}>
|
||||
<Search size={14} color={colors.textMuted} />
|
||||
<TextInput
|
||||
style={styles.searchInput}
|
||||
<MobileSearchField
|
||||
value={search}
|
||||
onChangeText={setSearch}
|
||||
placeholder="Search worktrees…"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoFocus
|
||||
// Why: new key each open remounts focus effect if the field stays mounted
|
||||
// across rapid toggles; pairs with delayed focus so the keyboard appears.
|
||||
focusKey={showSearch}
|
||||
accessibilityLabel="Search worktrees"
|
||||
/>
|
||||
{search.length > 0 && (
|
||||
<Pressable onPress={() => setSearch('')}>
|
||||
<X size={14} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -1172,6 +1168,9 @@ export function HostScreen({
|
||||
sections={sections}
|
||||
keyExtractor={(w) => w.sectionListKey ?? w.worktreeId}
|
||||
stickySectionHeadersEnabled={false}
|
||||
// Why: keep the search IME up while tapping clear / scrolling results.
|
||||
keyboardShouldPersistTaps="handled"
|
||||
keyboardDismissMode="on-drag"
|
||||
onScrollToIndexFailed={onScrollToIndexFailed}
|
||||
// Why: edge-to-edge — the list scrolls under the system nav bar
|
||||
// while reserving insets.bottom keeps the last worktree row reachable
|
||||
@@ -1359,20 +1358,14 @@ export function HostScreen({
|
||||
actions={
|
||||
actionTarget
|
||||
? [
|
||||
{
|
||||
label: 'Source Control',
|
||||
icon: GitBranch,
|
||||
onPress: () => {
|
||||
const params = new URLSearchParams({
|
||||
name: actionTarget.displayName || actionTarget.repo,
|
||||
origin: 'host'
|
||||
})
|
||||
navigateFromHostList(
|
||||
`/h/${hostId}/source-control/${encodeURIComponent(actionTarget.worktreeId)}?${params.toString()}`
|
||||
)
|
||||
setActionTarget(null)
|
||||
}
|
||||
},
|
||||
...buildWorktreeNavigationActions({
|
||||
hostId,
|
||||
worktreeId: actionTarget.worktreeId,
|
||||
worktreeName: actionTarget.displayName || actionTarget.repo,
|
||||
hostCapabilities,
|
||||
navigate: navigateFromHostList,
|
||||
onDone: () => setActionTarget(null)
|
||||
}),
|
||||
{
|
||||
label: 'Sleep',
|
||||
icon: Moon,
|
||||
@@ -1612,21 +1605,12 @@ const styles = StyleSheet.create({
|
||||
padding: spacing.xs
|
||||
},
|
||||
searchBar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.xs + 2,
|
||||
gap: spacing.sm,
|
||||
borderBottomWidth: 1,
|
||||
paddingVertical: spacing.sm,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: colors.borderSubtle,
|
||||
backgroundColor: colors.bgPanel
|
||||
},
|
||||
searchInput: {
|
||||
flex: 1,
|
||||
color: colors.textPrimary,
|
||||
fontSize: 13,
|
||||
paddingVertical: 2
|
||||
},
|
||||
centered: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
|
||||
@@ -36,9 +36,9 @@ import {
|
||||
GitBranch,
|
||||
Globe,
|
||||
Keyboard as KeyboardIcon,
|
||||
ListChecks,
|
||||
MessageSquare,
|
||||
Monitor,
|
||||
MoreHorizontal,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Send,
|
||||
@@ -71,10 +71,14 @@ import {
|
||||
type ActivePanel,
|
||||
canDockSessionPanel,
|
||||
resolvePanelAction,
|
||||
shouldShowSessionHeaderChecksAction,
|
||||
panelRouteDescriptor
|
||||
} from '../../../../src/session/session-panel-host'
|
||||
import { useMobilePrBranchContext } from '../../../../src/session/use-mobile-pr-branch-context'
|
||||
import { SessionDockColumn } from '../../../../src/session/SessionDockColumn'
|
||||
import { MobileSessionHeaderIconButton } from '../../../../src/session/MobileSessionHeaderIconButton'
|
||||
import { MobileSessionHeaderMoreActionsSheet } from '../../../../src/session/MobileSessionHeaderMoreActionsSheet'
|
||||
import { MOBILE_AI_VAULT_CAPABILITY } from '../../../../src/agent-history/agent-history-capability'
|
||||
import type { ConnectionState, RpcFailure, RpcSuccess } from '../../../../src/transport/types'
|
||||
import { headlessActivationNeedsHostRenderer } from '../../../../src/worktree/worktree-activation-result'
|
||||
import { useMobileDictation } from '../../../../src/hooks/use-mobile-dictation'
|
||||
@@ -89,7 +93,7 @@ import type {
|
||||
TerminalKeyboardAvoidanceMetrics,
|
||||
TerminalModes,
|
||||
TerminalWebViewHandle
|
||||
} from '../../../../src/terminal/TerminalWebView'
|
||||
} from '../../../../src/terminal/terminal-webview-contract'
|
||||
import { isTerminalOscLinkRanges } from '../../../../src/terminal/terminal-osc-link-ranges'
|
||||
import { useTerminalViewportRefit } from '../../../../src/terminal/terminal-viewport-refit'
|
||||
import {
|
||||
@@ -940,6 +944,7 @@ export default function SessionScreen() {
|
||||
useState<MobileNewTabAgentLoadState>('idle')
|
||||
const [createTabAgentOptions, setCreateTabAgentOptions] = useState<MobileNewTabAgentOption[]>([])
|
||||
const [showCreateBrowserModal, setShowCreateBrowserModal] = useState(false)
|
||||
const [showHeaderMoreActions, setShowHeaderMoreActions] = useState(false)
|
||||
const [actionTarget, setActionTarget] = useState<Terminal | null>(null)
|
||||
const [markdownActionTarget, setMarkdownActionTarget] = useState<Extract<
|
||||
MobileSessionTab,
|
||||
@@ -1101,6 +1106,12 @@ export default function SessionScreen() {
|
||||
activeSessionTab?.type !== 'browser'
|
||||
const liveInputEnabled = activeHandle ? liveInputTerminalHandles.has(activeHandle) : false
|
||||
const [browserScreencastSupported, setBrowserScreencastSupported] = useState<boolean | null>(null)
|
||||
// Why: hosts without aiVault.v1 reject aiVault.listSessions, so the header
|
||||
// entry stays hidden there (mirrors the gated host-list action) instead of
|
||||
// opening a dead-end "update this host" panel.
|
||||
const [agentSessionHistorySupported, setAgentSessionHistorySupported] = useState<boolean | null>(
|
||||
null
|
||||
)
|
||||
// Why: stable callbacks (handleFileTap) read the live value via this ref, since
|
||||
// the capability probe resolves after the callbacks are created.
|
||||
const browserScreencastSupportedRef = useRef(browserScreencastSupported)
|
||||
@@ -2416,6 +2427,7 @@ export default function SessionScreen() {
|
||||
useEffect(() => {
|
||||
if (!client || connState !== 'connected') {
|
||||
setBrowserScreencastSupported(null)
|
||||
setAgentSessionHistorySupported(null)
|
||||
return
|
||||
}
|
||||
let stale = false
|
||||
@@ -2429,10 +2441,14 @@ export default function SessionScreen() {
|
||||
setBrowserScreencastSupported(
|
||||
status.capabilities?.includes('browser.screencast.v1') === true
|
||||
)
|
||||
setAgentSessionHistorySupported(
|
||||
status.capabilities?.includes(MOBILE_AI_VAULT_CAPABILITY) === true
|
||||
)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!stale) {
|
||||
setBrowserScreencastSupported(false)
|
||||
setAgentSessionHistorySupported(false)
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
@@ -2515,8 +2531,12 @@ export default function SessionScreen() {
|
||||
if (!shouldRecover) {
|
||||
return
|
||||
}
|
||||
for (const terminalRef of terminalRefs.current.values()) {
|
||||
terminalRef.prepareForForegroundRecovery()
|
||||
}
|
||||
// Why: iOS can resume a live WKWebView with a blank xterm backing store
|
||||
// without firing web-ready/reconnect; replay scrollback to repaint it.
|
||||
// without firing web-ready/reconnect; invalidate the native readiness
|
||||
// latch before replay so init waits for the document's pong.
|
||||
const outcome = recoverActiveTerminalAfterForeground({
|
||||
activeHandleRef,
|
||||
terminalRefs,
|
||||
@@ -2568,6 +2588,7 @@ export default function SessionScreen() {
|
||||
clientRef,
|
||||
deviceTokenRef,
|
||||
initializedHandlesRef,
|
||||
connState,
|
||||
tabStripVisible: terminals.length > 1,
|
||||
textScale: terminalTextScale,
|
||||
terminalFrameWidth,
|
||||
@@ -4486,6 +4507,19 @@ export default function SessionScreen() {
|
||||
})
|
||||
}
|
||||
|
||||
const openAgentSessionHistory = () => {
|
||||
const params = new URLSearchParams({ name: worktreeName || '' })
|
||||
router.push(`/h/${hostId}/agent-history/${encodeURIComponent(worktreeId)}?${params.toString()}`)
|
||||
}
|
||||
const showAgentSessionHistoryAction =
|
||||
!isFolderWorkspaceRoute && agentSessionHistorySupported === true
|
||||
const showChecksAction = shouldShowSessionHeaderChecksAction({
|
||||
isFolderWorkspaceRoute,
|
||||
repoContextLoaded: prRepoContextLoaded,
|
||||
hostedChecksSupported: prIsGithubRepo
|
||||
})
|
||||
const showHeaderMoreButton = showAgentSessionHistoryAction || showChecksAction
|
||||
|
||||
return (
|
||||
<View ref={setMobileSessionRootRef} style={styles.container}>
|
||||
<View style={styles.kavInner}>
|
||||
@@ -4521,45 +4555,27 @@ export default function SessionScreen() {
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.filesButton,
|
||||
pressed && styles.filesButtonPressed,
|
||||
activePanel === 'files' && styles.filesButtonActive
|
||||
]}
|
||||
onPress={() => handlePanelTap('files')}
|
||||
hitSlop={8}
|
||||
<MobileSessionHeaderIconButton
|
||||
active={activePanel === 'files'}
|
||||
accessibilityLabel="Open file explorer"
|
||||
>
|
||||
<Folder size={18} color={colors.textSecondary} strokeWidth={2.1} />
|
||||
</Pressable>
|
||||
icon={Folder}
|
||||
onPress={() => handlePanelTap('files')}
|
||||
/>
|
||||
{!isFolderWorkspaceRoute && (
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.filesButton,
|
||||
pressed && styles.filesButtonPressed,
|
||||
activePanel === 'sourceControl' && styles.filesButtonActive
|
||||
]}
|
||||
onPress={() => handlePanelTap('sourceControl')}
|
||||
hitSlop={8}
|
||||
<MobileSessionHeaderIconButton
|
||||
active={activePanel === 'sourceControl'}
|
||||
accessibilityLabel="Open source control"
|
||||
>
|
||||
<GitBranch size={18} color={colors.textSecondary} strokeWidth={2.1} />
|
||||
</Pressable>
|
||||
icon={GitBranch}
|
||||
onPress={() => handlePanelTap('sourceControl')}
|
||||
/>
|
||||
)}
|
||||
{prRepoContextLoaded && prIsGithubRepo ? (
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.filesButton,
|
||||
pressed && styles.filesButtonPressed,
|
||||
activePanel === 'pr' && styles.filesButtonActive
|
||||
]}
|
||||
onPress={() => handlePanelTap('pr')}
|
||||
hitSlop={8}
|
||||
accessibilityLabel="Open pull request"
|
||||
>
|
||||
<ListChecks size={18} color={colors.textSecondary} strokeWidth={2.1} />
|
||||
</Pressable>
|
||||
{showHeaderMoreButton ? (
|
||||
<MobileSessionHeaderIconButton
|
||||
active={activePanel === 'pr'}
|
||||
accessibilityLabel="More session actions"
|
||||
icon={MoreHorizontal}
|
||||
onPress={() => setShowHeaderMoreActions(true)}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
@@ -5176,6 +5192,15 @@ export default function SessionScreen() {
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<MobileSessionHeaderMoreActionsSheet
|
||||
visible={showHeaderMoreActions}
|
||||
showAgentSessionHistory={showAgentSessionHistoryAction}
|
||||
showChecks={showChecksAction}
|
||||
onOpenAgentSessionHistory={openAgentSessionHistory}
|
||||
onOpenChecks={() => handlePanelTap('pr')}
|
||||
onClose={() => setShowHeaderMoreActions(false)}
|
||||
/>
|
||||
|
||||
<ActionSheetModal
|
||||
visible={showCreateTabDrawer}
|
||||
title="New Tab"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { MobileBrowserTab } from '../../../../src/browser/MobileBrowserPane'
|
||||
import type { MobileTerminalTheme } from '../../../../src/terminal/TerminalWebView'
|
||||
import type { MobileTerminalTheme } from '../../../../src/terminal/terminal-webview-contract'
|
||||
import type { MobileDiffLine } from '../../../../src/session/mobile-diff-lines'
|
||||
import type {
|
||||
MobileHighlightedDiffLine,
|
||||
|
||||
@@ -29,7 +29,6 @@ import {
|
||||
Pencil,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Send,
|
||||
X
|
||||
} from 'lucide-react-native'
|
||||
@@ -48,6 +47,7 @@ import { ConfirmModal } from '../../../src/components/ConfirmModal'
|
||||
import { MobileMarkdown } from '../../../src/components/MobileMarkdown'
|
||||
import { MobileAgentIcon } from '../../../src/components/MobileAgentIcon'
|
||||
import { MobileWorkspaceNameInput } from '../../../src/components/MobileWorkspaceNameInput'
|
||||
import { MobileSearchField } from '../../../src/components/MobileSearchField'
|
||||
import { MobileSyntaxSegments } from '../../../src/components/MobileSyntaxSegments'
|
||||
import { PickerModal, type PickerOption } from '../../../src/components/PickerModal'
|
||||
import { TaskProviderLogo } from '../../../src/components/TaskProviderLogo'
|
||||
@@ -8668,6 +8668,7 @@ export default function MobileTasksScreen() {
|
||||
: provider === 'gitlab'
|
||||
? 'No GitLab tasks'
|
||||
: 'No Linear tasks'
|
||||
const isGithubProjectSearch = provider === 'github' && githubMode === 'project'
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
@@ -9048,31 +9049,33 @@ export default function MobileTasksScreen() {
|
||||
{provider === 'gitlab' && gitlabView === 'todos' ? null : provider === 'linear' &&
|
||||
!linearConnected ? null : (
|
||||
<View style={styles.searchBar}>
|
||||
<Search size={14} color={colors.textMuted} />
|
||||
<TextInput
|
||||
style={styles.searchInput}
|
||||
value={
|
||||
provider === 'github' && githubMode === 'project' ? githubProjectSearch : query
|
||||
}
|
||||
onChangeText={
|
||||
provider === 'github' && githubMode === 'project'
|
||||
? setGithubProjectSearch
|
||||
: setQuery
|
||||
}
|
||||
<MobileSearchField
|
||||
value={isGithubProjectSearch ? githubProjectSearch : query}
|
||||
onChangeText={isGithubProjectSearch ? setGithubProjectSearch : setQuery}
|
||||
placeholder={
|
||||
provider === 'github' && githubMode === 'project'
|
||||
isGithubProjectSearch
|
||||
? 'Search project view...'
|
||||
: `Search ${providerLabel} tasks...`
|
||||
}
|
||||
placeholderTextColor={colors.textMuted}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
returnKeyType="search"
|
||||
// Why: GitHub items seed the field with a preset query, so a bare
|
||||
// value.length check would always show clear. Project mode shows clear
|
||||
// for draft text or a non-empty applied override — not for applied ''
|
||||
// (explicit unfiltered after clear), or the button sticks forever.
|
||||
showClear={
|
||||
isGithubProjectSearch
|
||||
? githubProjectSearch.length > 0 ||
|
||||
(appliedGithubProjectSearch !== undefined &&
|
||||
appliedGithubProjectSearch.length > 0)
|
||||
: provider === 'github'
|
||||
? query.trim() !== getTaskPresetQuery(githubPreset).trim()
|
||||
: undefined
|
||||
}
|
||||
editable={taskUiReady}
|
||||
onSubmitEditing={() => {
|
||||
if (!taskUiReady) {
|
||||
return
|
||||
}
|
||||
if (provider === 'github' && githubMode === 'project') {
|
||||
if (isGithubProjectSearch) {
|
||||
applyGitHubProjectSearch()
|
||||
return
|
||||
}
|
||||
@@ -9091,42 +9094,38 @@ export default function MobileTasksScreen() {
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (provider === 'github' && githubMode === 'project') {
|
||||
if (isGithubProjectSearch) {
|
||||
applyGitHubProjectSearch()
|
||||
}
|
||||
}}
|
||||
// Why: Project clear means unfiltered results ('' override when the view
|
||||
// has a default filter), not restore view default. GitHub items clear
|
||||
// restores the preset query. Linear clears and persists empty resume.
|
||||
onClear={() => {
|
||||
if (isGithubProjectSearch) {
|
||||
const viewFilter = githubProjectTable?.selectedView.filter ?? ''
|
||||
setGithubProjectSearch('')
|
||||
// Why: undefined = use view default; '' = explicit unfiltered override.
|
||||
setAppliedGithubProjectSearch(viewFilter ? '' : undefined)
|
||||
return
|
||||
}
|
||||
if (provider === 'github') {
|
||||
const nextQuery = getTaskPresetQuery(githubPreset)
|
||||
setQuery(nextQuery)
|
||||
setAppliedQuery(nextQuery)
|
||||
persistTaskResumeState({
|
||||
githubItemsPreset: githubPreset,
|
||||
githubItemsQuery: nextQuery
|
||||
})
|
||||
return
|
||||
}
|
||||
setQuery('')
|
||||
setAppliedQuery('')
|
||||
if (provider === 'linear') {
|
||||
persistTaskResumeState({ linearQuery: '' })
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{(provider === 'github' && githubMode === 'project'
|
||||
? githubProjectSearch.length > 0 || appliedGithubProjectSearch !== undefined
|
||||
: query.length > 0) && (
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
if (provider === 'github' && githubMode === 'project') {
|
||||
const viewFilter = githubProjectTable?.selectedView.filter ?? ''
|
||||
setGithubProjectSearch('')
|
||||
setAppliedGithubProjectSearch(viewFilter ? '' : undefined)
|
||||
return
|
||||
}
|
||||
if (provider === 'github') {
|
||||
const nextQuery = getTaskPresetQuery(githubPreset)
|
||||
setQuery(nextQuery)
|
||||
setAppliedQuery(nextQuery)
|
||||
persistTaskResumeState({
|
||||
githubItemsPreset: githubPreset,
|
||||
githubItemsQuery: nextQuery
|
||||
})
|
||||
return
|
||||
}
|
||||
setQuery('')
|
||||
setAppliedQuery('')
|
||||
if (provider === 'linear') {
|
||||
persistTaskResumeState({ linearQuery: '' })
|
||||
}
|
||||
}}
|
||||
>
|
||||
<X size={14} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
@@ -13818,20 +13817,11 @@ const styles = StyleSheet.create({
|
||||
color: colors.textSecondary
|
||||
},
|
||||
searchBar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.xs + 2,
|
||||
gap: spacing.sm,
|
||||
borderTopWidth: 1,
|
||||
paddingVertical: spacing.sm,
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
borderTopColor: colors.borderSubtle
|
||||
},
|
||||
searchInput: {
|
||||
flex: 1,
|
||||
color: colors.textPrimary,
|
||||
fontSize: 13,
|
||||
paddingVertical: 2
|
||||
},
|
||||
errorBanner: {
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
|
||||
@@ -46,6 +46,10 @@ function HostStack({ animation }: { animation: 'none' | 'default' }) {
|
||||
name="[hostId]/source-control/[worktreeId]"
|
||||
options={{ title: 'Source Control' }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="[hostId]/agent-history/[worktreeId]"
|
||||
options={{ title: 'Agent Session History' }}
|
||||
/>
|
||||
<Stack.Screen name="[hostId]/review/[worktreeId]" options={{ title: 'Changes' }} />
|
||||
<Stack.Screen name="[hostId]/pr/[worktreeId]" options={{ title: 'Pull Request' }} />
|
||||
</Stack>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+8
-2
@@ -23,7 +23,7 @@
|
||||
"@xterm/addon-webgl": "0.20.0-beta.284",
|
||||
"@xterm/xterm": "6.1.0-beta.285",
|
||||
"buffer": "^6.0.3",
|
||||
"expo": "^55.0.23",
|
||||
"expo": "^55.0.27",
|
||||
"expo-build-properties": "^55.0.13",
|
||||
"expo-camera": "^55.0.18",
|
||||
"expo-clipboard": "^55.0.13",
|
||||
@@ -35,6 +35,7 @@
|
||||
"expo-haptics": "^55.0.14",
|
||||
"expo-image-manipulator": "^55.0.17",
|
||||
"expo-image-picker": "^55.0.20",
|
||||
"expo-keep-awake": "~55.0.8",
|
||||
"expo-linking": "^55.0.15",
|
||||
"expo-modules-core": "~55.0.25",
|
||||
"expo-network": "~55.0.14",
|
||||
@@ -73,8 +74,13 @@
|
||||
"oxlint": "^1.71.0",
|
||||
"react-test-renderer": "19.2.6",
|
||||
"tsx": "^4.22.4",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.0.16",
|
||||
"vitest": "^4.1.9"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"xcode>uuid": "11.1.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+753
-324
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,48 @@
|
||||
import Svg, { Path } from 'react-native-svg'
|
||||
|
||||
type MobileAgentSessionHistoryIconProps = {
|
||||
size?: number
|
||||
color?: string
|
||||
strokeWidth?: number
|
||||
}
|
||||
|
||||
export function MobileAgentSessionHistoryIcon({
|
||||
size = 18,
|
||||
color = '#000000',
|
||||
strokeWidth = 2
|
||||
}: MobileAgentSessionHistoryIconProps) {
|
||||
return (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||
{/* Why: desktop uses Tabler's category glyph for Agent Session History. */}
|
||||
<Path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<Path
|
||||
d="M14 4h6v6h-6z"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<Path
|
||||
d="M4 14h6v6h-6z"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<Path
|
||||
d="M17 17m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<Path
|
||||
d="M7 7m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</Svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { ActivityIndicator, Pressable, RefreshControl, SectionList, Text, View } from 'react-native'
|
||||
import { Play } from 'lucide-react-native'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import { MobileAgentIcon } from '../components/MobileAgentIcon'
|
||||
import { recentSessionConversationTurns } from '../../../src/shared/ai-vault-session-display'
|
||||
import type { AiVaultSession } from '../../../src/shared/ai-vault-types'
|
||||
import type { MobileAgentHistorySection } from './agent-history-sections'
|
||||
import type { MobileAgentHistoryCard } from './agent-history-session-card'
|
||||
import { styles } from './agent-history-styles'
|
||||
|
||||
// Lazy-render at most this many preview turns when a card is tapped — the
|
||||
// scanner already bounds preview text, but rendering them only on tap keeps the
|
||||
// list cheap.
|
||||
const PREVIEW_TURN_LIMIT = 5
|
||||
|
||||
type Props = {
|
||||
sections: MobileAgentHistorySection[]
|
||||
sessionsById: ReadonlyMap<string, AiVaultSession>
|
||||
refreshing: boolean
|
||||
showCurrentWorktreeBadges: boolean
|
||||
resumeActionStateBySessionId?: ReadonlyMap<string, { disabled: boolean; loading: boolean }>
|
||||
onResume?: (session: AiVaultSession) => void | Promise<void>
|
||||
onRefresh: () => void
|
||||
}
|
||||
|
||||
export function MobileAgentSessionHistoryList({
|
||||
sections,
|
||||
sessionsById,
|
||||
refreshing,
|
||||
showCurrentWorktreeBadges,
|
||||
resumeActionStateBySessionId,
|
||||
onResume,
|
||||
onRefresh
|
||||
}: Props) {
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||
|
||||
const toggleExpanded = useCallback((id: string) => {
|
||||
setExpandedId((prev) => (prev === id ? null : id))
|
||||
}, [])
|
||||
|
||||
const renderItem = useCallback(
|
||||
({ item }: { item: MobileAgentHistoryCard }) => (
|
||||
<AgentHistoryCardRow
|
||||
card={item}
|
||||
expanded={expandedId === item.id}
|
||||
session={sessionsById.get(item.id) ?? null}
|
||||
showCurrentWorktreeBadge={showCurrentWorktreeBadges}
|
||||
resumeActionState={resumeActionStateBySessionId?.get(item.id)}
|
||||
onResume={onResume}
|
||||
onPress={() => toggleExpanded(item.id)}
|
||||
/>
|
||||
),
|
||||
[
|
||||
expandedId,
|
||||
onResume,
|
||||
resumeActionStateBySessionId,
|
||||
sessionsById,
|
||||
showCurrentWorktreeBadges,
|
||||
toggleExpanded
|
||||
]
|
||||
)
|
||||
|
||||
return (
|
||||
<SectionList
|
||||
sections={sections}
|
||||
keyExtractor={(card) => card.id}
|
||||
stickySectionHeadersEnabled={false}
|
||||
contentContainerStyle={styles.list}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={onRefresh}
|
||||
tintColor={colors.textSecondary}
|
||||
/>
|
||||
}
|
||||
renderSectionHeader={({ section }) => (
|
||||
<View style={styles.groupHeader}>
|
||||
<Text style={styles.groupHeaderText} numberOfLines={1}>
|
||||
{section.label}
|
||||
</Text>
|
||||
<Text style={styles.groupHeaderCount}>{section.data.length}</Text>
|
||||
</View>
|
||||
)}
|
||||
renderItem={renderItem}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AgentHistoryCardRow({
|
||||
card,
|
||||
expanded,
|
||||
session,
|
||||
showCurrentWorktreeBadge,
|
||||
resumeActionState,
|
||||
onResume,
|
||||
onPress
|
||||
}: {
|
||||
card: MobileAgentHistoryCard
|
||||
expanded: boolean
|
||||
session: AiVaultSession | null
|
||||
showCurrentWorktreeBadge: boolean
|
||||
resumeActionState?: { disabled: boolean; loading: boolean }
|
||||
onResume?: (session: AiVaultSession) => void | Promise<void>
|
||||
onPress: () => void
|
||||
}) {
|
||||
const previewTurns = useMemo(
|
||||
() => (expanded && session ? recentSessionConversationTurns(session, PREVIEW_TURN_LIMIT) : []),
|
||||
[expanded, session]
|
||||
)
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.card, pressed && styles.cardPressed]}
|
||||
onPress={onPress}
|
||||
>
|
||||
<View style={styles.cardTopRow}>
|
||||
<MobileAgentIcon agentId={card.agent} size={16} />
|
||||
<Text style={styles.cardTitle} numberOfLines={1}>
|
||||
{card.title}
|
||||
</Text>
|
||||
{card.timeAgo ? <Text style={styles.cardTimeAgo}>{card.timeAgo}</Text> : null}
|
||||
</View>
|
||||
{card.lastMessage ? (
|
||||
<Text style={styles.cardLastMessage} numberOfLines={expanded ? undefined : 2}>
|
||||
{card.lastMessage}
|
||||
</Text>
|
||||
) : null}
|
||||
<View style={styles.cardMetaRow}>
|
||||
<Text style={styles.cardMetaText}>{card.agentLabel}</Text>
|
||||
<Text style={styles.cardMetaText}>
|
||||
{card.messageCount} {card.messageCount === 1 ? 'message' : 'messages'}
|
||||
</Text>
|
||||
{showCurrentWorktreeBadge && card.isCurrentWorktree ? (
|
||||
<View style={styles.currentBadge}>
|
||||
<Text style={styles.currentBadgeText}>current worktree</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{session && onResume ? (
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.resumeButton,
|
||||
resumeActionState?.disabled && styles.resumeButtonDisabled,
|
||||
pressed && !resumeActionState?.disabled && styles.resumeButtonPressed
|
||||
]}
|
||||
onPress={(event) => {
|
||||
event.stopPropagation()
|
||||
if (!resumeActionState?.disabled) {
|
||||
void onResume(session)
|
||||
}
|
||||
}}
|
||||
disabled={resumeActionState?.disabled}
|
||||
hitSlop={8}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Resume agent session"
|
||||
>
|
||||
{resumeActionState?.loading ? (
|
||||
<ActivityIndicator size="small" color={colors.textPrimary} />
|
||||
) : (
|
||||
<Play size={17} color={colors.textPrimary} strokeWidth={2.4} />
|
||||
)}
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
{expanded && previewTurns.length > 0 ? (
|
||||
<View style={styles.preview}>
|
||||
{previewTurns.map((turn, index) => (
|
||||
<View key={`${card.id}-turn-${index}`} style={styles.previewTurn}>
|
||||
<Text style={styles.previewRole}>{turn.role}</Text>
|
||||
<Text style={styles.previewText}>{turn.text}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { ActivityIndicator, Pressable, Text, TextInput, View } from 'react-native'
|
||||
import { SafeAreaView } from 'react-native-safe-area-context'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { ChevronLeft, RefreshCw } from 'lucide-react-native'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import { useHostClient } from '../transport/client-context'
|
||||
import type { RpcSuccess } from '../transport/types'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { getWorktreeLabel } from '../session/worktree-label'
|
||||
import {
|
||||
buildMobileAiVaultResumeLaunch,
|
||||
createMobileAiVaultResumeMutationRegistry,
|
||||
readMobileRuntimeHostPlatform,
|
||||
readMobileRuntimeTerminalWindowsShell,
|
||||
resolveMobileAiVaultResumePlatform,
|
||||
resumeAiVaultSessionInTerminal,
|
||||
RESUME_RPC_TIMEOUT_MS,
|
||||
type MobileAiVaultResumeSettings
|
||||
} from '../session/ai-vault-resume-launch'
|
||||
import { triggerError, triggerSuccess } from '../platform/haptics'
|
||||
import type { AiVaultScope, AiVaultSession } from '../../../src/shared/ai-vault-types'
|
||||
import type { Worktree } from '../worktree/workspace-list-types'
|
||||
import { useMobileAgentHistoryState } from './use-mobile-agent-history-state'
|
||||
import { buildMobileAgentHistorySections } from './agent-history-sections'
|
||||
import { shouldShowMobileCurrentWorktreeBadge } from './agent-history-current-worktree-badge'
|
||||
import { MobileAgentSessionHistoryList } from './MobileAgentSessionHistoryList'
|
||||
import {
|
||||
resolveMobileAiVaultSessionResumeTarget,
|
||||
type MobileAiVaultResumeFolderWorkspace,
|
||||
type MobileAiVaultResumeProjectGroup,
|
||||
type MobileAiVaultResumeRepo
|
||||
} from './agent-history-resume-target'
|
||||
import { buildMobileAgentHistoryResumeActionState } from './agent-history-session-card'
|
||||
import { styles } from './agent-history-styles'
|
||||
|
||||
export type MobileAgentSessionHistoryPanelProps = {
|
||||
hostId: string
|
||||
worktreeId: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
const SCOPE_TABS: { scope: AiVaultScope; label: string }[] = [
|
||||
{ scope: 'workspace', label: 'Workspace' },
|
||||
{ scope: 'project', label: 'Project' },
|
||||
{ scope: 'all', label: 'All' }
|
||||
]
|
||||
|
||||
export function MobileAgentSessionHistoryPanel({
|
||||
hostId,
|
||||
worktreeId,
|
||||
name = ''
|
||||
}: MobileAgentSessionHistoryPanelProps) {
|
||||
const router = useRouter()
|
||||
const { client, state: connState } = useHostClient(hostId)
|
||||
const [worktrees, setWorktrees] = useState<Worktree[]>([])
|
||||
const [worktreesLoaded, setWorktreesLoaded] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const [resumingSessionId, setResumingSessionId] = useState<string | null>(null)
|
||||
const [resumeMessage, setResumeMessage] = useState<string | null>(null)
|
||||
const resumeLaunchInFlightRef = useRef(false)
|
||||
const resumeMutationRegistryRef = useRef(
|
||||
createMobileAiVaultResumeMutationRegistry(createMobileAiVaultResumeMutationId)
|
||||
)
|
||||
const worktreeLabel = getWorktreeLabel(name, worktreeId)
|
||||
|
||||
// Why: the worktree list seeds the host-local scopePaths derivation and the
|
||||
// active-worktree path for the "current worktree" badge.
|
||||
useEffect(() => {
|
||||
if (!client || connState !== 'connected') {
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
void (async () => {
|
||||
try {
|
||||
const worktreeResponse = await client.sendRequest('worktree.ps', { limit: 10000 })
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
if (worktreeResponse.ok) {
|
||||
const result = (worktreeResponse as RpcSuccess).result as { worktrees: Worktree[] }
|
||||
setWorktrees(result.worktrees)
|
||||
}
|
||||
} catch {
|
||||
// Why: worktree list is best-effort context; the session scan still runs
|
||||
// (without it, scoped tabs can't narrow and fall back to the full list).
|
||||
} finally {
|
||||
// Why: mark loaded even on failure so a scoped tab proceeds with an
|
||||
// unscoped fetch instead of holding a spinner forever.
|
||||
if (!cancelled) {
|
||||
setWorktreesLoaded(true)
|
||||
}
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [client, connState])
|
||||
|
||||
const {
|
||||
scope,
|
||||
screenState,
|
||||
refreshing,
|
||||
hostStatusResult,
|
||||
activeWorktreePath,
|
||||
scopeFilterPaths,
|
||||
onSelectScope,
|
||||
onRefresh,
|
||||
retry
|
||||
} = useMobileAgentHistoryState({ hostId, worktreeId, worktrees, worktreesLoaded })
|
||||
|
||||
const sessions = screenState.kind === 'ready' ? screenState.sessions : EMPTY_SESSIONS
|
||||
const issues = screenState.kind === 'ready' ? screenState.issues : EMPTY_ISSUES
|
||||
const sessionsById = useMemo(
|
||||
() => new Map(sessions.map((session) => [session.id, session])),
|
||||
[sessions]
|
||||
)
|
||||
const sections = useMemo(
|
||||
() =>
|
||||
buildMobileAgentHistorySections(sessions, {
|
||||
query,
|
||||
scope,
|
||||
scopeFilterPaths,
|
||||
activeWorktreePath,
|
||||
now: Date.now()
|
||||
}),
|
||||
[sessions, query, scope, scopeFilterPaths, activeWorktreePath]
|
||||
)
|
||||
|
||||
const hostPlatform = useMemo(
|
||||
() => readMobileRuntimeHostPlatform(hostStatusResult),
|
||||
[hostStatusResult]
|
||||
)
|
||||
const hostTerminalWindowsShell = useMemo(
|
||||
() => readMobileRuntimeTerminalWindowsShell(hostStatusResult),
|
||||
[hostStatusResult]
|
||||
)
|
||||
|
||||
const resumeActionStateBySessionId = useMemo(
|
||||
() => buildMobileAgentHistoryResumeActionState(sessions, resumingSessionId),
|
||||
[resumingSessionId, sessions]
|
||||
)
|
||||
|
||||
const onResumeSession = useCallback(
|
||||
async (session: AiVaultSession): Promise<void> => {
|
||||
if (resumeLaunchInFlightRef.current) {
|
||||
return
|
||||
}
|
||||
if (!client || connState !== 'connected') {
|
||||
setResumeMessage('Waiting for host...')
|
||||
triggerError()
|
||||
return
|
||||
}
|
||||
if (!session.sessionId) {
|
||||
setResumeMessage('This session is missing a resume id.')
|
||||
triggerError()
|
||||
return
|
||||
}
|
||||
|
||||
resumeLaunchInFlightRef.current = true
|
||||
setResumingSessionId(session.id)
|
||||
setResumeMessage(null)
|
||||
try {
|
||||
const {
|
||||
repos,
|
||||
folderWorkspaces,
|
||||
projectGroups,
|
||||
settings,
|
||||
worktrees: freshWorktrees
|
||||
} = await loadMobileResumeMetadata(client)
|
||||
const target = resolveMobileAiVaultSessionResumeTarget({
|
||||
session,
|
||||
activeWorktreeId: worktreeId,
|
||||
// Why: resolve against live worktrees so a workspace deleted or
|
||||
// archived since panel mount can't be picked; the mount-time list is
|
||||
// only a fallback when the fresh fetch fails.
|
||||
worktrees: freshWorktrees ?? worktrees,
|
||||
repos,
|
||||
folderWorkspaces,
|
||||
projectGroups
|
||||
})
|
||||
if (target.status !== 'ready') {
|
||||
setResumeMessage(target.message)
|
||||
triggerError()
|
||||
return
|
||||
}
|
||||
|
||||
const platform = resolveMobileAiVaultResumePlatform(
|
||||
target.targetStatus,
|
||||
hostPlatform,
|
||||
target.workspacePath,
|
||||
target.terminalPlatform
|
||||
)
|
||||
if (!platform) {
|
||||
setResumeMessage('Unable to determine host platform.')
|
||||
triggerError()
|
||||
return
|
||||
}
|
||||
|
||||
const launch = buildMobileAiVaultResumeLaunch({
|
||||
session,
|
||||
hostPlatform: platform,
|
||||
hostTerminalWindowsShell,
|
||||
settings
|
||||
})
|
||||
await resumeAiVaultSessionInTerminal(client, target.worktreeId, {
|
||||
...launch,
|
||||
clientMutationId: resumeMutationRegistryRef.current.claim(session.id)
|
||||
})
|
||||
resumeMutationRegistryRef.current.releaseOnSuccess(session.id)
|
||||
triggerSuccess()
|
||||
setResumeMessage('Agent session queued.')
|
||||
router.push(
|
||||
`/h/${encodeURIComponent(hostId)}/session/${encodeURIComponent(target.worktreeId)}` as Parameters<
|
||||
typeof router.push
|
||||
>[0]
|
||||
)
|
||||
} catch (err) {
|
||||
triggerError()
|
||||
setResumeMessage(err instanceof Error ? err.message : 'Failed to resume session.')
|
||||
} finally {
|
||||
resumeLaunchInFlightRef.current = false
|
||||
setResumingSessionId(null)
|
||||
}
|
||||
},
|
||||
[
|
||||
client,
|
||||
connState,
|
||||
hostId,
|
||||
hostPlatform,
|
||||
hostTerminalWindowsShell,
|
||||
router,
|
||||
worktreeId,
|
||||
worktrees
|
||||
]
|
||||
)
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<SafeAreaView style={styles.header} edges={['top']}>
|
||||
<View style={styles.topBar}>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.backButton, pressed && styles.backButtonPressed]}
|
||||
onPress={() => router.back()}
|
||||
hitSlop={8}
|
||||
accessibilityLabel="Back"
|
||||
>
|
||||
<ChevronLeft size={22} color={colors.textSecondary} strokeWidth={2.2} />
|
||||
</Pressable>
|
||||
<View style={styles.titleBlock}>
|
||||
<Text style={styles.title} numberOfLines={1}>
|
||||
Agent Session History
|
||||
</Text>
|
||||
<Text style={styles.meta} numberOfLines={1}>
|
||||
{worktreeLabel}
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.refreshButton, pressed && styles.refreshButtonPressed]}
|
||||
onPress={() => void onRefresh()}
|
||||
hitSlop={8}
|
||||
accessibilityLabel="Refresh agent sessions"
|
||||
>
|
||||
<RefreshCw size={18} color={colors.textSecondary} strokeWidth={2.1} />
|
||||
</Pressable>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
|
||||
{screenState.kind === 'loading' ? (
|
||||
<View style={styles.state}>
|
||||
<ActivityIndicator size="small" color={colors.textSecondary} />
|
||||
</View>
|
||||
) : screenState.kind === 'unsupported' ? (
|
||||
<View style={styles.state}>
|
||||
<Text style={styles.stateTitle}>Agent Session History Unavailable</Text>
|
||||
<Text style={styles.stateText}>
|
||||
Update Orca on this host to browse agent session history.
|
||||
</Text>
|
||||
</View>
|
||||
) : screenState.kind === 'error' ? (
|
||||
<View style={styles.state}>
|
||||
<Text style={styles.stateTitle}>Unable to Load</Text>
|
||||
<Text style={styles.stateText}>{screenState.message}</Text>
|
||||
<Pressable style={styles.retryButton} onPress={retry}>
|
||||
<Text style={styles.retryText}>Retry</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<View style={styles.scopeTabs}>
|
||||
{SCOPE_TABS.map((tab) => {
|
||||
const active = scope === tab.scope
|
||||
return (
|
||||
<Pressable
|
||||
key={tab.scope}
|
||||
style={[styles.scopeTab, active && styles.scopeTabActive]}
|
||||
onPress={() => onSelectScope(tab.scope)}
|
||||
>
|
||||
<Text style={[styles.scopeTabText, active && styles.scopeTabTextActive]}>
|
||||
{tab.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
<View style={styles.searchRow}>
|
||||
<TextInput
|
||||
style={styles.searchInput}
|
||||
value={query}
|
||||
onChangeText={setQuery}
|
||||
placeholder="Search sessions, repo:, path:"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
</View>
|
||||
{issues.length > 0 ? (
|
||||
<View style={styles.noticeBanner}>
|
||||
<Text style={styles.noticeText}>
|
||||
{issues.length} {issues.length === 1 ? 'transcript' : 'transcripts'} skipped
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{resumeMessage ? (
|
||||
<View style={styles.resumeBanner}>
|
||||
<Text style={styles.resumeBannerText}>{resumeMessage}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{sections.length === 0 ? (
|
||||
<View style={styles.state}>
|
||||
<Text style={styles.stateTitle}>No agent sessions</Text>
|
||||
<Text style={styles.stateText}>
|
||||
{query ? 'No sessions match your search.' : 'No past agent sessions in this scope.'}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<MobileAgentSessionHistoryList
|
||||
sections={sections}
|
||||
sessionsById={sessionsById}
|
||||
refreshing={refreshing}
|
||||
showCurrentWorktreeBadges={shouldShowMobileCurrentWorktreeBadge(scope)}
|
||||
resumeActionStateBySessionId={resumeActionStateBySessionId}
|
||||
onResume={onResumeSession}
|
||||
onRefresh={() => void onRefresh()}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const EMPTY_SESSIONS: AiVaultSession[] = []
|
||||
const EMPTY_ISSUES: { agent: AiVaultSession['agent']; path: string; message: string }[] = []
|
||||
|
||||
async function loadMobileResumeMetadata(client: Pick<RpcClient, 'sendRequest'>): Promise<{
|
||||
repos: MobileAiVaultResumeRepo[]
|
||||
folderWorkspaces: MobileAiVaultResumeFolderWorkspace[]
|
||||
projectGroups: MobileAiVaultResumeProjectGroup[]
|
||||
settings: MobileAiVaultResumeSettings | null
|
||||
worktrees: Worktree[] | null
|
||||
}> {
|
||||
// Why: repo.list can enrich repo remote identities, so fetch resume-only
|
||||
// metadata after explicit user intent instead of delaying history browsing.
|
||||
// timeoutMs: without it a socket drop parks these on the reconnect waiter
|
||||
// for minutes, pinning the resume spinner (see RESUME_RPC_TIMEOUT_MS).
|
||||
const [
|
||||
repoResponse,
|
||||
folderWorkspaceResponse,
|
||||
projectGroupResponse,
|
||||
settingsResponse,
|
||||
worktreeResponse
|
||||
] = await Promise.all([
|
||||
client.sendRequest('repo.list', undefined, { timeoutMs: RESUME_RPC_TIMEOUT_MS }),
|
||||
client
|
||||
.sendRequest('folderWorkspace.list', undefined, { timeoutMs: RESUME_RPC_TIMEOUT_MS })
|
||||
.catch(() => null),
|
||||
client
|
||||
.sendRequest('projectGroup.list', undefined, { timeoutMs: RESUME_RPC_TIMEOUT_MS })
|
||||
.catch(() => null),
|
||||
client
|
||||
.sendRequest('settings.get', undefined, { timeoutMs: RESUME_RPC_TIMEOUT_MS })
|
||||
.catch(() => null),
|
||||
client
|
||||
.sendRequest('worktree.ps', { limit: 10000 }, { timeoutMs: RESUME_RPC_TIMEOUT_MS })
|
||||
.catch(() => null)
|
||||
])
|
||||
if (!repoResponse.ok) {
|
||||
throw new Error(repoResponse.error?.message || 'Unable to load workspace metadata.')
|
||||
}
|
||||
const repoResult = repoResponse.result as { repos?: MobileAiVaultResumeRepo[] }
|
||||
const folderWorkspaceResult =
|
||||
folderWorkspaceResponse?.ok === true
|
||||
? (folderWorkspaceResponse.result as {
|
||||
folderWorkspaces?: MobileAiVaultResumeFolderWorkspace[]
|
||||
})
|
||||
: null
|
||||
const projectGroupResult =
|
||||
projectGroupResponse?.ok === true
|
||||
? (projectGroupResponse.result as { groups?: MobileAiVaultResumeProjectGroup[] })
|
||||
: null
|
||||
const settingsResult =
|
||||
settingsResponse?.ok === true
|
||||
? (settingsResponse.result as { settings?: MobileAiVaultResumeSettings })
|
||||
: null
|
||||
const worktreeResult =
|
||||
worktreeResponse?.ok === true ? (worktreeResponse.result as { worktrees?: Worktree[] }) : null
|
||||
return {
|
||||
repos: repoResult.repos ?? [],
|
||||
folderWorkspaces: folderWorkspaceResult?.folderWorkspaces ?? [],
|
||||
projectGroups: projectGroupResult?.groups ?? [],
|
||||
settings: settingsResult?.settings ?? null,
|
||||
worktrees: worktreeResult?.worktrees ?? null
|
||||
}
|
||||
}
|
||||
|
||||
function createMobileAiVaultResumeMutationId(sessionId: string): string {
|
||||
const sessionPart = sessionId.replace(/[^a-zA-Z0-9_.:-]/g, '_').slice(0, 64) || 'session'
|
||||
const randomPart = Math.random().toString(36).slice(2, 10)
|
||||
return `ai-vault-resume:${sessionPart}:${Date.now().toString(36)}:${randomPart}`
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// Why: mobile's protocol-version.ts is a separate copy without the capability
|
||||
// constants (mirrors MOBILE_TASKS_CAPABILITY in tasks.tsx). Keep this string in
|
||||
// lockstep with src/shared/protocol-version.ts AI_VAULT_RUNTIME_CAPABILITY.
|
||||
export const MOBILE_AI_VAULT_CAPABILITY = 'aiVault.v1'
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { AiVaultScope } from '../../../src/shared/ai-vault-types'
|
||||
|
||||
export function shouldShowMobileCurrentWorktreeBadge(scope: AiVaultScope): boolean {
|
||||
// Why: Workspace is already the current-worktree-only view; Project and All
|
||||
// still mix in sibling/other worktrees, so the badge remains useful there.
|
||||
return scope !== 'workspace'
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
getMobileAiVaultResumeRepoTargetStatus,
|
||||
getMobileAiVaultResumeWorktreeTargetStatus,
|
||||
isSupportedMobileAiVaultResumeTargetStatus,
|
||||
mobileAiVaultResumeTargetBlockMessage,
|
||||
resolveMobileAiVaultSessionResumeTarget
|
||||
} from './agent-history-resume-target'
|
||||
import type { AiVaultSession } from '../../../src/shared/ai-vault-types'
|
||||
import type { Worktree } from '../worktree/workspace-list-types'
|
||||
|
||||
function session(overrides: Partial<AiVaultSession> = {}): AiVaultSession {
|
||||
return {
|
||||
id: 'claude:1',
|
||||
executionHostId: 'local',
|
||||
agent: 'claude',
|
||||
sessionId: 'session-1',
|
||||
title: 'Resume target',
|
||||
cwd: '/Users/ada/repo/app',
|
||||
branch: null,
|
||||
model: null,
|
||||
filePath: '/Users/ada/.claude/session.jsonl',
|
||||
codexHome: null,
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
modifiedAt: '2026-06-29T00:00:00.000Z',
|
||||
messageCount: 2,
|
||||
totalTokens: 10,
|
||||
previewMessages: [],
|
||||
queuedMessageCount: 0,
|
||||
subagentTranscriptCount: 0,
|
||||
resumeCommand: '',
|
||||
subagent: null,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function worktree(overrides: Partial<Worktree> & { worktreeId: string; path: string }): Worktree {
|
||||
return {
|
||||
repoId: 'local-repo',
|
||||
repo: 'orca',
|
||||
branch: 'main',
|
||||
displayName: overrides.worktreeId,
|
||||
liveTerminalCount: 0,
|
||||
hasAttachedPty: false,
|
||||
preview: '',
|
||||
unread: false,
|
||||
isPinned: false,
|
||||
linkedPR: null,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('mobile AI Vault resume target guards', () => {
|
||||
const worktrees = [
|
||||
{ worktreeId: 'local-wt', repoId: 'local-repo' },
|
||||
{ worktreeId: 'ssh-wt', repoId: 'ssh-repo' },
|
||||
{ worktreeId: 'runtime-wt', repoId: 'runtime-repo' },
|
||||
{ worktreeId: 'host-owned-wt', repoId: 'local-repo', hostId: 'ssh:builder' as const },
|
||||
{
|
||||
worktreeId: 'folder:folder-local',
|
||||
repoId: 'folder-workspace:group-local',
|
||||
workspaceKind: 'folder-workspace' as const
|
||||
},
|
||||
{
|
||||
worktreeId: 'folder:folder-ssh',
|
||||
repoId: 'folder-workspace:group-local',
|
||||
workspaceKind: 'folder-workspace' as const
|
||||
},
|
||||
{
|
||||
worktreeId: 'folder:folder-runtime',
|
||||
repoId: 'folder-workspace:group-runtime',
|
||||
workspaceKind: 'folder-workspace' as const
|
||||
}
|
||||
]
|
||||
const repos = [
|
||||
{ id: 'local-repo', path: '/Users/ada/repo', connectionId: null },
|
||||
{ id: 'ssh-repo', path: '/home/ada/ssh-repo', connectionId: 'builder' },
|
||||
{
|
||||
id: 'runtime-repo',
|
||||
path: '/workspace/runtime',
|
||||
connectionId: null,
|
||||
executionHostId: 'runtime:devbox' as const
|
||||
}
|
||||
]
|
||||
const folderWorkspaces = [
|
||||
{ id: 'folder-local', projectGroupId: 'group-local', folderPath: '/Users/ada/folder' },
|
||||
{
|
||||
id: 'folder-ssh',
|
||||
projectGroupId: 'group-local',
|
||||
folderPath: '/home/ada/folder',
|
||||
connectionId: 'folder-builder'
|
||||
},
|
||||
{ id: 'folder-runtime', projectGroupId: 'group-runtime', folderPath: '/workspace/folder' }
|
||||
]
|
||||
const projectGroups = [
|
||||
{ id: 'group-local', connectionId: null },
|
||||
{ id: 'group-runtime', executionHostId: 'runtime:devbox' as const }
|
||||
]
|
||||
|
||||
it('classifies local, SSH, runtime, and unknown repos', () => {
|
||||
expect(getMobileAiVaultResumeRepoTargetStatus(repos[0])).toBe('local')
|
||||
expect(getMobileAiVaultResumeRepoTargetStatus(repos[1])).toBe('ssh')
|
||||
expect(getMobileAiVaultResumeRepoTargetStatus(repos[2])).toBe('runtime')
|
||||
expect(getMobileAiVaultResumeRepoTargetStatus(null)).toBe('unknown')
|
||||
})
|
||||
|
||||
it('classifies worktrees from their repo, with worktree host taking precedence', () => {
|
||||
expect(
|
||||
getMobileAiVaultResumeWorktreeTargetStatus({ worktreeId: 'local-wt', worktrees, repos })
|
||||
).toBe('local')
|
||||
expect(
|
||||
getMobileAiVaultResumeWorktreeTargetStatus({ worktreeId: 'ssh-wt', worktrees, repos })
|
||||
).toBe('ssh')
|
||||
expect(
|
||||
getMobileAiVaultResumeWorktreeTargetStatus({ worktreeId: 'runtime-wt', worktrees, repos })
|
||||
).toBe('runtime')
|
||||
expect(
|
||||
getMobileAiVaultResumeWorktreeTargetStatus({ worktreeId: 'host-owned-wt', worktrees, repos })
|
||||
).toBe('ssh')
|
||||
expect(
|
||||
getMobileAiVaultResumeWorktreeTargetStatus({
|
||||
worktreeId: 'folder:folder-local',
|
||||
worktrees,
|
||||
repos,
|
||||
folderWorkspaces,
|
||||
projectGroups
|
||||
})
|
||||
).toBe('local')
|
||||
expect(
|
||||
getMobileAiVaultResumeWorktreeTargetStatus({
|
||||
worktreeId: 'folder:folder-ssh',
|
||||
worktrees,
|
||||
repos,
|
||||
folderWorkspaces,
|
||||
projectGroups
|
||||
})
|
||||
).toBe('ssh')
|
||||
expect(
|
||||
getMobileAiVaultResumeWorktreeTargetStatus({
|
||||
worktreeId: 'folder:folder-runtime',
|
||||
worktrees,
|
||||
repos,
|
||||
folderWorkspaces,
|
||||
projectGroups
|
||||
})
|
||||
).toBe('runtime')
|
||||
expect(
|
||||
getMobileAiVaultResumeWorktreeTargetStatus({ worktreeId: 'missing', worktrees, repos })
|
||||
).toBe('unknown')
|
||||
})
|
||||
|
||||
it('supports local targets only; SSH hosts cannot see host-local transcripts', () => {
|
||||
expect(isSupportedMobileAiVaultResumeTargetStatus('local')).toBe(true)
|
||||
expect(isSupportedMobileAiVaultResumeTargetStatus('ssh')).toBe(false)
|
||||
expect(isSupportedMobileAiVaultResumeTargetStatus('runtime')).toBe(false)
|
||||
expect(mobileAiVaultResumeTargetBlockMessage('runtime')).toContain('runtime-hosted')
|
||||
expect(mobileAiVaultResumeTargetBlockMessage('ssh')).toContain('SSH workspace')
|
||||
expect(mobileAiVaultResumeTargetBlockMessage('ssh')).toContain('local workspace')
|
||||
expect(mobileAiVaultResumeTargetBlockMessage('unknown')).toContain('local workspace')
|
||||
})
|
||||
|
||||
it('resolves project-scope rows to the matched session worktree before the route worktree', () => {
|
||||
const target = resolveMobileAiVaultSessionResumeTarget({
|
||||
session: session({ cwd: '/Users/ada/repo/feature/src' }),
|
||||
activeWorktreeId: 'route-wt',
|
||||
worktrees: [
|
||||
worktree({ worktreeId: 'route-wt', path: '/Users/ada/repo/main' }),
|
||||
worktree({ worktreeId: 'session-wt', path: '/Users/ada/repo/feature' })
|
||||
],
|
||||
repos
|
||||
})
|
||||
expect(target).toEqual({
|
||||
status: 'ready',
|
||||
worktreeId: 'session-wt',
|
||||
targetStatus: 'local',
|
||||
workspacePath: '/Users/ada/repo/feature',
|
||||
terminalPlatform: null
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to the active route worktree when the session worktree is archived', () => {
|
||||
const target = resolveMobileAiVaultSessionResumeTarget({
|
||||
session: session({ cwd: '/Users/ada/repo/archive/src' }),
|
||||
activeWorktreeId: 'route-wt',
|
||||
worktrees: [
|
||||
worktree({ worktreeId: 'route-wt', path: '/Users/ada/repo/main' }),
|
||||
worktree({
|
||||
worktreeId: 'archived-wt',
|
||||
path: '/Users/ada/repo/archive',
|
||||
isArchived: true
|
||||
})
|
||||
],
|
||||
repos
|
||||
})
|
||||
expect(target).toEqual({
|
||||
status: 'ready',
|
||||
worktreeId: 'route-wt',
|
||||
targetStatus: 'local',
|
||||
workspacePath: '/Users/ada/repo/main',
|
||||
terminalPlatform: null
|
||||
})
|
||||
})
|
||||
|
||||
it('blocks runtime targets when no supported fallback is available', () => {
|
||||
const target = resolveMobileAiVaultSessionResumeTarget({
|
||||
session: session({ cwd: '/Users/ada/runtime/app' }),
|
||||
activeWorktreeId: 'runtime-wt',
|
||||
worktrees: [
|
||||
worktree({ worktreeId: 'runtime-wt', repoId: 'runtime-repo', path: '/Users/ada/runtime' })
|
||||
],
|
||||
repos
|
||||
})
|
||||
expect(target.status).toBe('blocked')
|
||||
expect(target.status === 'blocked' ? target.message : '').toContain('runtime-hosted')
|
||||
})
|
||||
|
||||
it('blocks SSH folder workspace targets because transcripts are host-local', () => {
|
||||
const target = resolveMobileAiVaultSessionResumeTarget({
|
||||
session: session({ cwd: '/home/ada/folder/src' }),
|
||||
activeWorktreeId: 'folder:folder-ssh',
|
||||
worktrees: [
|
||||
worktree({
|
||||
worktreeId: 'folder:folder-ssh',
|
||||
repoId: 'folder-workspace:group-local',
|
||||
workspaceKind: 'folder-workspace',
|
||||
path: '/home/ada/folder'
|
||||
})
|
||||
],
|
||||
repos,
|
||||
folderWorkspaces,
|
||||
projectGroups
|
||||
})
|
||||
expect(target.status).toBe('blocked')
|
||||
expect(target.status === 'blocked' ? target.message : '').toContain('SSH workspace')
|
||||
})
|
||||
|
||||
it('skips an SSH session-worktree candidate in favor of a local active-worktree fallback', () => {
|
||||
const target = resolveMobileAiVaultSessionResumeTarget({
|
||||
session: session({ cwd: '/home/ada/ssh-repo/feature/src' }),
|
||||
activeWorktreeId: 'route-wt',
|
||||
worktrees: [
|
||||
worktree({ worktreeId: 'route-wt', path: '/Users/ada/repo/main' }),
|
||||
worktree({
|
||||
worktreeId: 'ssh-session-wt',
|
||||
repoId: 'ssh-repo',
|
||||
path: '/home/ada/ssh-repo/feature'
|
||||
})
|
||||
],
|
||||
repos
|
||||
})
|
||||
expect(target).toEqual({
|
||||
status: 'ready',
|
||||
worktreeId: 'route-wt',
|
||||
targetStatus: 'local',
|
||||
workspacePath: '/Users/ada/repo/main',
|
||||
terminalPlatform: null
|
||||
})
|
||||
})
|
||||
|
||||
it('blocks SSH session worktrees with the SSH message when no local fallback exists', () => {
|
||||
const target = resolveMobileAiVaultSessionResumeTarget({
|
||||
session: session({ cwd: '/home/ada/ssh-repo/feature/src' }),
|
||||
activeWorktreeId: 'ssh-session-wt',
|
||||
worktrees: [
|
||||
worktree({
|
||||
worktreeId: 'ssh-session-wt',
|
||||
repoId: 'ssh-repo',
|
||||
path: '/home/ada/ssh-repo/feature'
|
||||
})
|
||||
],
|
||||
repos
|
||||
})
|
||||
expect(target.status).toBe('blocked')
|
||||
expect(target.status === 'blocked' ? target.message : '').toContain('SSH workspace')
|
||||
})
|
||||
|
||||
it('blocks folder workspaces whose candidate repos include a runtime owner', () => {
|
||||
const target = resolveMobileAiVaultSessionResumeTarget({
|
||||
session: session({ cwd: '/workspace/folder/src' }),
|
||||
activeWorktreeId: 'folder:folder-runtime-candidate',
|
||||
worktrees: [
|
||||
worktree({
|
||||
worktreeId: 'folder:folder-runtime-candidate',
|
||||
repoId: 'folder-workspace:group-local',
|
||||
workspaceKind: 'folder-workspace',
|
||||
path: '/workspace/folder'
|
||||
})
|
||||
],
|
||||
repos: [
|
||||
...repos,
|
||||
{
|
||||
id: 'runtime-in-folder',
|
||||
path: '/workspace/folder/repo',
|
||||
connectionId: null,
|
||||
executionHostId: 'runtime:devbox' as const
|
||||
}
|
||||
],
|
||||
folderWorkspaces: [
|
||||
{
|
||||
id: 'folder-runtime-candidate',
|
||||
projectGroupId: 'group-local',
|
||||
folderPath: '/workspace/folder'
|
||||
}
|
||||
],
|
||||
projectGroups
|
||||
})
|
||||
expect(target.status).toBe('blocked')
|
||||
expect(target.status === 'blocked' ? target.message : '').toContain('runtime-hosted')
|
||||
})
|
||||
|
||||
it('blocks folder workspaces when folder metadata is unavailable', () => {
|
||||
const target = resolveMobileAiVaultSessionResumeTarget({
|
||||
session: session({ cwd: '/Users/ada/folder/src' }),
|
||||
activeWorktreeId: 'folder:missing-folder',
|
||||
worktrees: [
|
||||
worktree({
|
||||
worktreeId: 'folder:missing-folder',
|
||||
repoId: 'folder-workspace:group-local',
|
||||
workspaceKind: 'folder-workspace',
|
||||
path: '/Users/ada/folder'
|
||||
})
|
||||
],
|
||||
repos,
|
||||
folderWorkspaces: [],
|
||||
projectGroups
|
||||
})
|
||||
expect(target).toEqual({
|
||||
status: 'blocked',
|
||||
message: 'Open a local workspace before resuming a session.'
|
||||
})
|
||||
})
|
||||
|
||||
it('blocks folder workspaces with mixed candidate repo hosts as unknown', () => {
|
||||
const target = resolveMobileAiVaultSessionResumeTarget({
|
||||
session: session({ cwd: '/Users/ada/mixed/src' }),
|
||||
activeWorktreeId: 'folder:folder-mixed',
|
||||
worktrees: [
|
||||
worktree({
|
||||
worktreeId: 'folder:folder-mixed',
|
||||
repoId: 'folder-workspace:group-local',
|
||||
workspaceKind: 'folder-workspace',
|
||||
path: '/Users/ada/mixed'
|
||||
})
|
||||
],
|
||||
repos: [
|
||||
{
|
||||
id: 'local-in-folder',
|
||||
path: '/Users/ada/mixed/local',
|
||||
connectionId: null
|
||||
},
|
||||
{
|
||||
id: 'ssh-in-folder',
|
||||
path: '/Users/ada/mixed/ssh',
|
||||
connectionId: 'builder'
|
||||
}
|
||||
],
|
||||
folderWorkspaces: [
|
||||
{
|
||||
id: 'folder-mixed',
|
||||
projectGroupId: 'group-local',
|
||||
folderPath: '/Users/ada/mixed'
|
||||
}
|
||||
],
|
||||
projectGroups
|
||||
})
|
||||
expect(target).toEqual({
|
||||
status: 'blocked',
|
||||
message: 'Open a local workspace before resuming a session.'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,304 @@
|
||||
import {
|
||||
getRepoExecutionHostId,
|
||||
normalizeExecutionHostId,
|
||||
parseExecutionHostId,
|
||||
toSshExecutionHostId,
|
||||
type ExecutionHostId
|
||||
} from '../../../src/shared/execution-host'
|
||||
import type { AiVaultSession } from '../../../src/shared/ai-vault-types'
|
||||
import { isPathInsideOrEqual } from '../../../src/shared/cross-platform-path'
|
||||
import type { Worktree } from '../worktree/workspace-list-types'
|
||||
import {
|
||||
canResumeInMobileSessionWorktree,
|
||||
resolveMobileAgentHistorySessionWorktree
|
||||
} from './agent-history-session-worktree'
|
||||
|
||||
export type MobileAiVaultResumeTargetStatus = 'local' | 'ssh' | 'runtime' | 'unknown'
|
||||
|
||||
export type MobileAiVaultResumeRepo = {
|
||||
id: string
|
||||
path?: string | null
|
||||
projectGroupId?: string | null
|
||||
connectionId?: string | null
|
||||
executionHostId?: ExecutionHostId | null
|
||||
}
|
||||
|
||||
type MobileAiVaultResumeWorktree = Pick<Worktree, 'repoId' | 'worktreeId'> & {
|
||||
path?: string | null
|
||||
workspaceKind?: Worktree['workspaceKind']
|
||||
hostId?: ExecutionHostId | null
|
||||
}
|
||||
|
||||
export type MobileAiVaultResumeFolderWorkspace = {
|
||||
id: string
|
||||
projectGroupId: string
|
||||
folderPath: string
|
||||
connectionId?: string | null
|
||||
}
|
||||
|
||||
export type MobileAiVaultResumeProjectGroup = {
|
||||
id: string
|
||||
parentGroupId?: string | null
|
||||
connectionId?: string | null
|
||||
executionHostId?: ExecutionHostId | string | null
|
||||
}
|
||||
|
||||
export type MobileAiVaultSessionResumeTarget =
|
||||
| {
|
||||
status: 'ready'
|
||||
worktreeId: string
|
||||
targetStatus: 'local'
|
||||
workspacePath: string | null
|
||||
terminalPlatform: NodeJS.Platform | null
|
||||
}
|
||||
| { status: 'blocked'; message: string }
|
||||
|
||||
export function getMobileAiVaultResumeRepoTargetStatus(
|
||||
repo: MobileAiVaultResumeRepo | null | undefined
|
||||
): MobileAiVaultResumeTargetStatus {
|
||||
if (!repo) {
|
||||
return 'unknown'
|
||||
}
|
||||
return getMobileAiVaultResumeExecutionHostTargetStatus(getRepoExecutionHostId(repo))
|
||||
}
|
||||
|
||||
export function getMobileAiVaultResumeWorktreeTargetStatus(args: {
|
||||
worktreeId: string | null
|
||||
worktrees: readonly MobileAiVaultResumeWorktree[]
|
||||
repos: readonly MobileAiVaultResumeRepo[]
|
||||
folderWorkspaces?: readonly MobileAiVaultResumeFolderWorkspace[]
|
||||
projectGroups?: readonly MobileAiVaultResumeProjectGroup[]
|
||||
}): MobileAiVaultResumeTargetStatus {
|
||||
if (!args.worktreeId) {
|
||||
return 'unknown'
|
||||
}
|
||||
const worktree = args.worktrees.find((candidate) => candidate.worktreeId === args.worktreeId)
|
||||
if (!worktree) {
|
||||
return 'unknown'
|
||||
}
|
||||
if (worktree.workspaceKind === 'folder-workspace') {
|
||||
return getMobileAiVaultResumeFolderTargetStatus({
|
||||
worktreeId: args.worktreeId,
|
||||
worktree,
|
||||
repos: args.repos,
|
||||
folderWorkspaces: args.folderWorkspaces ?? [],
|
||||
projectGroups: args.projectGroups ?? []
|
||||
})
|
||||
}
|
||||
const worktreeHost = getMobileAiVaultResumeExecutionHostTargetStatus(worktree.hostId)
|
||||
if (worktreeHost !== 'unknown') {
|
||||
return worktreeHost
|
||||
}
|
||||
return getMobileAiVaultResumeRepoTargetStatus(
|
||||
args.repos.find((candidate) => candidate.id === worktree.repoId)
|
||||
)
|
||||
}
|
||||
|
||||
export function isSupportedMobileAiVaultResumeTargetStatus(
|
||||
status: MobileAiVaultResumeTargetStatus
|
||||
): status is 'local' {
|
||||
// Why: mobile sessions come from the host-local transcript scan, so an SSH
|
||||
// workspace cannot see the transcript file (mirror of desktop #6270).
|
||||
return status === 'local'
|
||||
}
|
||||
|
||||
export function mobileAiVaultResumeTargetBlockMessage(
|
||||
status: MobileAiVaultResumeTargetStatus
|
||||
): string {
|
||||
if (status === 'runtime') {
|
||||
return 'Resume from history is not available in runtime-hosted workspaces.'
|
||||
}
|
||||
if (status === 'ssh') {
|
||||
return 'This session is stored on the host machine, so it cannot be resumed in an SSH workspace. Open a local workspace for this project.'
|
||||
}
|
||||
return 'Open a local workspace before resuming a session.'
|
||||
}
|
||||
|
||||
export function resolveMobileAiVaultSessionResumeTarget(args: {
|
||||
session: AiVaultSession
|
||||
activeWorktreeId: string | null
|
||||
worktrees: readonly Worktree[]
|
||||
repos: readonly MobileAiVaultResumeRepo[]
|
||||
folderWorkspaces?: readonly MobileAiVaultResumeFolderWorkspace[]
|
||||
projectGroups?: readonly MobileAiVaultResumeProjectGroup[]
|
||||
}): MobileAiVaultSessionResumeTarget {
|
||||
const sessionWorktree = resolveMobileAgentHistorySessionWorktree({
|
||||
session: args.session,
|
||||
worktrees: args.worktrees,
|
||||
activeWorktreeId: args.activeWorktreeId
|
||||
})
|
||||
const sessionWorktreeId = canResumeInMobileSessionWorktree(sessionWorktree)
|
||||
? sessionWorktree?.worktreeId
|
||||
: null
|
||||
const candidateWorktreeIds = [
|
||||
sessionWorktreeId,
|
||||
args.activeWorktreeId && args.activeWorktreeId !== sessionWorktreeId
|
||||
? args.activeWorktreeId
|
||||
: null
|
||||
].filter((candidate): candidate is string => Boolean(candidate))
|
||||
|
||||
for (const candidateWorktreeId of candidateWorktreeIds) {
|
||||
const targetStatus = getMobileAiVaultResumeWorktreeTargetStatus({
|
||||
worktreeId: candidateWorktreeId,
|
||||
worktrees: args.worktrees,
|
||||
repos: args.repos,
|
||||
folderWorkspaces: args.folderWorkspaces,
|
||||
projectGroups: args.projectGroups
|
||||
})
|
||||
if (!isSupportedMobileAiVaultResumeTargetStatus(targetStatus)) {
|
||||
continue
|
||||
}
|
||||
return {
|
||||
status: 'ready',
|
||||
worktreeId: candidateWorktreeId,
|
||||
targetStatus,
|
||||
workspacePath:
|
||||
args.worktrees.find((worktree) => worktree.worktreeId === candidateWorktreeId)?.path ??
|
||||
null,
|
||||
terminalPlatform:
|
||||
args.worktrees.find((worktree) => worktree.worktreeId === candidateWorktreeId)
|
||||
?.terminalPlatform ?? null
|
||||
}
|
||||
}
|
||||
|
||||
const blockedStatus = getMobileAiVaultResumeWorktreeTargetStatus({
|
||||
worktreeId: candidateWorktreeIds[0] ?? args.activeWorktreeId,
|
||||
worktrees: args.worktrees,
|
||||
repos: args.repos,
|
||||
folderWorkspaces: args.folderWorkspaces,
|
||||
projectGroups: args.projectGroups
|
||||
})
|
||||
return { status: 'blocked', message: mobileAiVaultResumeTargetBlockMessage(blockedStatus) }
|
||||
}
|
||||
|
||||
function getMobileAiVaultResumeFolderTargetStatus(args: {
|
||||
worktreeId: string
|
||||
worktree: MobileAiVaultResumeWorktree
|
||||
repos: readonly MobileAiVaultResumeRepo[]
|
||||
folderWorkspaces: readonly MobileAiVaultResumeFolderWorkspace[]
|
||||
projectGroups: readonly MobileAiVaultResumeProjectGroup[]
|
||||
}): MobileAiVaultResumeTargetStatus {
|
||||
const folderWorkspaceId = args.worktreeId.startsWith('folder:')
|
||||
? args.worktreeId.slice('folder:'.length)
|
||||
: null
|
||||
const folderWorkspace = folderWorkspaceId
|
||||
? args.folderWorkspaces.find((workspace) => workspace.id === folderWorkspaceId)
|
||||
: null
|
||||
if (!folderWorkspace) {
|
||||
return 'unknown'
|
||||
}
|
||||
const projectGroupId =
|
||||
folderWorkspace.projectGroupId ?? parseFolderWorkspaceRepoId(args.worktree.repoId)
|
||||
const projectGroup = projectGroupId
|
||||
? args.projectGroups.find((group) => group.id === projectGroupId)
|
||||
: null
|
||||
|
||||
const groupHostId = normalizeExecutionHostId(projectGroup?.executionHostId)
|
||||
if (groupHostId) {
|
||||
return getMobileAiVaultResumeExecutionHostTargetStatus(groupHostId)
|
||||
}
|
||||
|
||||
const explicitConnectionId = (
|
||||
folderWorkspace?.connectionId ??
|
||||
projectGroup?.connectionId ??
|
||||
''
|
||||
).trim()
|
||||
if (explicitConnectionId) {
|
||||
return getMobileAiVaultResumeExecutionHostTargetStatus(
|
||||
toSshExecutionHostId(explicitConnectionId)
|
||||
)
|
||||
}
|
||||
|
||||
return mergeMobileAiVaultResumeExecutionHostTargetStatuses(
|
||||
getMobileFolderWorkspaceCandidateRepos({
|
||||
folderWorkspace,
|
||||
projectGroupId,
|
||||
projectGroups: args.projectGroups,
|
||||
repos: args.repos
|
||||
}).map(getRepoExecutionHostId)
|
||||
)
|
||||
}
|
||||
|
||||
function getMobileAiVaultResumeExecutionHostTargetStatus(
|
||||
hostId: ExecutionHostId | null | undefined
|
||||
): MobileAiVaultResumeTargetStatus {
|
||||
const parsed = parseExecutionHostId(hostId)
|
||||
if (!parsed) {
|
||||
return 'unknown'
|
||||
}
|
||||
return parsed.kind
|
||||
}
|
||||
|
||||
function parseFolderWorkspaceRepoId(repoId: string): string | null {
|
||||
const prefix = 'folder-workspace:'
|
||||
return repoId.startsWith(prefix) ? repoId.slice(prefix.length) || null : null
|
||||
}
|
||||
|
||||
function getMobileFolderWorkspaceCandidateRepos(args: {
|
||||
folderWorkspace: MobileAiVaultResumeFolderWorkspace | null | undefined
|
||||
projectGroupId: string | null
|
||||
projectGroups: readonly MobileAiVaultResumeProjectGroup[]
|
||||
repos: readonly MobileAiVaultResumeRepo[]
|
||||
}): MobileAiVaultResumeRepo[] {
|
||||
if (!args.folderWorkspace || !args.projectGroupId) {
|
||||
return []
|
||||
}
|
||||
const folderWorkspace = args.folderWorkspace
|
||||
const groupIds = getMobileProjectGroupSubtreeIds(args.projectGroups, args.projectGroupId)
|
||||
const groupRepos = args.repos.filter(
|
||||
(repo) => typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId)
|
||||
)
|
||||
const pathRepos = args.repos.filter(
|
||||
(repo) =>
|
||||
!(typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId)) &&
|
||||
typeof repo.path === 'string' &&
|
||||
repo.path.trim().length > 0 &&
|
||||
isPathInsideOrEqual(folderWorkspace.folderPath, repo.path)
|
||||
)
|
||||
if (folderWorkspace.connectionId) {
|
||||
return [
|
||||
...groupRepos,
|
||||
...pathRepos.filter((repo) => (repo.connectionId ?? null) === folderWorkspace.connectionId)
|
||||
]
|
||||
}
|
||||
if (groupRepos.length === 0) {
|
||||
return pathRepos
|
||||
}
|
||||
const groupConnectionIds = new Set(groupRepos.map((repo) => repo.connectionId ?? null))
|
||||
return [
|
||||
...groupRepos,
|
||||
...pathRepos.filter((repo) => groupConnectionIds.has(repo.connectionId ?? null))
|
||||
]
|
||||
}
|
||||
|
||||
function getMobileProjectGroupSubtreeIds(
|
||||
projectGroups: readonly MobileAiVaultResumeProjectGroup[],
|
||||
projectGroupId: string
|
||||
): Set<string> {
|
||||
const ids = new Set<string>([projectGroupId])
|
||||
let changed = true
|
||||
while (changed) {
|
||||
changed = false
|
||||
for (const group of projectGroups) {
|
||||
if (group.parentGroupId && ids.has(group.parentGroupId) && !ids.has(group.id)) {
|
||||
ids.add(group.id)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
function mergeMobileAiVaultResumeExecutionHostTargetStatuses(
|
||||
hostIds: readonly ExecutionHostId[]
|
||||
): MobileAiVaultResumeTargetStatus {
|
||||
if (hostIds.length === 0) {
|
||||
return 'local'
|
||||
}
|
||||
const statuses = hostIds.map(getMobileAiVaultResumeExecutionHostTargetStatus)
|
||||
const uniqueStatuses = new Set(statuses)
|
||||
if (uniqueStatuses.has('runtime')) {
|
||||
return 'runtime'
|
||||
}
|
||||
return new Set(hostIds).size === 1 ? (statuses[0] ?? 'unknown') : 'unknown'
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Worktree } from '../worktree/workspace-list-types'
|
||||
import { deriveMobileAiVaultScopePaths } from './agent-history-scope-paths'
|
||||
|
||||
function worktree(overrides: Partial<Worktree>): Pick<Worktree, 'worktreeId' | 'path' | 'repoId'> {
|
||||
return {
|
||||
worktreeId: overrides.worktreeId ?? 'w1',
|
||||
path: overrides.path ?? '/Users/ada/repo/app',
|
||||
repoId: overrides.repoId ?? 'repo-1'
|
||||
}
|
||||
}
|
||||
|
||||
const active = worktree({ worktreeId: 'w1', path: '/Users/ada/repo/app', repoId: 'repo-1' })
|
||||
const sibling = worktree({ worktreeId: 'w2', path: '/Users/ada/repo/app-2', repoId: 'repo-1' })
|
||||
const otherRepo = worktree({ worktreeId: 'w3', path: '/Users/ada/other/ui', repoId: 'repo-2' })
|
||||
|
||||
describe('deriveMobileAiVaultScopePaths', () => {
|
||||
it('workspace scope returns only the active worktree path', () => {
|
||||
expect(
|
||||
deriveMobileAiVaultScopePaths('workspace', active, [active, sibling, otherRepo])
|
||||
).toEqual(['/Users/ada/repo/app'])
|
||||
})
|
||||
|
||||
it('project scope adds same-repo siblings but not other-repo worktrees', () => {
|
||||
expect(deriveMobileAiVaultScopePaths('project', active, [active, sibling, otherRepo])).toEqual([
|
||||
'/Users/ada/repo/app',
|
||||
'/Users/ada/repo/app-2'
|
||||
])
|
||||
})
|
||||
|
||||
it('all scope returns no scope hints (global recency list)', () => {
|
||||
expect(deriveMobileAiVaultScopePaths('all', active, [active, sibling])).toEqual([])
|
||||
})
|
||||
|
||||
it('returns no paths when there is no active worktree', () => {
|
||||
expect(deriveMobileAiVaultScopePaths('workspace', null, [sibling])).toEqual([])
|
||||
})
|
||||
|
||||
it('caps project scope at 64 paths so the host RPC bound does not reject the request', () => {
|
||||
const siblings = Array.from({ length: 200 }, (_, index) =>
|
||||
worktree({
|
||||
worktreeId: `w-sib-${index}`,
|
||||
path: `/Users/ada/repo/app-${index}`,
|
||||
repoId: 'repo-1'
|
||||
})
|
||||
)
|
||||
const result = deriveMobileAiVaultScopePaths('project', active, [active, ...siblings])
|
||||
expect(result.length).toBe(64)
|
||||
// Active worktree is seeded first, so it survives truncation.
|
||||
expect(result[0]).toBe('/Users/ada/repo/app')
|
||||
})
|
||||
|
||||
it('dedupes and skips non-absolute paths', () => {
|
||||
const dupe = worktree({ worktreeId: 'w4', path: '/Users/ada/repo/app', repoId: 'repo-1' })
|
||||
const relative = worktree({ worktreeId: 'w5', path: 'relative/path', repoId: 'repo-1' })
|
||||
expect(deriveMobileAiVaultScopePaths('project', active, [active, dupe, relative])).toEqual([
|
||||
'/Users/ada/repo/app'
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
isRuntimePathAbsolute,
|
||||
normalizeRuntimePathForComparison
|
||||
} from '../../../src/shared/cross-platform-path'
|
||||
import type { Worktree } from '../worktree/workspace-list-types'
|
||||
import {
|
||||
AI_VAULT_SCOPE_PATHS_MAX_COUNT,
|
||||
type AiVaultScope
|
||||
} from '../../../src/shared/ai-vault-types'
|
||||
|
||||
// Why: the renderer's deriveAiVault* helpers are renderer-located and
|
||||
// Metro-unresolvable, so mobile does its own minimal derivation seeded by the
|
||||
// active worktree's path plus same-repo sibling worktrees (mobile already loads
|
||||
// the full worktree list via worktree.ps). scopePaths only widen the host scan's
|
||||
// discovery breadth; they are host-local match prefixes, never device paths.
|
||||
export function deriveMobileAiVaultScopePaths(
|
||||
scope: AiVaultScope,
|
||||
activeWorktree: Pick<Worktree, 'worktreeId' | 'path' | 'repoId'> | null,
|
||||
liveWorktrees: readonly Pick<Worktree, 'worktreeId' | 'path' | 'repoId'>[]
|
||||
): string[] {
|
||||
// 'all' scope scans without scope hints — the host returns the global recency
|
||||
// list, so no scopePaths are needed (and would only narrow discovery).
|
||||
if (scope === 'all' || !activeWorktree) {
|
||||
return []
|
||||
}
|
||||
|
||||
const paths: string[] = []
|
||||
addScopePath(paths, activeWorktree.path)
|
||||
|
||||
// Workspace scope = the active worktree only. Project scope additionally
|
||||
// covers same-repo sibling worktrees so the project view stays complete.
|
||||
if (scope === 'project') {
|
||||
for (const worktree of liveWorktrees) {
|
||||
// Why: the RPC rejects (does not truncate) oversized scopePaths, and the
|
||||
// list only widens discovery — dropping tail siblings beats hard-failing.
|
||||
if (paths.length >= AI_VAULT_SCOPE_PATHS_MAX_COUNT) {
|
||||
break
|
||||
}
|
||||
if (worktree.repoId === activeWorktree.repoId) {
|
||||
addScopePath(paths, worktree.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return paths
|
||||
}
|
||||
|
||||
function addScopePath(paths: string[], pathValue: string | undefined): void {
|
||||
const trimmedPath = pathValue?.trim()
|
||||
if (!trimmedPath || !isRuntimePathAbsolute(trimmedPath)) {
|
||||
return
|
||||
}
|
||||
const comparisonPath = normalizeRuntimePathForComparison(trimmedPath)
|
||||
if (
|
||||
paths.some((existingPath) => normalizeRuntimePathForComparison(existingPath) === comparisonPath)
|
||||
) {
|
||||
return
|
||||
}
|
||||
paths.push(trimmedPath)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
filterAiVaultSessions,
|
||||
groupAiVaultSessions
|
||||
} from '../../../src/shared/ai-vault-session-filters'
|
||||
import { AI_VAULT_AGENTS } from '../../../src/shared/ai-vault-types'
|
||||
import type { AiVaultScope, AiVaultSession } from '../../../src/shared/ai-vault-types'
|
||||
import {
|
||||
buildMobileAgentHistoryCard,
|
||||
type MobileAgentHistoryCard
|
||||
} from './agent-history-session-card'
|
||||
|
||||
// Why: `data` (not `cards`) is the field name React Native's SectionList reads
|
||||
// for each section's rows — naming it anything else renders empty sections.
|
||||
export type MobileAgentHistorySection = {
|
||||
key: string
|
||||
label: string
|
||||
data: MobileAgentHistoryCard[]
|
||||
}
|
||||
|
||||
// Why: the host treats scopePaths as a WIDENING union (it adds in-scope sessions
|
||||
// beyond the recency cap, never restricts), so the Workspace/Project tabs must
|
||||
// narrow on the client like the desktop panel does. Mobile has no project-key
|
||||
// metadata, so both scoped tabs narrow by cwd path-prefix: Workspace = the active
|
||||
// worktree path, Project = the active worktree + same-repo siblings (the same set
|
||||
// deriveMobileAiVaultScopePaths produces). 'all' applies no path narrowing.
|
||||
// hideEmptySessions matches the desktop default.
|
||||
export function buildMobileAgentHistorySections(
|
||||
sessions: readonly AiVaultSession[],
|
||||
options: {
|
||||
query: string
|
||||
scope: AiVaultScope
|
||||
scopeFilterPaths: readonly string[]
|
||||
activeWorktreePath: string | null
|
||||
now: number
|
||||
}
|
||||
): MobileAgentHistorySection[] {
|
||||
// Why: narrow scoped tabs by cwd path-prefix. When the worktree list hasn't
|
||||
// loaded yet, scopeFilterPaths is empty — fall back to unnarrowed rather than
|
||||
// filtering everything out (which would flash an empty list); the memo re-runs
|
||||
// with real paths once worktree.ps resolves.
|
||||
const narrowByPath = options.scope !== 'all' && options.scopeFilterPaths.length > 0
|
||||
const filtered = filterAiVaultSessions(sessions, {
|
||||
query: options.query,
|
||||
agents: AI_VAULT_AGENTS,
|
||||
scope: narrowByPath ? 'workspace' : 'all',
|
||||
sort: 'updated',
|
||||
activeWorktreePaths: narrowByPath ? options.scopeFilterPaths : [],
|
||||
hideEmptySessions: true
|
||||
})
|
||||
|
||||
const groups = groupAiVaultSessions(filtered, 'folder')
|
||||
return groups.map((group) => ({
|
||||
key: group.key,
|
||||
label: group.label,
|
||||
data: group.sessions.map((session) =>
|
||||
buildMobileAgentHistoryCard(session, options.activeWorktreePath, options.now)
|
||||
)
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AiVaultSession } from '../../../src/shared/ai-vault-types'
|
||||
import {
|
||||
buildMobileAgentHistoryResumeActionState,
|
||||
buildMobileAgentHistoryCard,
|
||||
isSessionInActiveWorktree
|
||||
} from './agent-history-session-card'
|
||||
import { buildMobileAgentHistorySections } from './agent-history-sections'
|
||||
import { MOBILE_AI_VAULT_CAPABILITY } from './agent-history-capability'
|
||||
import { shouldShowMobileCurrentWorktreeBadge } from './agent-history-current-worktree-badge'
|
||||
|
||||
const NOW = Date.parse('2026-06-29T00:00:00.000Z')
|
||||
|
||||
function session(overrides: Partial<AiVaultSession> = {}): AiVaultSession {
|
||||
return {
|
||||
id: 'claude:1',
|
||||
executionHostId: 'local',
|
||||
agent: 'claude',
|
||||
sessionId: 'session-1',
|
||||
title: 'Implement vault filters',
|
||||
cwd: '/Users/ada/repo/app',
|
||||
branch: 'feature/vault',
|
||||
model: 'claude-sonnet-4-5',
|
||||
filePath: '/Users/ada/.claude/projects/session-1.jsonl',
|
||||
codexHome: null,
|
||||
createdAt: '2026-06-28T23:00:00.000Z',
|
||||
updatedAt: '2026-06-28T23:55:00.000Z',
|
||||
modifiedAt: '2026-06-28T23:55:00.000Z',
|
||||
messageCount: 4,
|
||||
totalTokens: 1200,
|
||||
previewMessages: [
|
||||
{ role: 'user', text: 'add the scope tabs', timestamp: null },
|
||||
{ role: 'assistant', text: 'done — tabs added', timestamp: null }
|
||||
],
|
||||
queuedMessageCount: 0,
|
||||
subagentTranscriptCount: 0,
|
||||
resumeCommand: '',
|
||||
subagent: null,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('buildMobileAgentHistoryCard', () => {
|
||||
it('maps the card view-model with time-ago, label, and last message', () => {
|
||||
const card = buildMobileAgentHistoryCard(session(), '/Users/ada/repo/app', NOW)
|
||||
expect(card.agentLabel).toBe('Claude')
|
||||
expect(card.timeAgo).toBe('5m')
|
||||
expect(card.lastMessage).toBe('done — tabs added')
|
||||
expect(card.messageCount).toBe(4)
|
||||
expect(card.isCurrentWorktree).toBe(true)
|
||||
})
|
||||
|
||||
it('omits the current-worktree badge when cwd is outside the active worktree', () => {
|
||||
const card = buildMobileAgentHistoryCard(session(), '/Users/ada/other', NOW)
|
||||
expect(card.isCurrentWorktree).toBe(false)
|
||||
})
|
||||
|
||||
it('falls back to a title for empty session titles', () => {
|
||||
const card = buildMobileAgentHistoryCard(session({ title: '' }), null, NOW)
|
||||
expect(card.title).toBe('Untitled session')
|
||||
expect(card.isCurrentWorktree).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildMobileAgentHistoryResumeActionState', () => {
|
||||
it('disables all resume buttons while one session is launching', () => {
|
||||
const state = buildMobileAgentHistoryResumeActionState(
|
||||
[session({ id: 'claude:1' }), session({ id: 'codex:2', agent: 'codex' })],
|
||||
'codex:2'
|
||||
)
|
||||
expect(state.get('claude:1')).toEqual({ disabled: true, loading: false })
|
||||
expect(state.get('codex:2')).toEqual({ disabled: true, loading: true })
|
||||
})
|
||||
|
||||
it('keeps resume buttons enabled when no launch is in flight', () => {
|
||||
const state = buildMobileAgentHistoryResumeActionState([session({ id: 'claude:1' })], null)
|
||||
expect(state.get('claude:1')).toEqual({ disabled: false, loading: false })
|
||||
})
|
||||
})
|
||||
|
||||
describe('isSessionInActiveWorktree', () => {
|
||||
it('matches a nested cwd inside the worktree path', () => {
|
||||
expect(
|
||||
isSessionInActiveWorktree({ cwd: '/Users/ada/repo/app/src' }, '/Users/ada/repo/app')
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('is false without a cwd or active path', () => {
|
||||
expect(isSessionInActiveWorktree({ cwd: null }, '/Users/ada/repo/app')).toBe(false)
|
||||
expect(isSessionInActiveWorktree({ cwd: '/Users/ada/repo/app' }, null)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildMobileAgentHistorySections', () => {
|
||||
it('hides empty sessions by default and groups remaining by folder', () => {
|
||||
const sections = buildMobileAgentHistorySections(
|
||||
[
|
||||
session(),
|
||||
// Truly empty: no turns, no preview turns, no recoverable signals —
|
||||
// preview turns alone now count as resumable content and stay visible.
|
||||
session({ id: 'claude:empty', messageCount: 0, previewMessages: [] })
|
||||
],
|
||||
{
|
||||
query: '',
|
||||
scope: 'all',
|
||||
scopeFilterPaths: [],
|
||||
activeWorktreePath: '/Users/ada/repo/app',
|
||||
now: NOW
|
||||
}
|
||||
)
|
||||
const allCards = sections.flatMap((s) => s.data)
|
||||
expect(allCards.map((card) => card.id)).toEqual(['claude:1'])
|
||||
})
|
||||
|
||||
it('applies the search query across sections', () => {
|
||||
const sections = buildMobileAgentHistorySections(
|
||||
[session(), session({ id: 'claude:2', title: 'Repair terminal tabs' })],
|
||||
{ query: 'repair', scope: 'all', scopeFilterPaths: [], activeWorktreePath: null, now: NOW }
|
||||
)
|
||||
expect(sections.flatMap((s) => s.data).map((card) => card.id)).toEqual(['claude:2'])
|
||||
})
|
||||
|
||||
// Why: the host union widens, so the screen must narrow scoped tabs by cwd
|
||||
// path-prefix on the client — otherwise Workspace/Project show the same set as All.
|
||||
it('narrows Workspace scope to sessions whose cwd is inside the active worktree path', () => {
|
||||
const sessions = [
|
||||
session({ id: 'claude:in', cwd: '/Users/ada/repo/app/src' }),
|
||||
session({ id: 'claude:out', cwd: '/Users/ada/other-repo' })
|
||||
]
|
||||
const sections = buildMobileAgentHistorySections(sessions, {
|
||||
query: '',
|
||||
scope: 'workspace',
|
||||
scopeFilterPaths: ['/Users/ada/repo/app'],
|
||||
activeWorktreePath: '/Users/ada/repo/app',
|
||||
now: NOW
|
||||
})
|
||||
expect(sections.flatMap((s) => s.data).map((card) => card.id)).toEqual(['claude:in'])
|
||||
})
|
||||
|
||||
it('narrows Project scope to the active worktree plus same-repo sibling paths', () => {
|
||||
const sessions = [
|
||||
session({ id: 'claude:active', cwd: '/Users/ada/repo/app' }),
|
||||
session({ id: 'claude:sibling', cwd: '/Users/ada/repo/app-feature/lib' }),
|
||||
session({ id: 'claude:foreign', cwd: '/Users/ada/unrelated' })
|
||||
]
|
||||
const sections = buildMobileAgentHistorySections(sessions, {
|
||||
query: '',
|
||||
scope: 'project',
|
||||
scopeFilterPaths: ['/Users/ada/repo/app', '/Users/ada/repo/app-feature'],
|
||||
activeWorktreePath: '/Users/ada/repo/app',
|
||||
now: NOW
|
||||
})
|
||||
expect(
|
||||
sections
|
||||
.flatMap((s) => s.data)
|
||||
.map((card) => card.id)
|
||||
.sort()
|
||||
).toEqual(['claude:active', 'claude:sibling'])
|
||||
})
|
||||
|
||||
// Why: before worktree.ps resolves, scopeFilterPaths is empty on a scoped tab;
|
||||
// the builder must fall back to unnarrowed rather than filter to an empty list.
|
||||
it('falls back to unnarrowed when a scoped tab has no scopeFilterPaths yet', () => {
|
||||
const sessions = [
|
||||
session({ id: 'claude:a', cwd: '/Users/ada/repo/app' }),
|
||||
session({ id: 'claude:b', cwd: '/Users/ada/elsewhere' })
|
||||
]
|
||||
const sections = buildMobileAgentHistorySections(sessions, {
|
||||
query: '',
|
||||
scope: 'workspace',
|
||||
scopeFilterPaths: [],
|
||||
activeWorktreePath: null,
|
||||
now: NOW
|
||||
})
|
||||
expect(
|
||||
sections
|
||||
.flatMap((s) => s.data)
|
||||
.map((card) => card.id)
|
||||
.sort()
|
||||
).toEqual(['claude:a', 'claude:b'])
|
||||
})
|
||||
|
||||
it('shows all sessions under All scope regardless of path', () => {
|
||||
const sessions = [
|
||||
session({ id: 'claude:a', cwd: '/Users/ada/repo/app' }),
|
||||
session({ id: 'claude:b', cwd: '/Users/ada/elsewhere' })
|
||||
]
|
||||
const sections = buildMobileAgentHistorySections(sessions, {
|
||||
query: '',
|
||||
scope: 'all',
|
||||
scopeFilterPaths: [],
|
||||
activeWorktreePath: '/Users/ada/repo/app',
|
||||
now: NOW
|
||||
})
|
||||
expect(
|
||||
sections
|
||||
.flatMap((s) => s.data)
|
||||
.map((card) => card.id)
|
||||
.sort()
|
||||
).toEqual(['claude:a', 'claude:b'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('MOBILE_AI_VAULT_CAPABILITY', () => {
|
||||
it('stays in lockstep with the host AI_VAULT_RUNTIME_CAPABILITY string', () => {
|
||||
// Why: mobile's protocol-version copy lacks the host constant; this literal
|
||||
// must match src/shared/protocol-version.ts AI_VAULT_RUNTIME_CAPABILITY.
|
||||
expect(MOBILE_AI_VAULT_CAPABILITY).toBe('aiVault.v1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldShowMobileCurrentWorktreeBadge', () => {
|
||||
it('hides the repeated current-worktree badge in Workspace scope', () => {
|
||||
expect(shouldShowMobileCurrentWorktreeBadge('workspace')).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the badge in mixed Project and All scopes', () => {
|
||||
expect(shouldShowMobileCurrentWorktreeBadge('project')).toBe(true)
|
||||
expect(shouldShowMobileCurrentWorktreeBadge('all')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,68 @@
|
||||
import { isPathInsideOrEqual } from '../../../src/shared/cross-platform-path'
|
||||
import { latestSessionConversationTurn } from '../../../src/shared/ai-vault-session-display'
|
||||
import { aiVaultAgentLabel } from '../../../src/shared/ai-vault-types'
|
||||
import type { AiVaultSession } from '../../../src/shared/ai-vault-types'
|
||||
import { formatTimeAgo } from '../worktree/agent-row-display'
|
||||
|
||||
export type MobileAgentHistoryCard = {
|
||||
id: string
|
||||
agent: AiVaultSession['agent']
|
||||
agentLabel: string
|
||||
title: string
|
||||
lastMessage: string
|
||||
messageCount: number
|
||||
timeAgo: string
|
||||
isCurrentWorktree: boolean
|
||||
}
|
||||
|
||||
export type MobileAgentHistoryResumeActionState = {
|
||||
disabled: boolean
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
// Why: a session belongs to the active worktree when its recorded cwd is inside
|
||||
// (or equal to) the worktree path; session rows carry no host id, so this is a
|
||||
// path-prefix check (same logic the desktop badge uses).
|
||||
export function isSessionInActiveWorktree(
|
||||
session: Pick<AiVaultSession, 'cwd'>,
|
||||
activeWorktreePath: string | null
|
||||
): boolean {
|
||||
if (!activeWorktreePath || !session.cwd) {
|
||||
return false
|
||||
}
|
||||
return isPathInsideOrEqual(activeWorktreePath, session.cwd)
|
||||
}
|
||||
|
||||
export function buildMobileAgentHistoryCard(
|
||||
session: AiVaultSession,
|
||||
activeWorktreePath: string | null,
|
||||
now: number
|
||||
): MobileAgentHistoryCard {
|
||||
const latestTurn = latestSessionConversationTurn(session)
|
||||
const updatedAtMs = Date.parse(session.updatedAt ?? session.modifiedAt)
|
||||
return {
|
||||
id: session.id,
|
||||
agent: session.agent,
|
||||
agentLabel: aiVaultAgentLabel(session.agent),
|
||||
title: session.title || 'Untitled session',
|
||||
lastMessage: latestTurn?.text.trim() ?? '',
|
||||
messageCount: session.messageCount,
|
||||
timeAgo: Number.isFinite(updatedAtMs) ? formatTimeAgo(updatedAtMs, now) : '',
|
||||
isCurrentWorktree: isSessionInActiveWorktree(session, activeWorktreePath)
|
||||
}
|
||||
}
|
||||
|
||||
export function buildMobileAgentHistoryResumeActionState(
|
||||
sessions: readonly Pick<AiVaultSession, 'id'>[],
|
||||
resumingSessionId: string | null
|
||||
): ReadonlyMap<string, MobileAgentHistoryResumeActionState> {
|
||||
return new Map(
|
||||
sessions.map((session) => [
|
||||
session.id,
|
||||
{
|
||||
disabled: resumingSessionId !== null,
|
||||
loading: resumingSessionId === session.id
|
||||
}
|
||||
])
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AiVaultSession } from '../../../src/shared/ai-vault-types'
|
||||
import type { Worktree } from '../worktree/workspace-list-types'
|
||||
import {
|
||||
canResumeInMobileSessionWorktree,
|
||||
resolveMobileAgentHistorySessionWorktree
|
||||
} from './agent-history-session-worktree'
|
||||
|
||||
function session(cwd: string | null): Pick<AiVaultSession, 'cwd'> {
|
||||
return { cwd }
|
||||
}
|
||||
|
||||
function worktree(overrides: Partial<Worktree> & { worktreeId: string; path: string }): Worktree {
|
||||
return {
|
||||
repoId: 'repo-1',
|
||||
repo: 'orca',
|
||||
branch: 'main',
|
||||
displayName: overrides.worktreeId,
|
||||
liveTerminalCount: 0,
|
||||
hasAttachedPty: false,
|
||||
preview: '',
|
||||
unread: false,
|
||||
isPinned: false,
|
||||
linkedPR: null,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('resolveMobileAgentHistorySessionWorktree', () => {
|
||||
it('matches the active worktree when session cwd is inside it', () => {
|
||||
const resolved = resolveMobileAgentHistorySessionWorktree({
|
||||
session: session('/Users/ada/repo/app/src'),
|
||||
worktrees: [worktree({ worktreeId: 'wt-1', path: '/Users/ada/repo/app' })],
|
||||
activeWorktreeId: 'wt-1'
|
||||
})
|
||||
expect(resolved).toMatchObject({ status: 'current', worktreeId: 'wt-1' })
|
||||
})
|
||||
|
||||
it('prefers the longest active path match', () => {
|
||||
const resolved = resolveMobileAgentHistorySessionWorktree({
|
||||
session: session('/Users/ada/repo/app/packages/mobile'),
|
||||
worktrees: [
|
||||
worktree({ worktreeId: 'root', path: '/Users/ada/repo' }),
|
||||
worktree({ worktreeId: 'app', path: '/Users/ada/repo/app' })
|
||||
],
|
||||
activeWorktreeId: 'root'
|
||||
})
|
||||
expect(resolved).toMatchObject({ status: 'active', worktreeId: 'app' })
|
||||
})
|
||||
|
||||
it('marks archived worktrees as unavailable for resume', () => {
|
||||
const resolved = resolveMobileAgentHistorySessionWorktree({
|
||||
session: session('/Users/ada/repo/app'),
|
||||
worktrees: [
|
||||
worktree({ worktreeId: 'archived', path: '/Users/ada/repo/app', isArchived: true })
|
||||
],
|
||||
activeWorktreeId: 'other'
|
||||
})
|
||||
expect(resolved).toMatchObject({ status: 'archived', worktreeId: 'archived' })
|
||||
expect(canResumeInMobileSessionWorktree(resolved)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns null when the session cwd has no active worktree match', () => {
|
||||
expect(
|
||||
resolveMobileAgentHistorySessionWorktree({
|
||||
session: session('/Users/ada/missing'),
|
||||
worktrees: [worktree({ worktreeId: 'wt-1', path: '/Users/ada/repo/app' })],
|
||||
activeWorktreeId: 'wt-1'
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('matches WSL UNC worktree paths against Linux transcript paths', () => {
|
||||
const resolved = resolveMobileAgentHistorySessionWorktree({
|
||||
session: session('/home/ada/repo/app'),
|
||||
worktrees: [
|
||||
worktree({
|
||||
worktreeId: 'wsl',
|
||||
path: '\\\\wsl.localhost\\Ubuntu\\home\\ada\\repo'
|
||||
})
|
||||
],
|
||||
activeWorktreeId: 'wsl'
|
||||
})
|
||||
expect(resolved).toMatchObject({ status: 'current', worktreeId: 'wsl' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
import { isPathInsideOrEqual, isRuntimePathAbsolute } from '../../../src/shared/cross-platform-path'
|
||||
import { parseWslUncPath } from '../../../src/shared/wsl-paths'
|
||||
import { splitWorktreeIdForFilesystem } from '../../../src/shared/worktree-id'
|
||||
import type { AiVaultSession } from '../../../src/shared/ai-vault-types'
|
||||
import type { Worktree } from '../worktree/workspace-list-types'
|
||||
|
||||
export type MobileAgentHistorySessionWorktreeStatus = 'current' | 'active' | 'archived'
|
||||
|
||||
export type MobileAgentHistorySessionWorktreeInfo = {
|
||||
status: MobileAgentHistorySessionWorktreeStatus
|
||||
worktreeId: string
|
||||
path: string
|
||||
}
|
||||
|
||||
type WorktreeCandidate = {
|
||||
worktree: WorktreeWithPriorIds
|
||||
path: string
|
||||
source: 'current-path' | 'prior-path'
|
||||
}
|
||||
|
||||
type WorktreeWithPriorIds = Worktree & {
|
||||
priorWorktreeIds?: readonly string[]
|
||||
}
|
||||
|
||||
export function resolveMobileAgentHistorySessionWorktree(args: {
|
||||
session: Pick<AiVaultSession, 'cwd'>
|
||||
worktrees: readonly Worktree[]
|
||||
activeWorktreeId: string | null
|
||||
}): MobileAgentHistorySessionWorktreeInfo | null {
|
||||
if (!args.session.cwd) {
|
||||
return null
|
||||
}
|
||||
const sessionCwd = args.session.cwd
|
||||
|
||||
const candidates = buildMobileWorktreeCandidates(args.worktrees)
|
||||
.filter((candidate) => isSessionInWorktreePath(candidate.path, sessionCwd))
|
||||
.sort(compareWorktreeCandidates)
|
||||
const best = candidates[0]
|
||||
if (!best) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
status:
|
||||
best.worktree.worktreeId === args.activeWorktreeId
|
||||
? 'current'
|
||||
: best.worktree.isArchived
|
||||
? 'archived'
|
||||
: 'active',
|
||||
worktreeId: best.worktree.worktreeId,
|
||||
path: best.path
|
||||
}
|
||||
}
|
||||
|
||||
export function canResumeInMobileSessionWorktree(
|
||||
worktreeInfo: MobileAgentHistorySessionWorktreeInfo | null
|
||||
): boolean {
|
||||
return Boolean(worktreeInfo && worktreeInfo.status !== 'archived')
|
||||
}
|
||||
|
||||
function buildMobileWorktreeCandidates(worktrees: readonly Worktree[]): WorktreeCandidate[] {
|
||||
const candidates: WorktreeCandidate[] = []
|
||||
for (const worktree of worktrees as readonly WorktreeWithPriorIds[]) {
|
||||
if (hasUsablePath(worktree.path)) {
|
||||
candidates.push({ worktree, path: worktree.path, source: 'current-path' })
|
||||
}
|
||||
for (const priorWorktreeId of worktree.priorWorktreeIds ?? []) {
|
||||
const parsed = splitWorktreeIdForFilesystem(priorWorktreeId)
|
||||
if (!parsed || parsed.repoId !== worktree.repoId || !hasUsablePath(parsed.worktreePath)) {
|
||||
continue
|
||||
}
|
||||
candidates.push({ worktree, path: parsed.worktreePath, source: 'prior-path' })
|
||||
}
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
function hasUsablePath(pathValue: string): boolean {
|
||||
return Boolean(pathValue.trim() && isRuntimePathAbsolute(pathValue))
|
||||
}
|
||||
|
||||
function isSessionInWorktreePath(worktreePath: string, sessionCwd: string): boolean {
|
||||
if (isPathInsideOrEqual(worktreePath, sessionCwd)) {
|
||||
return true
|
||||
}
|
||||
const wslPath = parseWslUncPath(worktreePath)
|
||||
return wslPath ? isPathInsideOrEqual(wslPath.linuxPath, sessionCwd) : false
|
||||
}
|
||||
|
||||
function compareWorktreeCandidates(left: WorktreeCandidate, right: WorktreeCandidate): number {
|
||||
const lengthDifference = normalizedPathLength(right.path) - normalizedPathLength(left.path)
|
||||
if (lengthDifference !== 0) {
|
||||
return lengthDifference
|
||||
}
|
||||
if (left.source === right.source) {
|
||||
return 0
|
||||
}
|
||||
return left.source === 'current-path' ? -1 : 1
|
||||
}
|
||||
|
||||
function normalizedPathLength(pathValue: string): number {
|
||||
return pathValue.replace(/\\/g, '/').replace(/\/+$/g, '').toLowerCase().length
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import { StyleSheet } from 'react-native'
|
||||
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
|
||||
|
||||
export const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bgBase
|
||||
},
|
||||
header: {
|
||||
backgroundColor: colors.bgBase
|
||||
},
|
||||
topBar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.sm,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.borderSubtle
|
||||
},
|
||||
backButton: {
|
||||
padding: spacing.xs,
|
||||
borderRadius: radii.button
|
||||
},
|
||||
backButtonPressed: {
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
titleBlock: {
|
||||
flex: 1,
|
||||
marginHorizontal: spacing.sm
|
||||
},
|
||||
title: {
|
||||
color: colors.textPrimary,
|
||||
fontSize: typography.titleSize,
|
||||
fontWeight: '600'
|
||||
},
|
||||
meta: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.metaSize
|
||||
},
|
||||
refreshButton: {
|
||||
padding: spacing.xs,
|
||||
borderRadius: radii.button
|
||||
},
|
||||
refreshButtonPressed: {
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
scopeTabs: {
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingTop: spacing.sm,
|
||||
gap: spacing.xs
|
||||
},
|
||||
scopeTab: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radii.button,
|
||||
backgroundColor: colors.bgPanel
|
||||
},
|
||||
scopeTabActive: {
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
scopeTabText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.bodySize
|
||||
},
|
||||
scopeTabTextActive: {
|
||||
color: colors.textPrimary,
|
||||
fontWeight: '600'
|
||||
},
|
||||
searchRow: {
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingTop: spacing.sm
|
||||
},
|
||||
searchInput: {
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderRadius: radii.input,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
color: colors.textPrimary,
|
||||
fontSize: typography.bodySize
|
||||
},
|
||||
list: {
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingTop: spacing.sm,
|
||||
paddingBottom: spacing.xl
|
||||
},
|
||||
groupHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.sm,
|
||||
gap: spacing.sm
|
||||
},
|
||||
groupHeaderText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.metaSize,
|
||||
fontWeight: '600',
|
||||
textTransform: 'uppercase'
|
||||
},
|
||||
groupHeaderCount: {
|
||||
color: colors.textMuted,
|
||||
fontSize: typography.metaSize
|
||||
},
|
||||
card: {
|
||||
backgroundColor: colors.bgPanel,
|
||||
borderRadius: radii.card,
|
||||
padding: spacing.md,
|
||||
marginBottom: spacing.sm
|
||||
},
|
||||
cardPressed: {
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
cardTopRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm
|
||||
},
|
||||
cardTitle: {
|
||||
flex: 1,
|
||||
color: colors.textPrimary,
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '600'
|
||||
},
|
||||
cardTimeAgo: {
|
||||
color: colors.textMuted,
|
||||
fontSize: typography.metaSize
|
||||
},
|
||||
cardLastMessage: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.metaSize,
|
||||
marginTop: spacing.xs
|
||||
},
|
||||
cardMetaRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
gap: spacing.sm,
|
||||
marginTop: spacing.xs
|
||||
},
|
||||
cardMetaText: {
|
||||
color: colors.textMuted,
|
||||
fontSize: typography.metaSize
|
||||
},
|
||||
currentBadge: {
|
||||
backgroundColor: colors.bgRaised,
|
||||
borderRadius: radii.button,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 2
|
||||
},
|
||||
currentBadgeText: {
|
||||
color: colors.accentBlue,
|
||||
fontSize: typography.metaSize,
|
||||
fontWeight: '600'
|
||||
},
|
||||
resumeButton: {
|
||||
minHeight: 28,
|
||||
minWidth: 28,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginLeft: 'auto',
|
||||
paddingHorizontal: spacing.xs,
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: radii.button
|
||||
},
|
||||
resumeButtonPressed: {
|
||||
opacity: 0.78
|
||||
},
|
||||
resumeButtonDisabled: {
|
||||
opacity: 0.45
|
||||
},
|
||||
preview: {
|
||||
marginTop: spacing.sm,
|
||||
paddingTop: spacing.sm,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.borderSubtle,
|
||||
gap: spacing.sm
|
||||
},
|
||||
previewTurn: {
|
||||
gap: 2
|
||||
},
|
||||
previewRole: {
|
||||
color: colors.textMuted,
|
||||
fontSize: typography.metaSize,
|
||||
fontWeight: '600',
|
||||
textTransform: 'uppercase'
|
||||
},
|
||||
previewText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.metaSize
|
||||
},
|
||||
noticeBanner: {
|
||||
marginHorizontal: spacing.md,
|
||||
marginTop: spacing.sm,
|
||||
padding: spacing.sm,
|
||||
borderRadius: radii.input,
|
||||
backgroundColor: colors.bgPanel
|
||||
},
|
||||
noticeText: {
|
||||
color: colors.statusAmber,
|
||||
fontSize: typography.metaSize
|
||||
},
|
||||
resumeBanner: {
|
||||
marginHorizontal: spacing.md,
|
||||
marginTop: spacing.sm,
|
||||
padding: spacing.sm,
|
||||
borderRadius: radii.input,
|
||||
backgroundColor: colors.bgPanel
|
||||
},
|
||||
resumeBannerText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.metaSize
|
||||
},
|
||||
state: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: spacing.xl,
|
||||
gap: spacing.sm
|
||||
},
|
||||
stateTitle: {
|
||||
color: colors.textPrimary,
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '600'
|
||||
},
|
||||
stateText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.metaSize,
|
||||
textAlign: 'center'
|
||||
},
|
||||
retryButton: {
|
||||
marginTop: spacing.sm,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radii.button,
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
retryText: {
|
||||
color: colors.textPrimary,
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '600'
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,192 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useHostClient, useForceReconnect } from '../transport/client-context'
|
||||
import type { RpcSuccess } from '../transport/types'
|
||||
import type {
|
||||
AiVaultListResult,
|
||||
AiVaultScanIssue,
|
||||
AiVaultScope,
|
||||
AiVaultSession
|
||||
} from '../../../src/shared/ai-vault-types'
|
||||
import type { Worktree } from '../worktree/workspace-list-types'
|
||||
import { deriveMobileAiVaultScopePaths } from './agent-history-scope-paths'
|
||||
import { MOBILE_AI_VAULT_CAPABILITY } from './agent-history-capability'
|
||||
|
||||
export { MOBILE_AI_VAULT_CAPABILITY }
|
||||
|
||||
// Why: mirror the desktop renderer's SESSION_LIMIT=500. limit caps only the
|
||||
// global recency list; the in-scope Claude union is capped separately host-side,
|
||||
// so the response can still be large — hence virtualization + lazy preview.
|
||||
const MOBILE_AI_VAULT_SESSION_LIMIT = 500
|
||||
|
||||
export type AgentHistoryScreenState =
|
||||
| { kind: 'loading' }
|
||||
| { kind: 'unsupported' }
|
||||
| { kind: 'error'; message: string }
|
||||
| { kind: 'ready'; sessions: AiVaultSession[]; issues: AiVaultScanIssue[] }
|
||||
|
||||
type StatusWithCapabilities = { capabilities?: string[] }
|
||||
|
||||
export type MobileAgentHistoryStateParams = {
|
||||
hostId: string
|
||||
worktreeId: string
|
||||
worktrees: readonly Worktree[]
|
||||
// Why: distinguishes "worktree list not fetched yet" from "fetched, but this
|
||||
// worktree isn't in it" so a scoped tab holds loading only for the former.
|
||||
worktreesLoaded: boolean
|
||||
}
|
||||
|
||||
export function useMobileAgentHistoryState(params: MobileAgentHistoryStateParams) {
|
||||
const { hostId, worktreeId, worktrees, worktreesLoaded } = params
|
||||
const { client, state: connState } = useHostClient(hostId)
|
||||
const forceReconnect = useForceReconnect()
|
||||
const [scope, setScope] = useState<AiVaultScope>('workspace')
|
||||
const [screenState, setScreenState] = useState<AgentHistoryScreenState>({ kind: 'loading' })
|
||||
const [hostStatusResult, setHostStatusResult] = useState<unknown>(null)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const generationRef = useRef(0)
|
||||
const mountedRef = useRef(true)
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true
|
||||
return () => {
|
||||
mountedRef.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const activeWorktree = useMemo(
|
||||
() => worktrees.find((worktree) => worktree.worktreeId === worktreeId) ?? null,
|
||||
[worktrees, worktreeId]
|
||||
)
|
||||
const activeWorktreePath = activeWorktree?.path ?? null
|
||||
// Why: the host union only widens, so the screen narrows by these cwd
|
||||
// path-prefixes for the current scope (empty for 'all'). Same derivation the
|
||||
// RPC scopePaths use, reused client-side for the actual narrowing.
|
||||
const scopeFilterPaths = useMemo(
|
||||
() => deriveMobileAiVaultScopePaths(scope, activeWorktree, worktrees),
|
||||
[scope, activeWorktree, worktrees]
|
||||
)
|
||||
|
||||
const loadSessions = useCallback(
|
||||
async (options: { scope: AiVaultScope; force: boolean }): Promise<void> => {
|
||||
const generation = generationRef.current + 1
|
||||
generationRef.current = generation
|
||||
const isCurrent = () => mountedRef.current && generationRef.current === generation
|
||||
|
||||
if (!client || connState !== 'connected') {
|
||||
if (isCurrent()) {
|
||||
// Why: keep the stale list visible through transient reconnects
|
||||
// (connState flips re-run the load effect) instead of tearing it
|
||||
// down to a full-screen error, matching the host list screen.
|
||||
setScreenState((prev) =>
|
||||
prev.kind === 'ready' ? prev : { kind: 'error', message: 'Waiting for host…' }
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
setScreenState((prev) => (prev.kind === 'ready' ? prev : { kind: 'loading' }))
|
||||
try {
|
||||
// Gate on the capability so older hosts lacking the method are detected
|
||||
// and we never call a missing RPC.
|
||||
const statusResponse = await client.sendRequest('status.get')
|
||||
if (!isCurrent()) {
|
||||
return
|
||||
}
|
||||
if (!statusResponse.ok) {
|
||||
throw new Error(statusResponse.error?.message || 'Unable to reach host')
|
||||
}
|
||||
const status = (statusResponse as RpcSuccess).result as StatusWithCapabilities
|
||||
setHostStatusResult(status)
|
||||
if (!status.capabilities?.includes(MOBILE_AI_VAULT_CAPABILITY)) {
|
||||
setScreenState({ kind: 'unsupported' })
|
||||
return
|
||||
}
|
||||
|
||||
// Why: a scoped tab needs the active worktree's path to narrow. Until the
|
||||
// worktree list has loaded, hold loading rather than firing an unscoped
|
||||
// fetch that would briefly show unrelated host history. Once loaded we
|
||||
// proceed even if the worktree isn't found, to avoid a stuck spinner.
|
||||
if (options.scope !== 'all' && !activeWorktree && !worktreesLoaded) {
|
||||
if (isCurrent()) {
|
||||
setScreenState((prev) => (prev.kind === 'ready' ? prev : { kind: 'loading' }))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const scopePaths = deriveMobileAiVaultScopePaths(options.scope, activeWorktree, worktrees)
|
||||
const response = await client.sendRequest('aiVault.listSessions', {
|
||||
limit: MOBILE_AI_VAULT_SESSION_LIMIT,
|
||||
force: options.force,
|
||||
scopePaths
|
||||
})
|
||||
if (!isCurrent()) {
|
||||
return
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(response.error?.message || 'Unable to load agent sessions')
|
||||
}
|
||||
const result = (response as RpcSuccess).result as AiVaultListResult
|
||||
setScreenState({ kind: 'ready', sessions: result.sessions, issues: result.issues })
|
||||
} catch (err) {
|
||||
if (!isCurrent()) {
|
||||
return
|
||||
}
|
||||
const message = err instanceof Error ? err.message : 'Unable to load agent sessions'
|
||||
setHostStatusResult(null)
|
||||
setScreenState({ kind: 'error', message })
|
||||
}
|
||||
},
|
||||
[activeWorktree, client, connState, worktrees, worktreesLoaded]
|
||||
)
|
||||
|
||||
// Initial + reconnect load. Why: scope switches reuse the host's 15s cache
|
||||
// (force:false) so a tab tap is cheap; only an explicit refresh bypasses it.
|
||||
useEffect(() => {
|
||||
void loadSessions({ scope, force: false })
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- scope handled by onSelectScope
|
||||
}, [loadSessions])
|
||||
|
||||
const onSelectScope = useCallback(
|
||||
(nextScope: AiVaultScope) => {
|
||||
setScope(nextScope)
|
||||
void loadSessions({ scope: nextScope, force: false })
|
||||
},
|
||||
[loadSessions]
|
||||
)
|
||||
|
||||
const onRefresh = useCallback(async () => {
|
||||
setRefreshing(true)
|
||||
try {
|
||||
// Why: pull-to-refresh bypasses the host TTL (force:true) and joins any
|
||||
// active inflight scan; otherwise a refresh within 15s shows no change.
|
||||
await loadSessions({ scope, force: true })
|
||||
} finally {
|
||||
if (mountedRef.current) {
|
||||
setRefreshing(false)
|
||||
}
|
||||
}
|
||||
}, [loadSessions, scope])
|
||||
|
||||
const retry = useCallback(() => {
|
||||
if (connState !== 'connected' && hostId) {
|
||||
void forceReconnect(hostId)
|
||||
return
|
||||
}
|
||||
void loadSessions({ scope, force: false })
|
||||
}, [connState, forceReconnect, hostId, loadSessions, scope])
|
||||
|
||||
return {
|
||||
connState,
|
||||
scope,
|
||||
screenState,
|
||||
refreshing,
|
||||
hostStatusResult,
|
||||
activeWorktreePath,
|
||||
scopeFilterPaths,
|
||||
onSelectScope,
|
||||
onRefresh,
|
||||
retry
|
||||
}
|
||||
}
|
||||
|
||||
export type MobileAgentHistoryState = ReturnType<typeof useMobileAgentHistoryState>
|
||||
@@ -0,0 +1,50 @@
|
||||
import { GitBranch } from 'lucide-react-native'
|
||||
import type { ActionSheetAction } from '../components/ActionSheetModal'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import { MOBILE_AI_VAULT_CAPABILITY } from './agent-history-capability'
|
||||
import { MobileAgentSessionHistoryIcon } from './MobileAgentSessionHistoryIcon'
|
||||
|
||||
type Args = {
|
||||
hostId: string
|
||||
worktreeId: string
|
||||
worktreeName: string
|
||||
hostCapabilities: readonly string[]
|
||||
navigate: (target: string) => void
|
||||
onDone: () => void
|
||||
}
|
||||
|
||||
// Why: builds the per-worktree "navigate to a screen" action-sheet actions
|
||||
// (Source Control + Agent Session History). The Agent Session History action is
|
||||
// included only on hosts advertising the aiVault.v1 capability, so we never
|
||||
// navigate to a screen that would call a missing RPC method. Extracted from the
|
||||
// host index action sheet to keep that file under its max-lines budget.
|
||||
export function buildWorktreeNavigationActions(args: Args): ActionSheetAction[] {
|
||||
const actions: ActionSheetAction[] = [
|
||||
{
|
||||
label: 'Source Control',
|
||||
icon: GitBranch,
|
||||
onPress: () => {
|
||||
const params = new URLSearchParams({ name: args.worktreeName, origin: 'host' })
|
||||
args.navigate(
|
||||
`/h/${args.hostId}/source-control/${encodeURIComponent(args.worktreeId)}?${params.toString()}`
|
||||
)
|
||||
args.onDone()
|
||||
}
|
||||
}
|
||||
]
|
||||
if (args.hostCapabilities.includes(MOBILE_AI_VAULT_CAPABILITY)) {
|
||||
actions.push({
|
||||
label: 'Agent Session History',
|
||||
renderIcon: () =>
|
||||
MobileAgentSessionHistoryIcon({ size: 16, color: colors.textSecondary, strokeWidth: 2 }),
|
||||
onPress: () => {
|
||||
const params = new URLSearchParams({ name: args.worktreeName })
|
||||
args.navigate(
|
||||
`/h/${args.hostId}/agent-history/${encodeURIComponent(args.worktreeId)}?${params.toString()}`
|
||||
)
|
||||
args.onDone()
|
||||
}
|
||||
})
|
||||
}
|
||||
return actions
|
||||
}
|
||||
@@ -21,9 +21,9 @@ export {
|
||||
hasRenderableUsage
|
||||
} from './account-usage-state'
|
||||
|
||||
// Why: matches desktop StatusBar convention — bars show percent remaining
|
||||
// (so a fresh account renders full, a depleted one renders empty), not
|
||||
// percent used. Color thresholds invert accordingly.
|
||||
// Why: matches desktop StatusBar — bars show percent used (consumption), same
|
||||
// as Claude/Codex harness meters. Fresh account is empty/green; depleted is
|
||||
// full/red.
|
||||
export function UsageBar({
|
||||
label,
|
||||
usedPercent,
|
||||
@@ -35,13 +35,15 @@ export function UsageBar({
|
||||
unavailable: boolean
|
||||
loading?: boolean
|
||||
}) {
|
||||
const remaining = usedPercent == null ? null : Math.max(0, Math.min(100, 100 - usedPercent))
|
||||
// Why: round then clamp so bar width, color, and label share one value (desktop parity).
|
||||
const used = usedPercent == null ? null : Math.max(0, Math.min(100, Math.round(usedPercent)))
|
||||
// Why: same consumption bands as desktop barColor (green <60, amber <80, red ≥80).
|
||||
const barColor =
|
||||
remaining == null
|
||||
used == null
|
||||
? colors.textMuted
|
||||
: remaining <= 10
|
||||
: used >= 80
|
||||
? colors.statusRed
|
||||
: remaining <= 30
|
||||
: used >= 60
|
||||
? colors.statusAmber
|
||||
: colors.statusGreen
|
||||
return (
|
||||
@@ -52,7 +54,7 @@ export function UsageBar({
|
||||
style={[
|
||||
styles.usageFill,
|
||||
{
|
||||
width: `${remaining ?? 0}%`,
|
||||
width: `${used ?? 0}%`,
|
||||
backgroundColor: unavailable ? colors.textMuted : barColor
|
||||
}
|
||||
]}
|
||||
@@ -61,9 +63,7 @@ export function UsageBar({
|
||||
{loading ? (
|
||||
<ActivityIndicator size="small" color={colors.textSecondary} style={styles.usageSpinner} />
|
||||
) : (
|
||||
<Text style={styles.usageValue}>
|
||||
{unavailable || remaining == null ? '—' : `${Math.round(remaining)}%`}
|
||||
</Text>
|
||||
<Text style={styles.usageValue}>{unavailable || used == null ? '—' : `${used}%`}</Text>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
|
||||
@@ -7,7 +7,8 @@ import {
|
||||
useWindowDimensions,
|
||||
ScrollView,
|
||||
Keyboard,
|
||||
BackHandler
|
||||
BackHandler,
|
||||
Modal
|
||||
} from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler'
|
||||
@@ -264,96 +265,105 @@ function MountedBottomDrawer({
|
||||
return { opacity: progress.value * dragFade }
|
||||
})
|
||||
|
||||
// Why: rendering through a full-screen Modal lifts the sheet into its own
|
||||
// native window so it always covers the viewport — even when the drawer is
|
||||
// mounted deep inside a ScrollView, where a plain absolute overlay anchors to
|
||||
// the scrolled content and clips the sheet. The Modal stays mounted (visible)
|
||||
// for the whole life of MountedBottomDrawer so the reanimated exit animation
|
||||
// runs before the parent unmounts us; show/hide is driven by `progress`, so
|
||||
// animationType stays "none". onRequestClose handles the Android back button.
|
||||
return (
|
||||
<Animated.View
|
||||
pointerEvents={visible ? 'auto' : 'none'}
|
||||
style={[styles.overlay, { zIndex, elevation: zIndex }]}
|
||||
accessibilityViewIsModal
|
||||
aria-modal
|
||||
>
|
||||
<GestureHandlerRootView style={styles.root}>
|
||||
<Animated.View style={[styles.backdrop, backdropStyle]}>
|
||||
<Pressable style={StyleSheet.absoluteFill} onPress={dismiss} />
|
||||
</Animated.View>
|
||||
|
||||
<View style={[styles.anchor, isWideLayout && styles.anchorWide]} pointerEvents="box-none">
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.drawer,
|
||||
{
|
||||
width: '100%',
|
||||
maxWidth: isWideLayout ? modalMaxWidth : undefined,
|
||||
maxHeight: screenHeight - insets.top - spacing.lg,
|
||||
paddingBottom: insets.bottom + spacing.lg
|
||||
},
|
||||
drawerStyle
|
||||
]}
|
||||
>
|
||||
{!contentScrollable ? (
|
||||
<>
|
||||
<GestureDetector gesture={handlePanGesture}>
|
||||
<Animated.View
|
||||
style={styles.handleHitArea}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Dismiss drawer"
|
||||
>
|
||||
<View style={styles.handle} />
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
<View style={styles.staticContent}>{children}</View>
|
||||
</>
|
||||
) : dragContentToDismiss ? (
|
||||
<>
|
||||
<GestureDetector gesture={handlePanGesture}>
|
||||
<Animated.View
|
||||
style={styles.handleHitArea}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Dismiss drawer"
|
||||
>
|
||||
<View style={styles.handle} />
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
<GestureDetector gesture={contentPanGesture}>
|
||||
<Animated.View collapsable={false}>
|
||||
<GestureDetector gesture={scrollGesture}>
|
||||
<Animated.ScrollView
|
||||
bounces={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
onScroll={scrollHandler}
|
||||
scrollEventThrottle={16}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{children}
|
||||
</Animated.ScrollView>
|
||||
</GestureDetector>
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<GestureDetector gesture={handlePanGesture}>
|
||||
<Animated.View
|
||||
style={styles.handleHitArea}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Dismiss drawer"
|
||||
>
|
||||
<View style={styles.handle} />
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
<ScrollView
|
||||
bounces={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{children}
|
||||
</ScrollView>
|
||||
</>
|
||||
)}
|
||||
<View style={styles.bottomExtension} />
|
||||
<Modal visible transparent animationType="none" statusBarTranslucent onRequestClose={dismiss}>
|
||||
<Animated.View
|
||||
pointerEvents={visible ? 'auto' : 'none'}
|
||||
style={[styles.overlay, { zIndex, elevation: zIndex }]}
|
||||
accessibilityViewIsModal
|
||||
aria-modal
|
||||
>
|
||||
<GestureHandlerRootView style={styles.root}>
|
||||
<Animated.View style={[styles.backdrop, backdropStyle]}>
|
||||
<Pressable style={StyleSheet.absoluteFill} onPress={dismiss} />
|
||||
</Animated.View>
|
||||
</View>
|
||||
</GestureHandlerRootView>
|
||||
</Animated.View>
|
||||
|
||||
<View style={[styles.anchor, isWideLayout && styles.anchorWide]} pointerEvents="box-none">
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.drawer,
|
||||
{
|
||||
width: '100%',
|
||||
maxWidth: isWideLayout ? modalMaxWidth : undefined,
|
||||
maxHeight: screenHeight - insets.top - spacing.lg,
|
||||
paddingBottom: insets.bottom + spacing.lg
|
||||
},
|
||||
drawerStyle
|
||||
]}
|
||||
>
|
||||
{!contentScrollable ? (
|
||||
<>
|
||||
<GestureDetector gesture={handlePanGesture}>
|
||||
<Animated.View
|
||||
style={styles.handleHitArea}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Dismiss drawer"
|
||||
>
|
||||
<View style={styles.handle} />
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
<View style={styles.staticContent}>{children}</View>
|
||||
</>
|
||||
) : dragContentToDismiss ? (
|
||||
<>
|
||||
<GestureDetector gesture={handlePanGesture}>
|
||||
<Animated.View
|
||||
style={styles.handleHitArea}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Dismiss drawer"
|
||||
>
|
||||
<View style={styles.handle} />
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
<GestureDetector gesture={contentPanGesture}>
|
||||
<Animated.View collapsable={false}>
|
||||
<GestureDetector gesture={scrollGesture}>
|
||||
<Animated.ScrollView
|
||||
bounces={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
onScroll={scrollHandler}
|
||||
scrollEventThrottle={16}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{children}
|
||||
</Animated.ScrollView>
|
||||
</GestureDetector>
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<GestureDetector gesture={handlePanGesture}>
|
||||
<Animated.View
|
||||
style={styles.handleHitArea}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Dismiss drawer"
|
||||
>
|
||||
<View style={styles.handle} />
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
<ScrollView
|
||||
bounces={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{children}
|
||||
</ScrollView>
|
||||
</>
|
||||
)}
|
||||
<View style={styles.bottomExtension} />
|
||||
</Animated.View>
|
||||
</View>
|
||||
</GestureHandlerRootView>
|
||||
</Animated.View>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
InteractionManager,
|
||||
Platform,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
TextInput,
|
||||
View,
|
||||
type TextInputProps
|
||||
} from 'react-native'
|
||||
import { Search, X } from 'lucide-react-native'
|
||||
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
|
||||
|
||||
// Why: toolbar/list chrome paints and settles after the open tap; native
|
||||
// autoFocus alone often fails to raise the soft keyboard on iOS/Android.
|
||||
const SEARCH_AUTO_FOCUS_DELAY_MS = 120
|
||||
|
||||
type MobileSearchFieldProps = {
|
||||
value: string
|
||||
onChangeText: (text: string) => void
|
||||
placeholder: string
|
||||
onClear?: () => void
|
||||
/** Override clear-button visibility (default: value is non-empty). */
|
||||
showClear?: boolean
|
||||
clearAccessibilityLabel?: string
|
||||
autoFocus?: boolean
|
||||
/** Re-run delayed focus when this identity changes (e.g. each time search opens). */
|
||||
focusKey?: unknown
|
||||
returnKeyType?: TextInputProps['returnKeyType']
|
||||
onSubmitEditing?: TextInputProps['onSubmitEditing']
|
||||
onBlur?: TextInputProps['onBlur']
|
||||
editable?: boolean
|
||||
accessibilityLabel?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Raised search field used on list screens. Sits above the base/panel canvas
|
||||
* so it reads as a tappable control instead of chrome that blends into the list.
|
||||
*/
|
||||
export function MobileSearchField({
|
||||
value,
|
||||
onChangeText,
|
||||
placeholder,
|
||||
onClear,
|
||||
showClear,
|
||||
clearAccessibilityLabel = 'Clear search',
|
||||
autoFocus = false,
|
||||
focusKey,
|
||||
returnKeyType = 'search',
|
||||
onSubmitEditing,
|
||||
onBlur,
|
||||
editable = true,
|
||||
accessibilityLabel
|
||||
}: MobileSearchFieldProps) {
|
||||
const inputRef = useRef<TextInput>(null)
|
||||
const [focused, setFocused] = useState(false)
|
||||
const clearVisible = showClear ?? value.length > 0
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoFocus || !editable) {
|
||||
return
|
||||
}
|
||||
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined
|
||||
// Why: wait for the open-press interaction + layout to finish, then focus
|
||||
// so the soft keyboard actually appears (not just a caret with no IME).
|
||||
const task = InteractionManager.runAfterInteractions(() => {
|
||||
timeout = setTimeout(() => {
|
||||
inputRef.current?.focus()
|
||||
}, SEARCH_AUTO_FOCUS_DELAY_MS)
|
||||
})
|
||||
|
||||
return () => {
|
||||
task.cancel()
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
}, [autoFocus, editable, focusKey])
|
||||
|
||||
function handleClear() {
|
||||
if (onClear) {
|
||||
onClear()
|
||||
} else {
|
||||
onChangeText('')
|
||||
}
|
||||
// Why: pressing the clear chip steals focus and drops the keyboard;
|
||||
// re-focus so the user can keep typing without tapping the field again.
|
||||
requestAnimationFrame(() => {
|
||||
inputRef.current?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.shell, focused && styles.shellFocused, !editable && styles.shellDisabled]}>
|
||||
<Search
|
||||
size={15}
|
||||
color={focused ? colors.textPrimary : colors.textSecondary}
|
||||
strokeWidth={2.2}
|
||||
/>
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
style={styles.input}
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
placeholder={placeholder}
|
||||
// Why: textSecondary keeps the hint readable on bgRaised; textMuted
|
||||
// disappears against the raised shell and makes the field look empty.
|
||||
placeholderTextColor={colors.textSecondary}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
// Still request native auto-focus; the delayed ref focus is the reliable path.
|
||||
autoFocus={autoFocus}
|
||||
showSoftInputOnFocus
|
||||
editable={editable}
|
||||
returnKeyType={returnKeyType}
|
||||
onSubmitEditing={onSubmitEditing}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={(event) => {
|
||||
setFocused(false)
|
||||
onBlur?.(event)
|
||||
}}
|
||||
clearButtonMode="never"
|
||||
accessibilityLabel={accessibilityLabel ?? placeholder}
|
||||
selectionColor={colors.accentBlue}
|
||||
/>
|
||||
{clearVisible ? (
|
||||
<Pressable
|
||||
onPress={handleClear}
|
||||
hitSlop={8}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={clearAccessibilityLabel}
|
||||
style={({ pressed }) => [styles.clearButton, pressed && styles.clearButtonPressed]}
|
||||
>
|
||||
{/* Why: chip + larger hit target — a bare 14px X was hard to tap and
|
||||
read as decoration rather than a clear control. */}
|
||||
<View style={styles.clearChip}>
|
||||
<X size={12} color={colors.surfaceBright} strokeWidth={2.6} />
|
||||
</View>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
shell: {
|
||||
minHeight: 42,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
// Why: bgRaised lifts the field off bgBase/bgPanel so search is an obvious
|
||||
// control, matching TextInputModal / MobilePrBasePicker input shells.
|
||||
backgroundColor: colors.bgRaised,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
borderRadius: radii.input,
|
||||
paddingLeft: spacing.md,
|
||||
paddingRight: spacing.xs,
|
||||
paddingVertical: Platform.OS === 'ios' ? spacing.sm : spacing.xs + 2
|
||||
},
|
||||
shellFocused: {
|
||||
// Why: monochrome focus cue without burning the blue accent token
|
||||
// (reserved for state/selection). textMuted reads clearly on bgRaised.
|
||||
borderColor: colors.textMuted
|
||||
},
|
||||
shellDisabled: {
|
||||
opacity: 0.55
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
color: colors.textPrimary,
|
||||
fontSize: typography.bodySize,
|
||||
// Why: Android TextInput draws extra vertical padding that misaligns the
|
||||
// icon/clear chip unless we zero it out.
|
||||
includeFontPadding: false,
|
||||
textAlignVertical: 'center'
|
||||
},
|
||||
clearButton: {
|
||||
minWidth: 36,
|
||||
minHeight: 36,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
clearButtonPressed: {
|
||||
opacity: 0.7
|
||||
},
|
||||
clearChip: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
// Why: textMuted reads as a solid chip on bgRaised; borderSubtle was nearly
|
||||
// invisible and made the clear control feel like decorative chrome.
|
||||
backgroundColor: colors.textMuted
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import {
|
||||
MOBILE_DICTATION_CONNECTION_SLOW_ERROR_MESSAGE,
|
||||
MOBILE_DICTATION_PCM_SAMPLE_RATE
|
||||
} from './mobile-dictation-pending-audio-budget'
|
||||
import { bytesToBase64 } from './mobile-dictation-session-state'
|
||||
import type { MicrophoneDataEvent } from '@orca/expo-two-way-audio'
|
||||
import type { MobileDictationPendingAudioBudget } from './mobile-dictation-pending-audio-budget'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
|
||||
type MobileDictationAudioChunkQueue = {
|
||||
pendingChunks: Set<Promise<void>>
|
||||
pendingAudioBudget: MobileDictationPendingAudioBudget
|
||||
shouldReleaseBudget: (dictationId: string) => boolean
|
||||
failActiveDictation: (dictationId: string, err: unknown) => void
|
||||
}
|
||||
|
||||
export function enqueueMobileDictationAudioChunk(
|
||||
client: RpcClient,
|
||||
dictationId: string,
|
||||
event: MicrophoneDataEvent,
|
||||
queue: MobileDictationAudioChunkQueue
|
||||
): void {
|
||||
const raw = event.data
|
||||
const bytes = raw instanceof Uint8Array ? raw : new Uint8Array(raw)
|
||||
const byteLength = bytes.byteLength
|
||||
if (!queue.pendingAudioBudget.tryReserve(byteLength)) {
|
||||
queue.failActiveDictation(
|
||||
dictationId,
|
||||
new Error(MOBILE_DICTATION_CONNECTION_SLOW_ERROR_MESSAGE)
|
||||
)
|
||||
return
|
||||
}
|
||||
const sendChunk = client
|
||||
.sendRequest('speech.dictation.chunk', {
|
||||
dictationId,
|
||||
audioBase64: bytesToBase64(bytes),
|
||||
sampleRate: MOBILE_DICTATION_PCM_SAMPLE_RATE
|
||||
})
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(response.error.message)
|
||||
}
|
||||
})
|
||||
.catch((err) => queue.failActiveDictation(dictationId, err))
|
||||
.finally(() => {
|
||||
if (queue.shouldReleaseBudget(dictationId)) {
|
||||
queue.pendingAudioBudget.release(byteLength)
|
||||
}
|
||||
queue.pendingChunks.delete(sendChunk)
|
||||
})
|
||||
queue.pendingChunks.add(sendChunk)
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { MOBILE_DICTATION_KEEP_AWAKE_STARTUP_BUDGET_MS } from './mobile-dictation-session-state'
|
||||
import { startMobileDictationDesktopSession } from './mobile-dictation-desktop-start'
|
||||
import type { MobileDictationKeepAwakeOwner } from './mobile-dictation-keep-awake'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
|
||||
const OK_RESPONSE = { ok: true, result: {} } as const
|
||||
|
||||
type StartHarnessOptions = {
|
||||
sendRequest?: (method: string) => Promise<unknown>
|
||||
acquire?: () => Promise<void>
|
||||
commitRecordingStart?: () => boolean
|
||||
}
|
||||
|
||||
function createStartHarness(options: StartHarnessOptions = {}) {
|
||||
let generation = 1
|
||||
let enabled = true
|
||||
let activeId: string | null = 'dictation-a'
|
||||
const setIdle = vi.fn()
|
||||
const release = vi.fn().mockResolvedValue(undefined)
|
||||
const commitRecordingStart = vi.fn(options.commitRecordingStart ?? (() => true))
|
||||
const rollbackRecordingStart = vi.fn()
|
||||
const sendRequest = vi.fn(
|
||||
options.sendRequest ?? (async () => OK_RESPONSE)
|
||||
) as unknown as RpcClient['sendRequest']
|
||||
const client = { sendRequest } as RpcClient
|
||||
const keepAwakeOwner = {
|
||||
acquire: vi.fn(options.acquire ?? (async () => undefined)),
|
||||
release
|
||||
} as unknown as MobileDictationKeepAwakeOwner
|
||||
|
||||
return {
|
||||
options: {
|
||||
client,
|
||||
dictationId: 'dictation-a',
|
||||
generation: 1,
|
||||
getCurrentGeneration: () => generation,
|
||||
getEnabled: () => enabled,
|
||||
getActiveId: () => activeId,
|
||||
clearActiveId: (dictationId: string) => {
|
||||
if (activeId === dictationId) {
|
||||
activeId = null
|
||||
}
|
||||
},
|
||||
setIdle,
|
||||
keepAwakeOwner,
|
||||
commitRecordingStart,
|
||||
rollbackRecordingStart
|
||||
},
|
||||
setNewerStart: () => {
|
||||
generation = 2
|
||||
activeId = 'dictation-b'
|
||||
},
|
||||
setDisabled: () => {
|
||||
enabled = false
|
||||
},
|
||||
getActiveId: () => activeId,
|
||||
setIdle,
|
||||
release,
|
||||
sendRequest,
|
||||
commitRecordingStart,
|
||||
rollbackRecordingStart
|
||||
}
|
||||
}
|
||||
|
||||
describe('startMobileDictationDesktopSession', () => {
|
||||
it('does not reset UI state when a newer start supersedes keep-awake acquisition', async () => {
|
||||
let setNewerStart = () => undefined
|
||||
const harness = createStartHarness({
|
||||
acquire: async () => setNewerStart()
|
||||
})
|
||||
setNewerStart = harness.setNewerStart
|
||||
|
||||
await expect(startMobileDictationDesktopSession(harness.options)).resolves.toBe(false)
|
||||
|
||||
expect(harness.setIdle).not.toHaveBeenCalled()
|
||||
expect(harness.getActiveId()).toBe('dictation-b')
|
||||
expect(harness.release).toHaveBeenCalledWith('dictation-a')
|
||||
expect(harness.commitRecordingStart).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sends desktop cancellation without waiting for a hung keep-awake release', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
let goStale = () => undefined
|
||||
const harness = createStartHarness({
|
||||
acquire: () => {
|
||||
goStale()
|
||||
return new Promise<void>(() => undefined)
|
||||
}
|
||||
})
|
||||
goStale = harness.setNewerStart
|
||||
// Release queues behind the still-running acquisition, so it never settles.
|
||||
harness.release.mockReturnValue(new Promise<void>(() => undefined))
|
||||
|
||||
const startPromise = startMobileDictationDesktopSession(harness.options)
|
||||
await vi.advanceTimersByTimeAsync(MOBILE_DICTATION_KEEP_AWAKE_STARTUP_BUDGET_MS)
|
||||
|
||||
// Cancellation is dispatched even though release is still pending, so the
|
||||
// native session tears down without waiting out its own timeout.
|
||||
expect(harness.release).toHaveBeenCalledWith('dictation-a')
|
||||
expect(harness.sendRequest).toHaveBeenCalledWith('speech.dictation.cancel', {
|
||||
dictationId: 'dictation-a'
|
||||
})
|
||||
|
||||
void startPromise
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('returns to idle when disable makes keep-awake acquisition stale', async () => {
|
||||
let setDisabled = () => undefined
|
||||
const harness = createStartHarness({
|
||||
acquire: async () => setDisabled()
|
||||
})
|
||||
setDisabled = harness.setDisabled
|
||||
|
||||
await expect(startMobileDictationDesktopSession(harness.options)).resolves.toBe(false)
|
||||
|
||||
expect(harness.setIdle).toHaveBeenCalledOnce()
|
||||
expect(harness.getActiveId()).toBeNull()
|
||||
expect(harness.release).toHaveBeenCalledWith('dictation-a')
|
||||
expect(harness.commitRecordingStart).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not hold recording start on a hung keep-awake acquisition', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const harness = createStartHarness({
|
||||
acquire: () => new Promise<void>(() => undefined)
|
||||
})
|
||||
|
||||
const startPromise = startMobileDictationDesktopSession(harness.options)
|
||||
await vi.advanceTimersByTimeAsync(MOBILE_DICTATION_KEEP_AWAKE_STARTUP_BUDGET_MS)
|
||||
|
||||
await expect(startPromise).resolves.toBe(true)
|
||||
expect(harness.commitRecordingStart).toHaveBeenCalledOnce()
|
||||
expect(harness.release).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('continues dictation when keep-awake acquisition fails', async () => {
|
||||
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
const harness = createStartHarness({
|
||||
acquire: async () => {
|
||||
throw new Error('Unable to activate keep awake')
|
||||
}
|
||||
})
|
||||
|
||||
await expect(startMobileDictationDesktopSession(harness.options)).resolves.toBe(true)
|
||||
|
||||
expect(consoleError).toHaveBeenCalledOnce()
|
||||
consoleError.mockRestore()
|
||||
|
||||
expect(harness.commitRecordingStart).toHaveBeenCalledOnce()
|
||||
expect(harness.setIdle).not.toHaveBeenCalled()
|
||||
expect(harness.getActiveId()).toBe('dictation-a')
|
||||
expect(harness.release).not.toHaveBeenCalled()
|
||||
expect(harness.sendRequest).not.toHaveBeenCalledWith('speech.dictation.cancel', {
|
||||
dictationId: 'dictation-a'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not surface a desktop-start failure after the start became stale', async () => {
|
||||
let setNewerStart = () => undefined
|
||||
const harness = createStartHarness({
|
||||
sendRequest: async (method) => {
|
||||
if (method === 'speech.dictation.start') {
|
||||
setNewerStart()
|
||||
throw new Error('Desktop start failed')
|
||||
}
|
||||
return OK_RESPONSE
|
||||
}
|
||||
})
|
||||
setNewerStart = harness.setNewerStart
|
||||
|
||||
await expect(startMobileDictationDesktopSession(harness.options)).resolves.toBe(false)
|
||||
|
||||
expect(harness.setIdle).not.toHaveBeenCalled()
|
||||
expect(harness.getActiveId()).toBe('dictation-b')
|
||||
expect(harness.commitRecordingStart).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('commits recording before returning a current start to the hook', async () => {
|
||||
const harness = createStartHarness()
|
||||
|
||||
await expect(startMobileDictationDesktopSession(harness.options)).resolves.toBe(true)
|
||||
|
||||
expect(harness.commitRecordingStart).toHaveBeenCalledOnce()
|
||||
expect(harness.rollbackRecordingStart).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('cleans up the keep-awake tag and desktop session when native recording throws', async () => {
|
||||
const harness = createStartHarness({
|
||||
commitRecordingStart: () => {
|
||||
throw new Error('Audio focus request failed')
|
||||
}
|
||||
})
|
||||
|
||||
await expect(startMobileDictationDesktopSession(harness.options)).rejects.toThrow(
|
||||
'Audio focus request failed'
|
||||
)
|
||||
|
||||
expect(harness.release).toHaveBeenCalledWith('dictation-a')
|
||||
expect(harness.sendRequest).toHaveBeenCalledWith('speech.dictation.cancel', {
|
||||
dictationId: 'dictation-a'
|
||||
})
|
||||
expect(harness.getActiveId()).toBeNull()
|
||||
expect(harness.setIdle).toHaveBeenCalledOnce()
|
||||
expect(harness.rollbackRecordingStart).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('rejects and cleans up when the native recorder does not start', async () => {
|
||||
const harness = createStartHarness({ commitRecordingStart: () => false })
|
||||
|
||||
await expect(startMobileDictationDesktopSession(harness.options)).rejects.toThrow(
|
||||
'Failed to start microphone recording'
|
||||
)
|
||||
|
||||
expect(harness.release).toHaveBeenCalledWith('dictation-a')
|
||||
expect(harness.sendRequest).toHaveBeenCalledWith('speech.dictation.cancel', {
|
||||
dictationId: 'dictation-a'
|
||||
})
|
||||
expect(harness.getActiveId()).toBeNull()
|
||||
expect(harness.setIdle).toHaveBeenCalledOnce()
|
||||
expect(harness.rollbackRecordingStart).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,142 @@
|
||||
import {
|
||||
MOBILE_DICTATION_KEEP_AWAKE_STARTUP_BUDGET_MS,
|
||||
isCurrentMobileDictationStart
|
||||
} from './mobile-dictation-session-state'
|
||||
import type { MobileDictationKeepAwakeOwner } from './mobile-dictation-keep-awake'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
|
||||
type StartMobileDictationDesktopSessionOptions = {
|
||||
client: RpcClient
|
||||
dictationId: string
|
||||
generation: number
|
||||
getCurrentGeneration: () => number
|
||||
getEnabled: () => boolean
|
||||
getActiveId: () => string | null
|
||||
clearActiveId: (dictationId: string) => void
|
||||
setIdle: () => void
|
||||
keepAwakeOwner: MobileDictationKeepAwakeOwner
|
||||
commitRecordingStart: () => boolean
|
||||
rollbackRecordingStart: () => void
|
||||
}
|
||||
|
||||
function isCurrentStart(options: StartMobileDictationDesktopSessionOptions): boolean {
|
||||
return isCurrentMobileDictationStart(
|
||||
options.getCurrentGeneration(),
|
||||
options.generation,
|
||||
options.getEnabled(),
|
||||
options.getActiveId(),
|
||||
options.dictationId
|
||||
)
|
||||
}
|
||||
|
||||
function canReportStartFailure(options: StartMobileDictationDesktopSessionOptions): boolean {
|
||||
return options.getCurrentGeneration() === options.generation && options.getEnabled()
|
||||
}
|
||||
|
||||
function setIdleIfGenerationCurrent(options: StartMobileDictationDesktopSessionOptions): void {
|
||||
if (options.getCurrentGeneration() === options.generation) {
|
||||
options.setIdle()
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel a start that went stale mid-startup. The wake-lock release and remote
|
||||
// cancel are independent, so run them concurrently: awaiting release first can
|
||||
// queue behind a still-running acquisition and delay cancel for the remainder
|
||||
// of the native timeout.
|
||||
async function cancelStaleStart(
|
||||
options: StartMobileDictationDesktopSessionOptions,
|
||||
{ releaseKeepAwake }: { releaseKeepAwake: boolean }
|
||||
): Promise<void> {
|
||||
const { client, dictationId, keepAwakeOwner } = options
|
||||
options.clearActiveId(dictationId)
|
||||
setIdleIfGenerationCurrent(options)
|
||||
const cleanups: Promise<unknown>[] = [
|
||||
client.sendRequest('speech.dictation.cancel', { dictationId })
|
||||
]
|
||||
if (releaseKeepAwake) {
|
||||
cleanups.push(keepAwakeOwner.release(dictationId))
|
||||
}
|
||||
await Promise.allSettled(cleanups)
|
||||
}
|
||||
|
||||
export async function startMobileDictationDesktopSession(
|
||||
options: StartMobileDictationDesktopSessionOptions
|
||||
): Promise<boolean> {
|
||||
const { client, dictationId, keepAwakeOwner } = options
|
||||
|
||||
try {
|
||||
const response = await client.sendRequest('speech.dictation.start', { dictationId })
|
||||
if (!response.ok) {
|
||||
throw new Error(response.error.message)
|
||||
}
|
||||
} catch (err) {
|
||||
const wasCurrent = isCurrentStart(options)
|
||||
options.clearActiveId(dictationId)
|
||||
await client.sendRequest('speech.dictation.cancel', { dictationId }).catch(() => undefined)
|
||||
// Awaited cleanup may overlap a newer start; stale work must not reset or
|
||||
// report over the replacement session.
|
||||
const shouldReport = wasCurrent && canReportStartFailure(options)
|
||||
setIdleIfGenerationCurrent(options)
|
||||
if (!shouldReport) {
|
||||
return false
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
if (!isCurrentStart(options)) {
|
||||
await cancelStaleStart(options, { releaseKeepAwake: false })
|
||||
return false
|
||||
}
|
||||
|
||||
// Keep-awake is acquired only after the desktop session exists, so stale
|
||||
// mobile starts can be canceled without holding a screen-lock tag. It is
|
||||
// best-effort: Android throws with no current Activity, and a screen-lock
|
||||
// nicety must not abort an otherwise viable dictation — nor delay recording
|
||||
// past a small budget when native calls hang. A late acquisition finishes in
|
||||
// the background; the serialized keep-awake queue orders any later release
|
||||
// after it.
|
||||
await new Promise<void>((resolve) => {
|
||||
const budgetTimer = setTimeout(resolve, MOBILE_DICTATION_KEEP_AWAKE_STARTUP_BUDGET_MS)
|
||||
keepAwakeOwner
|
||||
.acquire(dictationId)
|
||||
.catch((err: unknown) => console.error('Keep-awake activation failed', err))
|
||||
.finally(() => {
|
||||
clearTimeout(budgetTimer)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
|
||||
if (!isCurrentStart(options)) {
|
||||
await cancelStaleStart(options, { releaseKeepAwake: true })
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
// Commit in the same continuation as the final stale check; returning first
|
||||
// would let a queued cancel resurrect microphone recording after cleanup.
|
||||
if (!options.commitRecordingStart()) {
|
||||
throw new Error('Failed to start microphone recording')
|
||||
}
|
||||
} catch (err) {
|
||||
const wasCurrent = isCurrentStart(options)
|
||||
// Native recording can partially start before throwing, so stop audio before
|
||||
// releasing the wake tag and remote session.
|
||||
try {
|
||||
options.rollbackRecordingStart()
|
||||
} catch {
|
||||
// Continue releasing independently owned resources after native audio failure.
|
||||
}
|
||||
options.clearActiveId(dictationId)
|
||||
await Promise.allSettled([
|
||||
keepAwakeOwner.release(dictationId),
|
||||
client.sendRequest('speech.dictation.cancel', { dictationId })
|
||||
])
|
||||
const shouldReport = wasCurrent && canReportStartFailure(options)
|
||||
setIdleIfGenerationCurrent(options)
|
||||
if (!shouldReport) {
|
||||
return false
|
||||
}
|
||||
throw err
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useEffect } from 'react'
|
||||
import { AppState, Platform } from 'react-native'
|
||||
import { drainMobileDictationKeepAwakeCleanup } from './mobile-dictation-keep-awake'
|
||||
import type { RefObject } from 'react'
|
||||
import type { MobileDictationKeepAwakeOwner } from './mobile-dictation-keep-awake'
|
||||
|
||||
// A transient Activity gap can fail a foreground refresh; retry briefly while
|
||||
// the same dictation is live instead of waiting for the next foreground.
|
||||
const REACQUIRE_RETRY_DELAYS_MS = [1_000, 5_000]
|
||||
|
||||
let globalStaleTagDrainInstalled = false
|
||||
|
||||
// Failed final deactivations must be retried even after every session screen
|
||||
// unmounts, or a stale native tag keeps the screen awake until app restart.
|
||||
// Installed once for the app's lifetime; the drain spares still-wanted tags
|
||||
// and fast-paths to a no-op when nothing is pending.
|
||||
function installGlobalStaleTagForegroundDrain(): void {
|
||||
if (globalStaleTagDrainInstalled) {
|
||||
return
|
||||
}
|
||||
globalStaleTagDrainInstalled = true
|
||||
AppState.addEventListener('change', (state) => {
|
||||
if (state === 'active') {
|
||||
void drainMobileDictationKeepAwakeCleanup().catch(() => undefined)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function useMobileDictationForegroundKeepAwake(
|
||||
keepAwakeOwner: MobileDictationKeepAwakeOwner,
|
||||
activeIdRef: RefObject<string | null>
|
||||
): void {
|
||||
useEffect(() => {
|
||||
installGlobalStaleTagForegroundDrain()
|
||||
// Android keeps FLAG_KEEP_SCREEN_ON on the Activity window, so Activity
|
||||
// recreation silently drops it mid-dictation; refresh on return to
|
||||
// active. iOS re-applies natively on foreground.
|
||||
if (Platform.OS !== 'android') {
|
||||
return
|
||||
}
|
||||
// A retry from an earlier foreground event can outlive a newer reacquire and
|
||||
// deactivate the recovered tag; a run token invalidated on each AppState
|
||||
// change and on unmount drops superseded retry chains.
|
||||
let reacquireRun = 0
|
||||
const reacquireWithRetry = (dictationId: string, attempt: number, run: number): void => {
|
||||
void keepAwakeOwner.reacquire(dictationId).catch(() => {
|
||||
const delay = REACQUIRE_RETRY_DELAYS_MS[attempt]
|
||||
if (delay === undefined) {
|
||||
return
|
||||
}
|
||||
setTimeout(() => {
|
||||
if (reacquireRun === run && activeIdRef.current === dictationId) {
|
||||
reacquireWithRetry(dictationId, attempt + 1, run)
|
||||
}
|
||||
}, delay)
|
||||
})
|
||||
}
|
||||
const sub = AppState.addEventListener('change', (state) => {
|
||||
const run = ++reacquireRun
|
||||
const dictationId = activeIdRef.current
|
||||
if (state === 'active' && dictationId) {
|
||||
reacquireWithRetry(dictationId, 0, run)
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
reacquireRun += 1
|
||||
sub.remove()
|
||||
}
|
||||
}, [keepAwakeOwner, activeIdRef])
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const keepAwake = vi.hoisted(() => ({
|
||||
activate: vi.fn<(tag: string) => Promise<void>>(),
|
||||
deactivate: vi.fn<(tag: string) => Promise<void>>()
|
||||
}))
|
||||
|
||||
vi.mock('expo-keep-awake', () => ({
|
||||
activateKeepAwakeAsync: keepAwake.activate,
|
||||
deactivateKeepAwake: keepAwake.deactivate
|
||||
}))
|
||||
|
||||
import {
|
||||
MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS,
|
||||
MobileDictationKeepAwakeOwner,
|
||||
drainMobileDictationKeepAwakeCleanup
|
||||
} from './mobile-dictation-keep-awake'
|
||||
|
||||
function deferred(): {
|
||||
promise: Promise<void>
|
||||
resolve: () => void
|
||||
reject: (error: Error) => void
|
||||
} {
|
||||
let resolvePromise: (() => void) | undefined
|
||||
let rejectPromise: ((error: Error) => void) | undefined
|
||||
const promise = new Promise<void>((resolve, reject) => {
|
||||
resolvePromise = resolve
|
||||
rejectPromise = reject
|
||||
})
|
||||
return {
|
||||
promise,
|
||||
resolve: () => resolvePromise?.(),
|
||||
reject: (error) => rejectPromise?.(error)
|
||||
}
|
||||
}
|
||||
|
||||
describe('MobileDictationKeepAwakeOwner', () => {
|
||||
beforeEach(() => {
|
||||
keepAwake.activate.mockReset().mockResolvedValue(undefined)
|
||||
keepAwake.deactivate.mockReset().mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('retries a failed native deactivation after the hook owner is replaced', async () => {
|
||||
const firstOwner = new MobileDictationKeepAwakeOwner()
|
||||
|
||||
await firstOwner.acquire('first')
|
||||
const firstTag = keepAwake.activate.mock.calls[0]?.[0]
|
||||
expect(firstTag).toContain(':first')
|
||||
|
||||
keepAwake.deactivate.mockRejectedValueOnce(new Error('Activity unavailable'))
|
||||
await expect(firstOwner.release('first')).rejects.toThrow('Activity unavailable')
|
||||
|
||||
const replacementOwner = new MobileDictationKeepAwakeOwner()
|
||||
await replacementOwner.acquire('second')
|
||||
const secondTag = keepAwake.activate.mock.calls[1]?.[0]
|
||||
expect(secondTag).toContain(':second')
|
||||
expect(keepAwake.deactivate.mock.calls.slice(0, 2)).toEqual([[firstTag], [firstTag]])
|
||||
expect(keepAwake.deactivate.mock.invocationCallOrder[1]).toBeLessThan(
|
||||
keepAwake.activate.mock.invocationCallOrder[1] ?? 0
|
||||
)
|
||||
|
||||
await replacementOwner.release('second')
|
||||
})
|
||||
|
||||
it('serializes cancel and restart without letting a stale release deactivate the restart', async () => {
|
||||
const firstActivation = deferred()
|
||||
keepAwake.activate.mockImplementationOnce(() => firstActivation.promise)
|
||||
const owner = new MobileDictationKeepAwakeOwner()
|
||||
|
||||
const acquireFirst = owner.acquire('first')
|
||||
const releaseFirst = owner.release('first')
|
||||
const acquireSecond = owner.acquire('second')
|
||||
firstActivation.resolve()
|
||||
await Promise.all([acquireFirst, releaseFirst, acquireSecond])
|
||||
|
||||
const secondTag = keepAwake.activate.mock.calls[1]?.[0]
|
||||
await owner.release('first')
|
||||
expect(keepAwake.deactivate).toHaveBeenCalledTimes(1)
|
||||
|
||||
await owner.release('second')
|
||||
expect(keepAwake.deactivate).toHaveBeenLastCalledWith(secondTag)
|
||||
})
|
||||
|
||||
it('waits for an in-flight failed release before a replacement owner activates', async () => {
|
||||
const deactivation = deferred()
|
||||
const firstOwner = new MobileDictationKeepAwakeOwner()
|
||||
await firstOwner.acquire('first')
|
||||
keepAwake.deactivate.mockImplementationOnce(() => deactivation.promise)
|
||||
|
||||
const releaseFirst = firstOwner.release('first')
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
expect(keepAwake.deactivate).toHaveBeenCalledOnce()
|
||||
|
||||
const replacementOwner = new MobileDictationKeepAwakeOwner()
|
||||
const acquireReplacement = replacementOwner.acquire('replacement')
|
||||
expect(keepAwake.activate).toHaveBeenCalledOnce()
|
||||
|
||||
deactivation.reject(new Error('Activity unavailable'))
|
||||
await expect(releaseFirst).rejects.toThrow('Activity unavailable')
|
||||
await acquireReplacement
|
||||
|
||||
expect(keepAwake.deactivate).toHaveBeenCalledTimes(2)
|
||||
expect(keepAwake.activate).toHaveBeenCalledTimes(2)
|
||||
expect(keepAwake.deactivate.mock.invocationCallOrder[1]).toBeLessThan(
|
||||
keepAwake.activate.mock.invocationCallOrder[1] ?? 0
|
||||
)
|
||||
await replacementOwner.release('replacement')
|
||||
})
|
||||
|
||||
it('does not fail a fresh acquire when stale-tag cleanup keeps failing', async () => {
|
||||
const firstOwner = new MobileDictationKeepAwakeOwner()
|
||||
await firstOwner.acquire('first')
|
||||
|
||||
// Both the release deactivate and its trailing drain retry fail.
|
||||
keepAwake.deactivate
|
||||
.mockRejectedValueOnce(new Error('Activity unavailable'))
|
||||
.mockRejectedValueOnce(new Error('Activity unavailable'))
|
||||
await expect(firstOwner.release('first')).rejects.toThrow('Activity unavailable')
|
||||
|
||||
keepAwake.deactivate.mockRejectedValueOnce(new Error('Activity unavailable'))
|
||||
const replacementOwner = new MobileDictationKeepAwakeOwner()
|
||||
await expect(replacementOwner.acquire('second')).resolves.toBeUndefined()
|
||||
expect(keepAwake.activate).toHaveBeenCalledTimes(2)
|
||||
|
||||
// The still-pending first tag drains once a deactivation finally succeeds.
|
||||
await replacementOwner.release('second')
|
||||
expect(keepAwake.deactivate.mock.calls.filter(([tag]) => tag.includes(':first'))).toHaveLength(
|
||||
4
|
||||
)
|
||||
})
|
||||
|
||||
it('times out a never-settling native call instead of wedging the queue', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
keepAwake.activate.mockImplementationOnce(() => new Promise<void>(() => undefined))
|
||||
const hungOwner = new MobileDictationKeepAwakeOwner()
|
||||
const hungAcquire = hungOwner.acquire('hung')
|
||||
// Drain microtasks to quiescence so the timeout timer is registered.
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS)
|
||||
await expect(hungAcquire).rejects.toThrow('Keep-awake native call timed out')
|
||||
|
||||
// The queue must advance, and another owner's drain must spare the
|
||||
// still-wanted maybe-late activation.
|
||||
const nextOwner = new MobileDictationKeepAwakeOwner()
|
||||
await nextOwner.acquire('next')
|
||||
expect(keepAwake.deactivate).not.toHaveBeenCalled()
|
||||
expect(keepAwake.activate.mock.calls[1]?.[0]).toContain(':next')
|
||||
|
||||
// Once its own dictation ends, the orphan gets cleaned.
|
||||
await hungOwner.release('hung')
|
||||
expect(keepAwake.deactivate.mock.calls.filter(([tag]) => tag.includes(':hung'))).toHaveLength(
|
||||
1
|
||||
)
|
||||
await nextOwner.release('next')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('adopts a timed-out activation that lands late while the dictation is live', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const lateActivation = deferred()
|
||||
keepAwake.activate.mockImplementationOnce(() => lateActivation.promise)
|
||||
const owner = new MobileDictationKeepAwakeOwner()
|
||||
const acquire = owner.acquire('late')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS)
|
||||
await expect(acquire).rejects.toThrow('Keep-awake native call timed out')
|
||||
|
||||
lateActivation.resolve()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
// Adopted, not deactivated: protection stays on for the live dictation.
|
||||
expect(keepAwake.deactivate).not.toHaveBeenCalled()
|
||||
|
||||
await owner.release('late')
|
||||
expect(keepAwake.deactivate.mock.calls.filter(([tag]) => tag.includes(':late'))).toHaveLength(
|
||||
1
|
||||
)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not let another owner drain a still-wanted timed-out activation', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const lateActivation = deferred()
|
||||
keepAwake.activate.mockImplementationOnce(() => lateActivation.promise)
|
||||
const ownerA = new MobileDictationKeepAwakeOwner()
|
||||
const acquireA = ownerA.acquire('wanted')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
await vi.advanceTimersByTimeAsync(MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS)
|
||||
await expect(acquireA).rejects.toThrow('Keep-awake native call timed out')
|
||||
|
||||
const ownerB = new MobileDictationKeepAwakeOwner()
|
||||
await ownerB.acquire('other')
|
||||
expect(keepAwake.deactivate).not.toHaveBeenCalled()
|
||||
|
||||
lateActivation.resolve()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
// Adopted for owner A; released like a normal activation afterwards.
|
||||
await ownerA.release('wanted')
|
||||
expect(
|
||||
keepAwake.deactivate.mock.calls.filter(([tag]) => tag.includes(':wanted'))
|
||||
).toHaveLength(1)
|
||||
await ownerB.release('other')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('deactivates a late-landing activation once its dictation has ended', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const lateActivation = deferred()
|
||||
keepAwake.activate.mockImplementationOnce(() => lateActivation.promise)
|
||||
const owner = new MobileDictationKeepAwakeOwner()
|
||||
const acquire = owner.acquire('ended')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
await vi.advanceTimersByTimeAsync(MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS)
|
||||
await expect(acquire).rejects.toThrow('Keep-awake native call timed out')
|
||||
|
||||
await owner.release('ended')
|
||||
lateActivation.resolve()
|
||||
await vi.waitFor(() => expect(keepAwake.deactivate).toHaveBeenCalledTimes(2))
|
||||
expect(keepAwake.deactivate.mock.calls.every(([tag]) => tag.includes(':ended'))).toBe(true)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('retries a timed-out final deactivation via the foreground drain', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const owner = new MobileDictationKeepAwakeOwner()
|
||||
await owner.acquire('final')
|
||||
const tag = keepAwake.activate.mock.calls[0]?.[0]
|
||||
// The release deactivate times out and its trailing drain retry fails.
|
||||
keepAwake.deactivate
|
||||
.mockImplementationOnce(() => new Promise<void>(() => undefined))
|
||||
.mockRejectedValueOnce(new Error('Activity unavailable'))
|
||||
const release = owner.release('final')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
await vi.advanceTimersByTimeAsync(MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS)
|
||||
await expect(release).rejects.toThrow('Keep-awake native call timed out')
|
||||
expect(keepAwake.deactivate).toHaveBeenCalledTimes(2)
|
||||
|
||||
await drainMobileDictationKeepAwakeCleanup()
|
||||
expect(keepAwake.deactivate).toHaveBeenCalledTimes(3)
|
||||
expect(keepAwake.deactivate).toHaveBeenLastCalledWith(tag)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('drains orphaned tags on release, not only on the next acquire', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
keepAwake.activate.mockImplementationOnce(() => new Promise<void>(() => undefined))
|
||||
const owner = new MobileDictationKeepAwakeOwner()
|
||||
const acquire = owner.acquire('orphan')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
await vi.advanceTimersByTimeAsync(MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS)
|
||||
await expect(acquire).rejects.toThrow('Keep-awake native call timed out')
|
||||
|
||||
// The dictation ends without another acquire; release must still clean.
|
||||
await owner.release('orphan')
|
||||
expect(
|
||||
keepAwake.deactivate.mock.calls.filter(([tag]) => tag.includes(':orphan'))
|
||||
).toHaveLength(1)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('recovers on foreground reacquire after a failed initial acquisition', async () => {
|
||||
keepAwake.activate.mockRejectedValueOnce(new Error('Unable to activate keep awake'))
|
||||
const owner = new MobileDictationKeepAwakeOwner()
|
||||
await expect(owner.acquire('current')).rejects.toThrow('Unable to activate keep awake')
|
||||
expect(keepAwake.deactivate).not.toHaveBeenCalled()
|
||||
|
||||
await owner.reacquire('current')
|
||||
const tag = keepAwake.activate.mock.calls[1]?.[0]
|
||||
expect(keepAwake.activate).toHaveBeenCalledTimes(2)
|
||||
expect(tag).toContain(':current')
|
||||
// A definite rejection activated nothing, so recovery must not deactivate.
|
||||
expect(keepAwake.deactivate).not.toHaveBeenCalled()
|
||||
|
||||
await owner.release('current')
|
||||
expect(keepAwake.deactivate).toHaveBeenLastCalledWith(tag)
|
||||
})
|
||||
|
||||
it('recovers keep-awake on a later reacquire after a failed refresh', async () => {
|
||||
const owner = new MobileDictationKeepAwakeOwner()
|
||||
await owner.acquire('current')
|
||||
const tag = keepAwake.activate.mock.calls[0]?.[0]
|
||||
|
||||
// Refresh loses the activation: deactivate succeeds, activate rejects.
|
||||
keepAwake.activate.mockRejectedValueOnce(new Error('Unable to activate keep awake'))
|
||||
await expect(owner.reacquire('current')).rejects.toThrow('Unable to activate keep awake')
|
||||
|
||||
await owner.reacquire('current')
|
||||
expect(keepAwake.activate).toHaveBeenCalledTimes(3)
|
||||
expect(keepAwake.activate.mock.calls[2]?.[0]).toBe(tag)
|
||||
// Only the pre-refresh deactivate ran; recovery must not deactivate again.
|
||||
expect(keepAwake.deactivate).toHaveBeenCalledTimes(1)
|
||||
|
||||
await owner.release('current')
|
||||
expect(keepAwake.deactivate).toHaveBeenLastCalledWith(tag)
|
||||
})
|
||||
|
||||
it('records new-dictation intent even when previous-tag cleanup fails', async () => {
|
||||
const owner = new MobileDictationKeepAwakeOwner()
|
||||
await owner.acquire('first')
|
||||
|
||||
// Release and its trailing drain both fail; the owner keeps stale intent.
|
||||
keepAwake.deactivate
|
||||
.mockRejectedValueOnce(new Error('Activity unavailable'))
|
||||
.mockRejectedValueOnce(new Error('Activity unavailable'))
|
||||
await expect(owner.release('first')).rejects.toThrow('Activity unavailable')
|
||||
|
||||
// The next acquire's drain and previous-tag cleanup fail too, and the new
|
||||
// activation itself fails — intent must still be recorded for the heal.
|
||||
keepAwake.deactivate
|
||||
.mockRejectedValueOnce(new Error('Activity unavailable'))
|
||||
.mockRejectedValueOnce(new Error('Activity unavailable'))
|
||||
keepAwake.activate.mockRejectedValueOnce(new Error('Unable to activate keep awake'))
|
||||
await expect(owner.acquire('second')).rejects.toThrow('Unable to activate keep awake')
|
||||
|
||||
await owner.reacquire('second')
|
||||
expect(keepAwake.activate).toHaveBeenCalledTimes(3)
|
||||
expect(keepAwake.activate.mock.calls.at(-1)?.[0]).toContain(':second')
|
||||
|
||||
await owner.release('second')
|
||||
})
|
||||
|
||||
it('keeps tracking a newer timed-out activation when an older one settles late', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const first = deferred()
|
||||
keepAwake.activate.mockImplementationOnce(() => first.promise)
|
||||
const owner = new MobileDictationKeepAwakeOwner()
|
||||
const acquire = owner.acquire('stacked')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
await vi.advanceTimersByTimeAsync(MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS)
|
||||
await expect(acquire).rejects.toThrow('Keep-awake native call timed out')
|
||||
|
||||
const second = deferred()
|
||||
keepAwake.activate.mockImplementationOnce(() => second.promise)
|
||||
const reacquire = owner.reacquire('stacked')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
await vi.advanceTimersByTimeAsync(MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS)
|
||||
await expect(reacquire).rejects.toThrow('Keep-awake native call timed out')
|
||||
|
||||
// The older activation settles late with a rejection; the newer
|
||||
// activation's tracking must survive it.
|
||||
first.reject(new Error('Activity unavailable'))
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
await owner.release('stacked')
|
||||
// Exactly two: the reacquire's deactivate-first pass, plus the release
|
||||
// drain cleaning the newer entry the stale settle must not have deleted.
|
||||
expect(
|
||||
keepAwake.deactivate.mock.calls.filter(([tag]) => tag.includes(':stacked'))
|
||||
).toHaveLength(2)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('reacquires a maybe-active timed-out activation by deactivating first', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
keepAwake.activate.mockImplementationOnce(() => new Promise<void>(() => undefined))
|
||||
const owner = new MobileDictationKeepAwakeOwner()
|
||||
const acquire = owner.acquire('maybe')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
await vi.advanceTimersByTimeAsync(MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS)
|
||||
await expect(acquire).rejects.toThrow('Keep-awake native call timed out')
|
||||
|
||||
await owner.reacquire('maybe')
|
||||
// The ambiguous activation may be natively live; it must be deactivated
|
||||
// before the fresh activate so Android re-applies the window flag.
|
||||
const tag = keepAwake.activate.mock.calls[0]?.[0]
|
||||
expect(keepAwake.deactivate).toHaveBeenCalledWith(tag)
|
||||
expect(keepAwake.deactivate.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
keepAwake.activate.mock.invocationCallOrder[1] ?? 0
|
||||
)
|
||||
|
||||
await owner.release('maybe')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a live tag out of the orphan pool when a refresh deactivation fails', async () => {
|
||||
const ownerA = new MobileDictationKeepAwakeOwner()
|
||||
await ownerA.acquire('live')
|
||||
const liveTag = keepAwake.activate.mock.calls[0]?.[0]
|
||||
|
||||
// Foreground refresh: the deactivate leg fails; the tag stays native-on
|
||||
// and the failure surfaces so the caller's bounded retry can kick in.
|
||||
keepAwake.deactivate.mockRejectedValueOnce(new Error('Activity unavailable'))
|
||||
await expect(ownerA.reacquire('live')).rejects.toThrow('Activity unavailable')
|
||||
expect(keepAwake.deactivate.mock.calls.filter(([tag]) => tag === liveTag)).toHaveLength(1)
|
||||
|
||||
// Another owner's drain must spare the still-wanted live tag.
|
||||
const ownerB = new MobileDictationKeepAwakeOwner()
|
||||
await ownerB.acquire('other')
|
||||
expect(keepAwake.deactivate.mock.calls.filter(([tag]) => tag === liveTag)).toHaveLength(1)
|
||||
|
||||
// The next foreground retries the full deactivate-then-activate refresh.
|
||||
await ownerA.reacquire('live')
|
||||
expect(keepAwake.activate.mock.calls.filter(([tag]) => tag === liveTag)).toHaveLength(2)
|
||||
|
||||
await ownerA.release('live')
|
||||
await ownerB.release('other')
|
||||
})
|
||||
|
||||
it('reacquires by deactivating before activating so Android re-applies the window flag', async () => {
|
||||
const owner = new MobileDictationKeepAwakeOwner()
|
||||
await owner.acquire('current')
|
||||
const tag = keepAwake.activate.mock.calls[0]?.[0]
|
||||
|
||||
await owner.reacquire('current')
|
||||
|
||||
expect(keepAwake.deactivate).toHaveBeenCalledWith(tag)
|
||||
expect(keepAwake.activate.mock.calls).toEqual([[tag], [tag]])
|
||||
expect(keepAwake.deactivate.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
keepAwake.activate.mock.invocationCallOrder[1] ?? 0
|
||||
)
|
||||
|
||||
await owner.reacquire('other')
|
||||
expect(keepAwake.activate).toHaveBeenCalledTimes(2)
|
||||
|
||||
await owner.release('current')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,236 @@
|
||||
import { activateKeepAwakeAsync, deactivateKeepAwake } from 'expo-keep-awake'
|
||||
|
||||
const MOBILE_DICTATION_KEEP_AWAKE_TAG_PREFIX = 'orca-mobile-dictation'
|
||||
|
||||
// Native keep-awake promises can be lost during Activity teardown; a bounded
|
||||
// wait keeps the serialized queue below from wedging dictation until restart.
|
||||
export const MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS = 10_000
|
||||
|
||||
let nextOwnerId = 0
|
||||
let keepAwakeOperation: Promise<void> = Promise.resolve()
|
||||
const activeTags = new Set<string>()
|
||||
const pendingCleanupTags = new Set<string>()
|
||||
// Timed-out activations that may still land natively, keyed to a predicate
|
||||
// saying whether their dictation still wants the tag.
|
||||
const pendingActivations = new Map<string, () => boolean>()
|
||||
|
||||
function createOwnerId(): string {
|
||||
nextOwnerId += 1
|
||||
return `${Date.now()}-${nextOwnerId}-${Math.random().toString(36).slice(2)}`
|
||||
}
|
||||
|
||||
function enqueueKeepAwakeOperation(action: () => Promise<void>): Promise<void> {
|
||||
const operation = keepAwakeOperation.then(action)
|
||||
keepAwakeOperation = operation.catch(() => undefined)
|
||||
return operation
|
||||
}
|
||||
|
||||
const KEEP_AWAKE_TIMEOUT_ERROR_NAME = 'KeepAwakeNativeTimeoutError'
|
||||
|
||||
function isNativeCallTimeout(err: unknown): boolean {
|
||||
return err instanceof Error && err.name === KEEP_AWAKE_TIMEOUT_ERROR_NAME
|
||||
}
|
||||
|
||||
function withNativeCallTimeout(nativeCall: Promise<void>): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
const timeoutError = new Error('Keep-awake native call timed out')
|
||||
timeoutError.name = KEEP_AWAKE_TIMEOUT_ERROR_NAME
|
||||
reject(timeoutError)
|
||||
}, MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS)
|
||||
nativeCall.then(
|
||||
() => {
|
||||
clearTimeout(timer)
|
||||
resolve()
|
||||
},
|
||||
(err: unknown) => {
|
||||
clearTimeout(timer)
|
||||
reject(err instanceof Error ? err : new Error(String(err)))
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async function activateTrackedTag(tag: string, isStillWanted: () => boolean): Promise<void> {
|
||||
const nativeActivation = activateKeepAwakeAsync(tag)
|
||||
try {
|
||||
await withNativeCallTimeout(nativeActivation)
|
||||
} catch (err) {
|
||||
// Only a timeout is ambiguous: the activation can still take effect late.
|
||||
// A definite native rejection activated nothing and needs no cleanup.
|
||||
if (isNativeCallTimeout(err)) {
|
||||
// Tracked apart from deactivation retries so drains (including another
|
||||
// owner's) never turn off an activation its dictation still wants.
|
||||
pendingActivations.set(tag, isStillWanted)
|
||||
nativeActivation.then(
|
||||
() =>
|
||||
void enqueueKeepAwakeOperation(async () => {
|
||||
// Delete only this activation's own entry — a newer timed-out
|
||||
// activation of the same tag may have replaced it.
|
||||
if (pendingActivations.get(tag) === isStillWanted) {
|
||||
pendingActivations.delete(tag)
|
||||
}
|
||||
if (activeTags.has(tag)) {
|
||||
return
|
||||
}
|
||||
if (isStillWanted()) {
|
||||
// The activation landed late but its dictation is still live;
|
||||
// adopt it rather than turning off screen-lock protection.
|
||||
activeTags.add(tag)
|
||||
return
|
||||
}
|
||||
// No owner wants it anymore — the screen must not stay awake.
|
||||
await deactivateTrackedTag(tag).catch(() => undefined)
|
||||
}),
|
||||
() => {
|
||||
// A late definite rejection means nothing activated after all, but
|
||||
// spare a newer activation's entry keyed to the same tag.
|
||||
if (pendingActivations.get(tag) === isStillWanted) {
|
||||
pendingActivations.delete(tag)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
throw err
|
||||
}
|
||||
activeTags.add(tag)
|
||||
pendingCleanupTags.delete(tag)
|
||||
pendingActivations.delete(tag)
|
||||
}
|
||||
|
||||
async function deactivateTrackedTag(tag: string): Promise<void> {
|
||||
try {
|
||||
await withNativeCallTimeout(deactivateKeepAwake(tag))
|
||||
} catch (err) {
|
||||
// A replacement hook must be able to retry cleanup after Android replaces
|
||||
// an Activity and the owner that acquired this tag has unmounted.
|
||||
pendingCleanupTags.add(tag)
|
||||
throw err
|
||||
}
|
||||
activeTags.delete(tag)
|
||||
pendingCleanupTags.delete(tag)
|
||||
pendingActivations.delete(tag)
|
||||
}
|
||||
|
||||
async function cleanupPendingTags(): Promise<void> {
|
||||
const staleTags = new Set(pendingCleanupTags)
|
||||
for (const [tag, isStillWanted] of pendingActivations) {
|
||||
// A still-wanted timed-out activation is not an orphan: deactivating it
|
||||
// would turn off screen-lock protection for a live dictation.
|
||||
if (!isStillWanted()) {
|
||||
pendingActivations.delete(tag)
|
||||
staleTags.add(tag)
|
||||
}
|
||||
}
|
||||
if (staleTags.size === 0) {
|
||||
return
|
||||
}
|
||||
// Retry concurrently so N stale tags cost one timeout window, not N, and
|
||||
// swallow failures: a stale tag that still cannot be deactivated must not
|
||||
// fail the fresh acquire that triggered this retry; it stays queued.
|
||||
await Promise.allSettled(
|
||||
Array.from(staleTags, (tag) => deactivateTrackedTag(tag).catch(() => undefined))
|
||||
)
|
||||
}
|
||||
|
||||
export class MobileDictationKeepAwakeOwner {
|
||||
private readonly ownerId = createOwnerId()
|
||||
private acquiredTag: string | null = null
|
||||
|
||||
acquire(dictationId: string): Promise<void> {
|
||||
const tag = this.createTag(dictationId)
|
||||
return enqueueKeepAwakeOperation(async () => {
|
||||
await cleanupPendingTags()
|
||||
if (this.acquiredTag && !activeTags.has(this.acquiredTag)) {
|
||||
this.acquiredTag = null
|
||||
}
|
||||
if (this.acquiredTag === tag) {
|
||||
return
|
||||
}
|
||||
if (this.acquiredTag) {
|
||||
const previousTag = this.acquiredTag
|
||||
this.acquiredTag = null
|
||||
// Best-effort: a failed previous-tag cleanup is queued for retry and
|
||||
// must not block recording intent for the new dictation below.
|
||||
await deactivateTrackedTag(previousTag).catch(() => undefined)
|
||||
}
|
||||
// Record ownership before the native call: acquiredTag is intent while
|
||||
// activeTags is native state, so a failed initial activation can still
|
||||
// be healed by a later foreground reacquire.
|
||||
this.acquiredTag = tag
|
||||
await activateTrackedTag(tag, () => this.acquiredTag === tag)
|
||||
})
|
||||
}
|
||||
|
||||
// Android keeps FLAG_KEEP_SCREEN_ON on the Activity window, so a recreated
|
||||
// Activity silently loses it while native tags persist — and native activate
|
||||
// skips re-applying the flag while any tag remains, so deactivate first.
|
||||
reacquire(dictationId: string): Promise<void> {
|
||||
const tag = this.createTag(dictationId)
|
||||
return enqueueKeepAwakeOperation(async () => {
|
||||
await cleanupPendingTags()
|
||||
if (this.acquiredTag !== tag) {
|
||||
return
|
||||
}
|
||||
// A timed-out activation may be natively active too, and Android only
|
||||
// re-applies the window flag from an empty tag set — deactivate both.
|
||||
if (activeTags.has(tag) || pendingActivations.has(tag)) {
|
||||
try {
|
||||
await deactivateTrackedTag(tag)
|
||||
} catch (err) {
|
||||
// A still-live tag must not sit in the orphan pool where another
|
||||
// owner's drain would turn it off without reactivating; keep it in
|
||||
// the wanted pool and surface the failure so the caller can retry
|
||||
// before the next foreground event.
|
||||
pendingCleanupTags.delete(tag)
|
||||
pendingActivations.set(tag, () => this.acquiredTag === tag)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
// Also recovers an activation lost to an earlier native failure, so a
|
||||
// later foreground event can restore keep-awake instead of no-oping.
|
||||
// Known gap: if another expo-keep-awake owner exists (e.g. dev-build
|
||||
// dev tools), the native module never empties its tag set, so the
|
||||
// deactivate/activate cycle cannot re-apply the Android window flag.
|
||||
await activateTrackedTag(tag, () => this.acquiredTag === tag)
|
||||
})
|
||||
}
|
||||
|
||||
release(dictationId?: string): Promise<void> {
|
||||
const targetTag = dictationId ? this.createTag(dictationId) : null
|
||||
return enqueueKeepAwakeOperation(async () => {
|
||||
try {
|
||||
const tag = this.acquiredTag
|
||||
if (!tag || (targetTag && tag !== targetTag)) {
|
||||
return
|
||||
}
|
||||
if (!activeTags.has(tag)) {
|
||||
this.acquiredTag = null
|
||||
return
|
||||
}
|
||||
await deactivateTrackedTag(tag)
|
||||
this.acquiredTag = null
|
||||
} finally {
|
||||
// Drain after the owner-local unset so this owner's own timed-out
|
||||
// activation is no longer wanted and gets cleaned here — an acquire
|
||||
// may never happen again this session. Still-wanted tags of other
|
||||
// live dictations are spared by the drain itself.
|
||||
await cleanupPendingTags()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private createTag(dictationId: string): string {
|
||||
return `${MOBILE_DICTATION_KEEP_AWAKE_TAG_PREFIX}:${this.ownerId}:${dictationId}`
|
||||
}
|
||||
}
|
||||
|
||||
export function createMobileDictationKeepAwakeOwner(): MobileDictationKeepAwakeOwner {
|
||||
return new MobileDictationKeepAwakeOwner()
|
||||
}
|
||||
|
||||
// Foreground is the retry point for wake tags whose final deactivation timed
|
||||
// out after a dictation ended — otherwise nothing runs until the next one.
|
||||
export function drainMobileDictationKeepAwakeCleanup(): Promise<void> {
|
||||
return enqueueKeepAwakeOperation(cleanupPendingTags)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// Why: import from 'buffer' (the npm polyfill), not 'node:buffer' because
|
||||
// Metro cannot resolve Node builtins in a React Native bundle.
|
||||
import { Buffer } from 'buffer'
|
||||
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
|
||||
export type DictationStatus = 'idle' | 'starting' | 'recording' | 'processing' | 'error'
|
||||
|
||||
export type UseMobileDictationOptions = {
|
||||
client: RpcClient | null
|
||||
enabled: boolean
|
||||
onTranscript: (text: string) => void
|
||||
onError?: (error: Error) => void
|
||||
}
|
||||
|
||||
export type UseMobileDictationResult = {
|
||||
status: DictationStatus
|
||||
isStarting: boolean
|
||||
isRecording: boolean
|
||||
isProcessing: boolean
|
||||
error: string | null
|
||||
start: () => Promise<void>
|
||||
stop: () => Promise<void>
|
||||
cancel: () => Promise<void>
|
||||
}
|
||||
|
||||
export const DICTATION_FINISH_TIMEOUT_MS = 75_000
|
||||
|
||||
// Recording start waits at most this long for the best-effort wake tag; a
|
||||
// slow or hung native keep-awake module must not hold the mic in 'starting'.
|
||||
export const MOBILE_DICTATION_KEEP_AWAKE_STARTUP_BUDGET_MS = 500
|
||||
|
||||
export function bytesToBase64(bytes: Uint8Array): string {
|
||||
return Buffer.from(bytes).toString('base64')
|
||||
}
|
||||
|
||||
export function createMobileDictationId(): string {
|
||||
return `mobile-dictation-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
}
|
||||
|
||||
export function isCurrentMobileDictationStart(
|
||||
currentGeneration: number,
|
||||
generation: number,
|
||||
enabled: boolean,
|
||||
activeId: string | null,
|
||||
dictationId: string
|
||||
): boolean {
|
||||
return currentGeneration === generation && enabled && activeId === dictationId
|
||||
}
|
||||
|
||||
export function isCurrentMobileDictationFinish(
|
||||
currentGeneration: number,
|
||||
generation: number,
|
||||
enabled: boolean,
|
||||
activeId: string | null,
|
||||
finishingId: string | null,
|
||||
dictationId: string
|
||||
): boolean {
|
||||
return (
|
||||
currentGeneration === generation &&
|
||||
enabled &&
|
||||
activeId === dictationId &&
|
||||
finishingId === dictationId
|
||||
)
|
||||
}
|
||||
@@ -2,13 +2,41 @@ import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const source = readFileSync(new URL('./use-mobile-dictation.ts', import.meta.url), 'utf8')
|
||||
const audioChunkSource = readFileSync(
|
||||
new URL('./mobile-dictation-audio-chunk.ts', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
const keepAwakeSource = readFileSync(
|
||||
new URL('./mobile-dictation-keep-awake.ts', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
const desktopStartSource = readFileSync(
|
||||
new URL('./mobile-dictation-desktop-start.ts', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
const sessionStateSource = readFileSync(
|
||||
new URL('./mobile-dictation-session-state.ts', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
const foregroundKeepAwakeSource = readFileSync(
|
||||
new URL('./mobile-dictation-foreground-keep-awake.ts', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
function sliceSource(sourceText: string, startPattern: string, endPattern: string): string {
|
||||
const start = sourceText.indexOf(startPattern)
|
||||
expect(start).toBeGreaterThanOrEqual(0)
|
||||
const end = sourceText.indexOf(endPattern, start)
|
||||
expect(end).toBeGreaterThan(start)
|
||||
return sourceText.slice(start, end)
|
||||
}
|
||||
|
||||
function sliceBetween(startPattern: string, endPattern: string): string {
|
||||
const start = source.indexOf(startPattern)
|
||||
expect(start).toBeGreaterThanOrEqual(0)
|
||||
const end = source.indexOf(endPattern, start)
|
||||
expect(end).toBeGreaterThan(start)
|
||||
return source.slice(start, end)
|
||||
return sliceSource(source, startPattern, endPattern)
|
||||
}
|
||||
|
||||
function sliceDesktopStartBetween(startPattern: string, endPattern: string): string {
|
||||
return sliceSource(desktopStartSource, startPattern, endPattern)
|
||||
}
|
||||
|
||||
describe('useMobileDictation source invariants', () => {
|
||||
@@ -28,9 +56,10 @@ describe('useMobileDictation source invariants', () => {
|
||||
})
|
||||
|
||||
it('reserves pending audio bytes before encoding microphone chunks', () => {
|
||||
const microphoneEffect = sliceBetween(
|
||||
"addExpoTwoWayAudioEventListener('onMicrophoneData'",
|
||||
'return () => sub.remove()'
|
||||
const microphoneEffect = sliceSource(
|
||||
audioChunkSource,
|
||||
'export function enqueueMobileDictationAudioChunk',
|
||||
' queue.pendingChunks.add(sendChunk)'
|
||||
)
|
||||
|
||||
const reserveIndex = microphoneEffect.indexOf('tryReserve(byteLength)')
|
||||
@@ -39,7 +68,19 @@ describe('useMobileDictation source invariants', () => {
|
||||
expect(encodeIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(reserveIndex).toBeLessThan(encodeIndex)
|
||||
expect(microphoneEffect).toContain('MOBILE_DICTATION_CONNECTION_SLOW_ERROR_MESSAGE')
|
||||
expect(microphoneEffect).toContain('pendingAudioBudgetRef.current.release(byteLength)')
|
||||
expect(microphoneEffect).toContain('queue.pendingAudioBudget.release(byteLength)')
|
||||
expect(source).toContain('enqueueMobileDictationAudioChunk(client, dictationId, event')
|
||||
})
|
||||
|
||||
it('reuses audio chunk queue wiring across microphone events', () => {
|
||||
const queueIndex = source.indexOf('const audioChunkQueue =')
|
||||
const listenerIndex = source.indexOf("addExpoTwoWayAudioEventListener('onMicrophoneData'")
|
||||
|
||||
expect(queueIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(queueIndex).toBeLessThan(listenerIndex)
|
||||
expect(source).toContain(
|
||||
'enqueueMobileDictationAudioChunk(client, dictationId, event, audioChunkQueue)'
|
||||
)
|
||||
})
|
||||
|
||||
it('resets pending audio bytes whenever pending chunk tracking is cleared', () => {
|
||||
@@ -48,4 +89,198 @@ describe('useMobileDictation source invariants', () => {
|
||||
|
||||
expect(pendingAudioResets).toHaveLength(pendingChunkClears.length)
|
||||
})
|
||||
|
||||
it('keeps mobile dictation keep-awake ownership beside the hook', () => {
|
||||
expect(source).toMatch(
|
||||
/import \{[^}]*createMobileDictationKeepAwakeOwner[^}]*\} from '\.\/mobile-dictation-keep-awake'/
|
||||
)
|
||||
expect(source).toContain(
|
||||
'const keepAwakeOwner = useMemo(createMobileDictationKeepAwakeOwner, [])'
|
||||
)
|
||||
expect(keepAwakeSource).toContain('activateKeepAwakeAsync')
|
||||
expect(keepAwakeSource).toContain('deactivateKeepAwake')
|
||||
expect(keepAwakeSource).not.toMatch(/\bactivateKeepAwake\s*\(/)
|
||||
})
|
||||
|
||||
it('acquires keep-awake only after desktop start and stale-start guards', () => {
|
||||
const hookStartBody = sliceBetween('const start = useCallback(async () => {', 'const stop =')
|
||||
const startBody = sliceDesktopStartBetween(
|
||||
'export async function startMobileDictationDesktopSession',
|
||||
' return true'
|
||||
)
|
||||
const desktopStartIndex = startBody.indexOf(
|
||||
"client.sendRequest('speech.dictation.start', { dictationId })"
|
||||
)
|
||||
const acquireIndex = startBody.indexOf('.acquire(dictationId)')
|
||||
const desktopSessionIndex = hookStartBody.indexOf('await startMobileDictationDesktopSession')
|
||||
const toggleRecordingIndex = hookStartBody.indexOf('toggleRecording(true)')
|
||||
|
||||
expect(desktopStartIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(acquireIndex).toBeGreaterThan(desktopStartIndex)
|
||||
expect(desktopSessionIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(toggleRecordingIndex).toBeGreaterThan(desktopSessionIndex)
|
||||
expect(hookStartBody).toContain('commitRecordingStart: () => {')
|
||||
expect(startBody).toContain('options.commitRecordingStart()')
|
||||
|
||||
const beforeAcquire = startBody.slice(desktopStartIndex, acquireIndex)
|
||||
expect(beforeAcquire).toContain('isCurrentStart(options)')
|
||||
expect(desktopStartSource).toContain('options.getCurrentGeneration()')
|
||||
expect(desktopStartSource).toContain('options.getEnabled()')
|
||||
expect(desktopStartSource).toContain('options.getActiveId()')
|
||||
expect(sessionStateSource).toContain(
|
||||
'currentGeneration === generation && enabled && activeId === dictationId'
|
||||
)
|
||||
})
|
||||
|
||||
it('re-checks stale-start guards after awaited keep-awake acquisition', () => {
|
||||
const startBody = sliceDesktopStartBetween(
|
||||
'export async function startMobileDictationDesktopSession',
|
||||
' return true'
|
||||
)
|
||||
const acquireIndex = startBody.indexOf('.acquire(dictationId)')
|
||||
const returnStartedIndex = startBody.indexOf('return true')
|
||||
const afterAcquire = startBody.slice(acquireIndex, returnStartedIndex)
|
||||
|
||||
expect(afterAcquire).toContain('isCurrentStart(options)')
|
||||
expect(desktopStartSource).toContain('options.getCurrentGeneration()')
|
||||
expect(desktopStartSource).toContain('options.getEnabled()')
|
||||
expect(desktopStartSource).toContain('options.getActiveId()')
|
||||
expect(afterAcquire).toContain('await cancelStaleStart(options, { releaseKeepAwake: true })')
|
||||
|
||||
// The stale-start cleanup must release the wake tag and cancel the desktop
|
||||
// session (concurrently, so a hung acquisition can't delay the cancel).
|
||||
const cancelStaleStartBody = sliceDesktopStartBetween(
|
||||
'async function cancelStaleStart',
|
||||
'export async function startMobileDictationDesktopSession'
|
||||
)
|
||||
expect(cancelStaleStartBody).toContain(
|
||||
"client.sendRequest('speech.dictation.cancel', { dictationId })"
|
||||
)
|
||||
expect(cancelStaleStartBody).toContain('cleanups.push(keepAwakeOwner.release(dictationId))')
|
||||
expect(cancelStaleStartBody).toContain('await Promise.allSettled(cleanups)')
|
||||
})
|
||||
|
||||
it('treats keep-awake acquisition as best-effort for the desktop session', () => {
|
||||
const startBody = sliceDesktopStartBetween(
|
||||
'export async function startMobileDictationDesktopSession',
|
||||
' return true'
|
||||
)
|
||||
const acquireIndex = startBody.indexOf('.acquire(dictationId)')
|
||||
expect(acquireIndex).toBeGreaterThanOrEqual(0)
|
||||
const staleCheckIndex = startBody.indexOf('isCurrentStart(options)', acquireIndex)
|
||||
expect(staleCheckIndex).toBeGreaterThan(acquireIndex)
|
||||
|
||||
// An acquisition failure must not cancel the dictation or surface an error.
|
||||
const acquireChain = startBody.slice(acquireIndex, staleCheckIndex)
|
||||
expect(acquireChain).toContain('.catch(')
|
||||
expect(acquireChain).not.toContain('throw')
|
||||
})
|
||||
|
||||
it('releases keep-awake on all dictation cleanup paths without delaying recording shutdown', () => {
|
||||
const closeAudio = sliceBetween(
|
||||
'const closeDictationAudio = useCallback(',
|
||||
'const failActiveDictation ='
|
||||
)
|
||||
expect(closeAudio.indexOf('toggleRecording(false)')).toBeLessThan(
|
||||
closeAudio.indexOf('void keepAwakeOwner.release')
|
||||
)
|
||||
expect(closeAudio).toContain('.catch(() => undefined)')
|
||||
|
||||
const cleanupSlices = [
|
||||
sliceBetween('const failActiveDictation = useCallback(', 'useEffect(() => {'),
|
||||
sliceBetween('const cancel = useCallback(async () => {', 'useEffect(() => {\n const sub'),
|
||||
sliceBetween('return () => {\n const dictationId = activeIdRef.current', ' return {')
|
||||
]
|
||||
|
||||
for (const cleanupSlice of cleanupSlices) {
|
||||
expect(cleanupSlice).toContain('closeDictationAudio(dictationId)')
|
||||
}
|
||||
|
||||
const stopBody = sliceBetween('const stop = useCallback(async () => {', 'const cancel =')
|
||||
expect(stopBody.indexOf('toggleRecording(false)')).toBeLessThan(
|
||||
stopBody.indexOf('await Promise.allSettled')
|
||||
)
|
||||
// The wake tag must be held through chunk drain and the finish RPC so a
|
||||
// screen lock cannot suspend the app before the transcript arrives.
|
||||
expect(stopBody.indexOf('speech.dictation.finish')).toBeLessThan(
|
||||
stopBody.indexOf('void keepAwakeOwner.release')
|
||||
)
|
||||
expect(stopBody.indexOf('} finally {')).toBeLessThan(
|
||||
stopBody.indexOf('void keepAwakeOwner.release')
|
||||
)
|
||||
})
|
||||
|
||||
it('reacquires the wake tag when Android returns to the foreground mid-dictation', () => {
|
||||
expect(source).toContain('useMobileDictationForegroundKeepAwake(keepAwakeOwner, activeIdRef)')
|
||||
expect(foregroundKeepAwakeSource).toContain("Platform.OS !== 'android'")
|
||||
expect(foregroundKeepAwakeSource).toContain('keepAwakeOwner.reacquire(dictationId)')
|
||||
// A transiently failing refresh retries while the dictation is live.
|
||||
expect(foregroundKeepAwakeSource).toContain('REACQUIRE_RETRY_DELAYS_MS[attempt]')
|
||||
expect(foregroundKeepAwakeSource).toContain('activeIdRef.current === dictationId')
|
||||
// Stale-tag retries survive hook unmount via a module-level listener.
|
||||
expect(foregroundKeepAwakeSource).toContain('installGlobalStaleTagForegroundDrain()')
|
||||
expect(foregroundKeepAwakeSource).toContain('drainMobileDictationKeepAwakeCleanup()')
|
||||
|
||||
// Native activate skips re-applying the window flag while any tag remains,
|
||||
// so reacquire must deactivate before activating.
|
||||
const reacquireBody = sliceSource(
|
||||
keepAwakeSource,
|
||||
'reacquire(dictationId: string)',
|
||||
'release(dictationId?: string)'
|
||||
)
|
||||
expect(reacquireBody.indexOf('await activateTrackedTag(tag,')).toBeGreaterThanOrEqual(0)
|
||||
expect(reacquireBody.indexOf('deactivateTrackedTag(tag)')).toBeGreaterThanOrEqual(0)
|
||||
expect(reacquireBody.indexOf('deactivateTrackedTag(tag)')).toBeLessThan(
|
||||
reacquireBody.indexOf('await activateTrackedTag(tag,')
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps cleanup going when native recording shutdown throws', () => {
|
||||
const closeAudio = sliceBetween(
|
||||
'const closeDictationAudio = useCallback(',
|
||||
'const failActiveDictation ='
|
||||
)
|
||||
const toggleIndex = closeAudio.indexOf('toggleRecording(false)')
|
||||
const catchIndex = closeAudio.indexOf('} catch', toggleIndex)
|
||||
const releaseIndex = closeAudio.indexOf('void keepAwakeOwner.release')
|
||||
expect(toggleIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(catchIndex).toBeGreaterThan(toggleIndex)
|
||||
expect(catchIndex).toBeLessThan(releaseIndex)
|
||||
|
||||
// stop()'s recording shutdown sits inside the try so a native throw still
|
||||
// runs the finally release and error cleanup.
|
||||
const stopBody = sliceBetween('const stop = useCallback(async () => {', 'const cancel =')
|
||||
expect(stopBody.indexOf('try {')).toBeGreaterThanOrEqual(0)
|
||||
expect(stopBody.indexOf('try {')).toBeLessThan(stopBody.indexOf('toggleRecording(false)'))
|
||||
})
|
||||
|
||||
it('routes disabled state and audio interruptions through cancel cleanup', () => {
|
||||
const interruptionEffect = sliceBetween(
|
||||
"addExpoTwoWayAudioEventListener('onAudioInterruption'",
|
||||
'return () => sub.remove()'
|
||||
)
|
||||
const disabledEffect = sliceBetween(
|
||||
'useEffect(() => {\n if (!enabled) {',
|
||||
' }, [cancel, enabled])'
|
||||
)
|
||||
|
||||
expect(interruptionEffect).toContain("event.data === 'began' || event.data === 'blocked'")
|
||||
expect(interruptionEffect).toContain('void cancel()')
|
||||
expect(disabledEffect).toContain('void cancel()')
|
||||
})
|
||||
|
||||
it('uses per-owner dictation keep-awake tags and serializes async ownership changes', () => {
|
||||
expect(keepAwakeSource).toContain('private readonly ownerId = createOwnerId()')
|
||||
expect(keepAwakeSource).toContain(
|
||||
'`${MOBILE_DICTATION_KEEP_AWAKE_TAG_PREFIX}:${this.ownerId}:${dictationId}`'
|
||||
)
|
||||
expect(keepAwakeSource).toContain('let keepAwakeOperation: Promise<void> = Promise.resolve()')
|
||||
expect(keepAwakeSource).toContain('const pendingCleanupTags = new Set<string>()')
|
||||
expect(keepAwakeSource.match(/enqueueKeepAwakeOperation/g)?.length).toBeGreaterThanOrEqual(3)
|
||||
expect(keepAwakeSource).toContain(
|
||||
'const targetTag = dictationId ? this.createTag(dictationId) : null'
|
||||
)
|
||||
expect(keepAwakeSource).toContain('if (!tag || (targetTag && tag !== targetTag))')
|
||||
expect(keepAwakeSource).toContain('await cleanupPendingTags()')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
// Why: import from 'buffer' (the npm polyfill), not 'node:buffer' — Metro
|
||||
// can't resolve Node's builtin in a React Native bundle.
|
||||
import { Buffer } from 'buffer'
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
addExpoTwoWayAudioEventListener,
|
||||
initialize,
|
||||
@@ -9,45 +6,27 @@ import {
|
||||
tearDown,
|
||||
toggleRecording
|
||||
} from '@orca/expo-two-way-audio'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { MobileDictationPendingAudioBudget } from './mobile-dictation-pending-audio-budget'
|
||||
import { enqueueMobileDictationAudioChunk } from './mobile-dictation-audio-chunk'
|
||||
import { createMobileDictationKeepAwakeOwner } from './mobile-dictation-keep-awake'
|
||||
import { useMobileDictationForegroundKeepAwake } from './mobile-dictation-foreground-keep-awake'
|
||||
import {
|
||||
MOBILE_DICTATION_CONNECTION_SLOW_ERROR_MESSAGE,
|
||||
MOBILE_DICTATION_PCM_SAMPLE_RATE,
|
||||
MobileDictationPendingAudioBudget
|
||||
} from './mobile-dictation-pending-audio-budget'
|
||||
DICTATION_FINISH_TIMEOUT_MS,
|
||||
createMobileDictationId,
|
||||
isCurrentMobileDictationFinish
|
||||
} from './mobile-dictation-session-state'
|
||||
import { startMobileDictationDesktopSession } from './mobile-dictation-desktop-start'
|
||||
import type {
|
||||
DictationStatus,
|
||||
UseMobileDictationOptions,
|
||||
UseMobileDictationResult
|
||||
} from './mobile-dictation-session-state'
|
||||
|
||||
type DictationStatus = 'idle' | 'starting' | 'recording' | 'processing' | 'error'
|
||||
|
||||
type UseMobileDictationOptions = {
|
||||
client: RpcClient | null
|
||||
enabled: boolean
|
||||
onTranscript: (text: string) => void
|
||||
onError?: (error: Error) => void
|
||||
}
|
||||
|
||||
export type UseMobileDictationResult = {
|
||||
status: DictationStatus
|
||||
isStarting: boolean
|
||||
isRecording: boolean
|
||||
isProcessing: boolean
|
||||
error: string | null
|
||||
start: () => Promise<void>
|
||||
stop: () => Promise<void>
|
||||
cancel: () => Promise<void>
|
||||
}
|
||||
|
||||
const DICTATION_FINISH_TIMEOUT_MS = 75_000
|
||||
|
||||
function bytesToBase64(bytes: Uint8Array): string {
|
||||
return Buffer.from(bytes).toString('base64')
|
||||
}
|
||||
|
||||
function createDictationId(): string {
|
||||
return `mobile-dictation-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
}
|
||||
export type { UseMobileDictationResult } from './mobile-dictation-session-state'
|
||||
|
||||
export function useMobileDictation(options: UseMobileDictationOptions): UseMobileDictationResult {
|
||||
const { client, enabled, onTranscript, onError } = options
|
||||
const keepAwakeOwner = useMemo(createMobileDictationKeepAwakeOwner, [])
|
||||
const [status, setStatus] = useState<DictationStatus>('idle')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const activeIdRef = useRef<string | null>(null)
|
||||
@@ -77,6 +56,23 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil
|
||||
onErrorRef.current?.(normalized)
|
||||
}, [])
|
||||
|
||||
const closeDictationAudio = useCallback(
|
||||
(dictationId?: string | null) => {
|
||||
acceptingChunksRef.current = false
|
||||
pendingChunksRef.current.clear()
|
||||
pendingAudioBudgetRef.current.reset()
|
||||
try {
|
||||
toggleRecording(false)
|
||||
} catch (err) {
|
||||
// Cleanup must keep going when native recording shutdown throws, or
|
||||
// the wake tag and dictation state would leak.
|
||||
console.error('Failed to stop microphone recording', err)
|
||||
}
|
||||
void keepAwakeOwner.release(dictationId ?? undefined).catch(() => undefined)
|
||||
},
|
||||
[keepAwakeOwner]
|
||||
)
|
||||
|
||||
const failActiveDictation = useCallback(
|
||||
(dictationId: string, err: unknown) => {
|
||||
const client = clientRef.current
|
||||
@@ -84,51 +80,32 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil
|
||||
return
|
||||
}
|
||||
activeIdRef.current = null
|
||||
acceptingChunksRef.current = false
|
||||
pendingChunksRef.current.clear()
|
||||
pendingAudioBudgetRef.current.reset()
|
||||
toggleRecording(false)
|
||||
closeDictationAudio(dictationId)
|
||||
if (client && dictationId) {
|
||||
void client.sendRequest('speech.dictation.cancel', { dictationId }).catch(() => undefined)
|
||||
}
|
||||
reportError(err)
|
||||
},
|
||||
[reportError]
|
||||
[closeDictationAudio, reportError]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
// Microphone events are a hot path; reuse this wiring instead of allocating
|
||||
// a queue object and release predicate for every audio chunk.
|
||||
const audioChunkQueue = {
|
||||
pendingChunks: pendingChunksRef.current,
|
||||
pendingAudioBudget: pendingAudioBudgetRef.current,
|
||||
shouldReleaseBudget: (id: string) =>
|
||||
activeIdRef.current === id || finishingIdRef.current === id,
|
||||
failActiveDictation
|
||||
}
|
||||
const sub = addExpoTwoWayAudioEventListener('onMicrophoneData', (event) => {
|
||||
const client = clientRef.current
|
||||
const dictationId = activeIdRef.current
|
||||
if (!client || !dictationId || !enabledRef.current || !acceptingChunksRef.current) {
|
||||
return
|
||||
}
|
||||
const raw = event.data
|
||||
const bytes = raw instanceof Uint8Array ? raw : new Uint8Array(raw)
|
||||
const byteLength = bytes.byteLength
|
||||
if (!pendingAudioBudgetRef.current.tryReserve(byteLength)) {
|
||||
failActiveDictation(dictationId, new Error(MOBILE_DICTATION_CONNECTION_SLOW_ERROR_MESSAGE))
|
||||
return
|
||||
}
|
||||
const sendChunk = client
|
||||
.sendRequest('speech.dictation.chunk', {
|
||||
dictationId,
|
||||
audioBase64: bytesToBase64(bytes),
|
||||
sampleRate: MOBILE_DICTATION_PCM_SAMPLE_RATE
|
||||
})
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(response.error.message)
|
||||
}
|
||||
})
|
||||
.catch((err) => failActiveDictation(dictationId, err))
|
||||
.finally(() => {
|
||||
if (activeIdRef.current === dictationId || finishingIdRef.current === dictationId) {
|
||||
pendingAudioBudgetRef.current.release(byteLength)
|
||||
}
|
||||
pendingChunksRef.current.delete(sendChunk)
|
||||
})
|
||||
pendingChunksRef.current.add(sendChunk)
|
||||
enqueueMobileDictationAudioChunk(client, dictationId, event, audioChunkQueue)
|
||||
})
|
||||
return () => sub.remove()
|
||||
}, [failActiveDictation, reportError])
|
||||
@@ -168,40 +145,41 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil
|
||||
throw new Error('Failed to initialize microphone')
|
||||
}
|
||||
|
||||
const dictationId = createDictationId()
|
||||
const dictationId = createMobileDictationId()
|
||||
activeIdRef.current = dictationId
|
||||
try {
|
||||
const response = await client.sendRequest('speech.dictation.start', { dictationId })
|
||||
if (!response.ok) {
|
||||
throw new Error(response.error.message)
|
||||
}
|
||||
} catch (err) {
|
||||
if (activeIdRef.current === dictationId) {
|
||||
activeIdRef.current = null
|
||||
}
|
||||
await client.sendRequest('speech.dictation.cancel', { dictationId }).catch(() => undefined)
|
||||
setStatus('idle')
|
||||
throw err
|
||||
}
|
||||
if (
|
||||
generationRef.current !== generation ||
|
||||
!enabledRef.current ||
|
||||
activeIdRef.current !== dictationId
|
||||
) {
|
||||
await client.sendRequest('speech.dictation.cancel', { dictationId }).catch(() => undefined)
|
||||
if (activeIdRef.current === dictationId) {
|
||||
activeIdRef.current = null
|
||||
}
|
||||
setStatus('idle')
|
||||
return
|
||||
}
|
||||
|
||||
acceptingChunksRef.current = true
|
||||
pendingChunksRef.current.clear()
|
||||
pendingAudioBudgetRef.current.reset()
|
||||
toggleRecording(true)
|
||||
setStatus('recording')
|
||||
}, [])
|
||||
await startMobileDictationDesktopSession({
|
||||
client,
|
||||
dictationId,
|
||||
generation,
|
||||
getCurrentGeneration: () => generationRef.current,
|
||||
getEnabled: () => enabledRef.current,
|
||||
getActiveId: () => activeIdRef.current,
|
||||
clearActiveId: (id) => {
|
||||
if (activeIdRef.current === id) {
|
||||
activeIdRef.current = null
|
||||
}
|
||||
},
|
||||
setIdle: () => setStatus('idle'),
|
||||
keepAwakeOwner,
|
||||
commitRecordingStart: () => {
|
||||
acceptingChunksRef.current = true
|
||||
pendingChunksRef.current.clear()
|
||||
pendingAudioBudgetRef.current.reset()
|
||||
if (!toggleRecording(true)) {
|
||||
return false
|
||||
}
|
||||
setStatus('recording')
|
||||
return true
|
||||
},
|
||||
rollbackRecordingStart: () => {
|
||||
acceptingChunksRef.current = false
|
||||
pendingChunksRef.current.clear()
|
||||
pendingAudioBudgetRef.current.reset()
|
||||
toggleRecording(false)
|
||||
}
|
||||
})
|
||||
}, [keepAwakeOwner])
|
||||
|
||||
const stop = useCallback(async () => {
|
||||
const client = clientRef.current
|
||||
@@ -215,14 +193,20 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil
|
||||
finishingIdRef.current = dictationId
|
||||
setStatus('processing')
|
||||
acceptingChunksRef.current = false
|
||||
toggleRecording(false)
|
||||
try {
|
||||
// Inside the try so a throwing native shutdown still runs the finally
|
||||
// release and error cleanup.
|
||||
toggleRecording(false)
|
||||
await Promise.allSettled(Array.from(pendingChunksRef.current))
|
||||
if (
|
||||
generationRef.current !== generation ||
|
||||
activeIdRef.current !== dictationId ||
|
||||
finishingIdRef.current !== dictationId ||
|
||||
!enabledRef.current
|
||||
!isCurrentMobileDictationFinish(
|
||||
generationRef.current,
|
||||
generation,
|
||||
enabledRef.current,
|
||||
activeIdRef.current,
|
||||
finishingIdRef.current,
|
||||
dictationId
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
@@ -235,10 +219,14 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil
|
||||
throw new Error(response.error.message)
|
||||
}
|
||||
if (
|
||||
generationRef.current !== generation ||
|
||||
activeIdRef.current !== dictationId ||
|
||||
finishingIdRef.current !== dictationId ||
|
||||
!enabledRef.current
|
||||
!isCurrentMobileDictationFinish(
|
||||
generationRef.current,
|
||||
generation,
|
||||
enabledRef.current,
|
||||
activeIdRef.current,
|
||||
finishingIdRef.current,
|
||||
dictationId
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
@@ -257,11 +245,14 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil
|
||||
} catch (err) {
|
||||
failActiveDictation(dictationId, err)
|
||||
} finally {
|
||||
// Hold the wake tag through chunk drain and the finish RPC: a screen
|
||||
// lock mid-processing suspends the app and loses the transcript.
|
||||
void keepAwakeOwner.release(dictationId).catch(() => undefined)
|
||||
if (finishingIdRef.current === dictationId) {
|
||||
finishingIdRef.current = null
|
||||
}
|
||||
}
|
||||
}, [failActiveDictation])
|
||||
}, [failActiveDictation, keepAwakeOwner])
|
||||
|
||||
const cancel = useCallback(async () => {
|
||||
const client = clientRef.current
|
||||
@@ -269,16 +260,15 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil
|
||||
generationRef.current += 1
|
||||
activeIdRef.current = null
|
||||
finishingIdRef.current = null
|
||||
acceptingChunksRef.current = false
|
||||
pendingChunksRef.current.clear()
|
||||
pendingAudioBudgetRef.current.reset()
|
||||
toggleRecording(false)
|
||||
closeDictationAudio(dictationId)
|
||||
if (client && dictationId) {
|
||||
await client.sendRequest('speech.dictation.cancel', { dictationId }).catch(() => undefined)
|
||||
}
|
||||
setStatus('idle')
|
||||
setError(null)
|
||||
}, [])
|
||||
}, [closeDictationAudio])
|
||||
|
||||
useMobileDictationForegroundKeepAwake(keepAwakeOwner, activeIdRef)
|
||||
|
||||
useEffect(() => {
|
||||
const sub = addExpoTwoWayAudioEventListener('onAudioInterruption', (event) => {
|
||||
@@ -301,10 +291,7 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil
|
||||
generationRef.current += 1
|
||||
activeIdRef.current = null
|
||||
finishingIdRef.current = null
|
||||
acceptingChunksRef.current = false
|
||||
pendingChunksRef.current.clear()
|
||||
pendingAudioBudgetRef.current.reset()
|
||||
toggleRecording(false)
|
||||
closeDictationAudio(dictationId)
|
||||
void tearDown()
|
||||
if (clientRef.current && dictationId) {
|
||||
void clientRef.current
|
||||
@@ -312,7 +299,7 @@ export function useMobileDictation(options: UseMobileDictationOptions): UseMobil
|
||||
.catch(() => undefined)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
}, [closeDictationAudio])
|
||||
|
||||
return {
|
||||
status,
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { ComponentType } from 'react'
|
||||
import { Pressable } from 'react-native'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import { styles } from '../../app/h/[hostId]/session/mobile-session-styles'
|
||||
|
||||
type HeaderIconProps = {
|
||||
size?: number
|
||||
color?: string
|
||||
strokeWidth?: number
|
||||
}
|
||||
|
||||
type MobileSessionHeaderIconButtonProps = {
|
||||
active?: boolean
|
||||
accessibilityLabel: string
|
||||
icon: ComponentType<HeaderIconProps>
|
||||
onPress: () => void
|
||||
}
|
||||
|
||||
export function MobileSessionHeaderIconButton({
|
||||
active = false,
|
||||
accessibilityLabel,
|
||||
icon: Icon,
|
||||
onPress
|
||||
}: MobileSessionHeaderIconButtonProps) {
|
||||
return (
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.filesButton,
|
||||
pressed && styles.filesButtonPressed,
|
||||
active && styles.filesButtonActive
|
||||
]}
|
||||
onPress={onPress}
|
||||
hitSlop={8}
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
>
|
||||
<Icon size={18} color={colors.textSecondary} strokeWidth={2.1} />
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { ListChecks } from 'lucide-react-native'
|
||||
import { MobileAgentSessionHistoryIcon } from '../agent-history/MobileAgentSessionHistoryIcon'
|
||||
import { ActionSheetModal } from '../components/ActionSheetModal'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
|
||||
type Props = {
|
||||
visible: boolean
|
||||
showAgentSessionHistory: boolean
|
||||
showChecks: boolean
|
||||
onOpenAgentSessionHistory: () => void
|
||||
onOpenChecks: () => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function MobileSessionHeaderMoreActionsSheet({
|
||||
visible,
|
||||
showAgentSessionHistory,
|
||||
showChecks,
|
||||
onOpenAgentSessionHistory,
|
||||
onOpenChecks,
|
||||
onClose
|
||||
}: Props) {
|
||||
return (
|
||||
<ActionSheetModal
|
||||
visible={visible}
|
||||
actions={[
|
||||
...(showAgentSessionHistory
|
||||
? [
|
||||
{
|
||||
label: 'Agent History',
|
||||
hint: 'Browse and resume agent sessions',
|
||||
renderIcon: () => (
|
||||
<MobileAgentSessionHistoryIcon
|
||||
size={16}
|
||||
color={colors.textSecondary}
|
||||
strokeWidth={2.1}
|
||||
/>
|
||||
),
|
||||
onPress: onOpenAgentSessionHistory
|
||||
}
|
||||
]
|
||||
: []),
|
||||
...(showChecks
|
||||
? [
|
||||
{
|
||||
label: 'Checks',
|
||||
hint: 'Open pull request checks',
|
||||
icon: ListChecks,
|
||||
onPress: onOpenChecks
|
||||
}
|
||||
]
|
||||
: [])
|
||||
]}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useCallback } from 'react'
|
||||
import { StyleSheet, View } from 'react-native'
|
||||
import {
|
||||
TerminalWebView,
|
||||
type MobileTerminalTheme,
|
||||
type TerminalKeyboardAvoidanceMetrics,
|
||||
type TerminalModes,
|
||||
type TerminalWebViewHandle
|
||||
} from '../terminal/TerminalWebView'
|
||||
import { TerminalWebView } from '../terminal/TerminalWebView'
|
||||
import type {
|
||||
MobileTerminalTheme,
|
||||
TerminalKeyboardAvoidanceMetrics,
|
||||
TerminalModes,
|
||||
TerminalWebViewHandle
|
||||
} from '../terminal/terminal-webview-contract'
|
||||
|
||||
type TerminalPaneViewProps = {
|
||||
handle: string
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { AiVaultSession } from '../../../src/shared/ai-vault-types'
|
||||
import {
|
||||
buildMobileAiVaultResumeLaunch,
|
||||
buildMobileAiVaultResumeCommand,
|
||||
createMobileAiVaultResumeMutationRegistry,
|
||||
readMobileRuntimeHostPlatform,
|
||||
readMobileRuntimeTerminalWindowsShell,
|
||||
resolveMobileAiVaultResumePlatform,
|
||||
resumeAiVaultSessionInTerminal,
|
||||
RESUME_RPC_TIMEOUT_MS
|
||||
} from './ai-vault-resume-launch'
|
||||
|
||||
function session(overrides: Partial<AiVaultSession> = {}): AiVaultSession {
|
||||
return {
|
||||
id: 'claude:1',
|
||||
executionHostId: 'local',
|
||||
agent: 'claude',
|
||||
sessionId: 'session 1',
|
||||
title: 'Resume me',
|
||||
cwd: '/Users/ada/repo',
|
||||
branch: 'main',
|
||||
model: null,
|
||||
filePath: '/Users/ada/.claude/session.jsonl',
|
||||
codexHome: null,
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
modifiedAt: '2026-06-29T00:00:00.000Z',
|
||||
messageCount: 2,
|
||||
totalTokens: 10,
|
||||
previewMessages: [],
|
||||
queuedMessageCount: 0,
|
||||
subagentTranscriptCount: 0,
|
||||
resumeCommand: '',
|
||||
subagent: null,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('buildMobileAiVaultResumeCommand', () => {
|
||||
it('delegates POSIX command construction to the shared AI Vault builder', () => {
|
||||
expect(buildMobileAiVaultResumeCommand({ session: session(), hostPlatform: 'darwin' })).toBe(
|
||||
"cd '/Users/ada/repo' && claude --resume 'session 1'"
|
||||
)
|
||||
})
|
||||
|
||||
it('uses PowerShell-safe queued command construction for default local Windows terminals', () => {
|
||||
expect(
|
||||
buildMobileAiVaultResumeCommand({
|
||||
session: session({
|
||||
agent: 'codex',
|
||||
sessionId: 'codex-1',
|
||||
cwd: 'C:\\repo app',
|
||||
codexHome: 'C:\\Users\\Ada\\.codex'
|
||||
}),
|
||||
hostPlatform: 'win32'
|
||||
})
|
||||
).toBe(
|
||||
"Set-Location -LiteralPath 'C:\\repo app'; $env:CODEX_HOME='C:\\Users\\Ada\\.codex'; codex resume 'codex-1'"
|
||||
)
|
||||
})
|
||||
|
||||
it('resumes OMP sessions by absolute transcript path like desktop', () => {
|
||||
// Regression: custom OMP_CODING_AGENT_DIR / WSL-store sessions miss on an
|
||||
// id lookup, so the forwarded filePath must win over the session id.
|
||||
expect(
|
||||
buildMobileAiVaultResumeCommand({
|
||||
session: session({
|
||||
agent: 'omp',
|
||||
sessionId: '019f27cd-4268-7000-96e7-62f42a55c144',
|
||||
filePath: '/Users/ada/.omp/agent/sessions/repo/sess.jsonl',
|
||||
cwd: '/Users/ada/repo'
|
||||
}),
|
||||
hostPlatform: 'darwin'
|
||||
})
|
||||
).toBe("cd '/Users/ada/repo' && omp --resume '/Users/ada/.omp/agent/sessions/repo/sess.jsonl'")
|
||||
})
|
||||
|
||||
it('uses cmd wrapping when the host Windows terminal is configured as cmd', () => {
|
||||
const command = buildMobileAiVaultResumeCommand({
|
||||
session: session({
|
||||
agent: 'codex',
|
||||
sessionId: 'codex-1',
|
||||
cwd: 'C:\\repo app',
|
||||
codexHome: 'C:\\Users\\Ada\\.codex'
|
||||
}),
|
||||
hostPlatform: 'win32',
|
||||
hostTerminalWindowsShell: 'cmd.exe'
|
||||
})
|
||||
expect(command).toContain('cmd /d /s /c')
|
||||
expect(command).toContain('cd /d ""C:\\repo app""')
|
||||
expect(command).toContain('set ""CODEX_HOME=C:\\Users\\Ada\\.codex""')
|
||||
expect(command).toContain('codex resume ""codex-1""')
|
||||
})
|
||||
|
||||
it('uses POSIX command construction and converts Codex home for WSL targets', () => {
|
||||
expect(
|
||||
buildMobileAiVaultResumeCommand({
|
||||
session: session({
|
||||
agent: 'codex',
|
||||
sessionId: 'codex-1',
|
||||
cwd: '/home/ada/repo',
|
||||
codexHome: '\\\\wsl.localhost\\Ubuntu\\home\\ada\\.codex'
|
||||
}),
|
||||
hostPlatform: 'linux'
|
||||
})
|
||||
).toBe("cd '/home/ada/repo' && CODEX_HOME='/home/ada/.codex' codex resume 'codex-1'")
|
||||
})
|
||||
|
||||
it('passes command overrides through to the shared builder', () => {
|
||||
expect(
|
||||
buildMobileAiVaultResumeCommand({
|
||||
session: session({ sessionId: 'abc' }),
|
||||
hostPlatform: 'linux',
|
||||
commandOverride: 'claude-dev'
|
||||
})
|
||||
).toBe("cd '/Users/ada/repo' && claude-dev --resume 'abc'")
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildMobileAiVaultResumeLaunch', () => {
|
||||
it('uses shared TUI startup planning for default args, env, and launch config', () => {
|
||||
const launch = buildMobileAiVaultResumeLaunch({
|
||||
session: session({
|
||||
agent: 'claude',
|
||||
sessionId: 'abc 123',
|
||||
cwd: '/Users/ada/repo'
|
||||
}),
|
||||
hostPlatform: 'darwin',
|
||||
settings: {
|
||||
agentCmdOverrides: { claude: 'claude-dev' },
|
||||
agentDefaultArgs: { claude: '--model opus' },
|
||||
agentDefaultEnv: { claude: { ANTHROPIC_BASE_URL: 'http://localhost:3000' } }
|
||||
}
|
||||
})
|
||||
expect(launch.command).toBe(
|
||||
"cd '/Users/ada/repo' && claude-dev '--model' 'opus' '--resume' 'abc 123'"
|
||||
)
|
||||
expect(launch.env).toEqual({ ANTHROPIC_BASE_URL: 'http://localhost:3000' })
|
||||
expect(launch.launchAgent).toBe('claude')
|
||||
expect(launch.launchConfig).toEqual({
|
||||
agentCommand: "claude-dev '--model' 'opus'",
|
||||
agentArgs: '--model opus',
|
||||
agentEnv: { ANTHROPIC_BASE_URL: 'http://localhost:3000' }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('resumeAiVaultSessionInTerminal', () => {
|
||||
it('creates a fresh terminal and sends the command with Enter', async () => {
|
||||
const sendRequest = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
result: { tab: { type: 'terminal', id: 'tab-1', terminal: 'pty-1', title: 'Terminal' } }
|
||||
})
|
||||
.mockResolvedValueOnce({ ok: true, result: { send: { accepted: true } } })
|
||||
|
||||
await expect(
|
||||
resumeAiVaultSessionInTerminal({ sendRequest }, 'worktree-1', {
|
||||
command: 'claude --resume abc',
|
||||
env: { ANTHROPIC_BASE_URL: 'http://localhost:3000' },
|
||||
launchConfig: {
|
||||
agentCommand: 'claude',
|
||||
agentArgs: '',
|
||||
agentEnv: { ANTHROPIC_BASE_URL: 'http://localhost:3000' }
|
||||
},
|
||||
launchAgent: 'claude',
|
||||
clientMutationId: 'resume-1'
|
||||
})
|
||||
).resolves.toMatchObject({ id: 'tab-1', terminal: 'pty-1' })
|
||||
expect(sendRequest).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'session.tabs.createTerminal',
|
||||
{
|
||||
worktree: 'id:worktree-1',
|
||||
env: { ANTHROPIC_BASE_URL: 'http://localhost:3000' },
|
||||
launchConfig: {
|
||||
agentCommand: 'claude',
|
||||
agentArgs: '',
|
||||
agentEnv: { ANTHROPIC_BASE_URL: 'http://localhost:3000' }
|
||||
},
|
||||
launchAgent: 'claude',
|
||||
clientMutationId: 'resume-1'
|
||||
},
|
||||
// Why: a socket drop mid-resume must reject within the request timeout
|
||||
// instead of parking on the reconnect waiter with the spinner pinned.
|
||||
{ timeoutMs: RESUME_RPC_TIMEOUT_MS }
|
||||
)
|
||||
expect(sendRequest).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'terminal.send',
|
||||
{
|
||||
terminal: 'pty-1',
|
||||
text: 'claude --resume abc',
|
||||
enter: true
|
||||
},
|
||||
{ timeoutMs: RESUME_RPC_TIMEOUT_MS }
|
||||
)
|
||||
})
|
||||
|
||||
it('throws when terminal creation fails', async () => {
|
||||
const sendRequest = vi.fn().mockResolvedValueOnce({
|
||||
ok: false,
|
||||
error: { message: 'no terminal' }
|
||||
})
|
||||
await expect(
|
||||
resumeAiVaultSessionInTerminal({ sendRequest }, 'worktree-1', { command: 'command' })
|
||||
).rejects.toThrow('no terminal')
|
||||
})
|
||||
|
||||
it('throws when the created terminal response is malformed', async () => {
|
||||
const sendRequest = vi.fn().mockResolvedValueOnce({ ok: true, result: { tab: { id: 'x' } } })
|
||||
await expect(
|
||||
resumeAiVaultSessionInTerminal({ sendRequest }, 'worktree-1', { command: 'command' })
|
||||
).rejects.toThrow('Created terminal response was invalid')
|
||||
})
|
||||
|
||||
it('throws when terminal send fails or is locked', async () => {
|
||||
const failedSend = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
result: { tab: { type: 'terminal', id: 'tab-1', terminal: 'pty-1' } }
|
||||
})
|
||||
.mockResolvedValueOnce({ ok: false, error: { message: 'send failed' } })
|
||||
await expect(
|
||||
resumeAiVaultSessionInTerminal({ sendRequest: failedSend }, 'worktree-1', {
|
||||
command: 'command'
|
||||
})
|
||||
).rejects.toThrow('send failed')
|
||||
|
||||
const lockedSend = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
result: { tab: { type: 'terminal', id: 'tab-1', terminal: 'pty-1' } }
|
||||
})
|
||||
.mockResolvedValueOnce({ ok: true, result: { send: { accepted: false } } })
|
||||
await expect(
|
||||
resumeAiVaultSessionInTerminal({ sendRequest: lockedSend }, 'worktree-1', {
|
||||
command: 'command'
|
||||
})
|
||||
).rejects.toThrow('Terminal input is locked')
|
||||
})
|
||||
})
|
||||
|
||||
describe('createMobileAiVaultResumeMutationRegistry', () => {
|
||||
it('reuses the claimed id across retries until a success releases it', () => {
|
||||
let mints = 0
|
||||
const registry = createMobileAiVaultResumeMutationRegistry((sessionId) => {
|
||||
mints += 1
|
||||
return `${sessionId}:mutation-${mints}`
|
||||
})
|
||||
|
||||
expect(registry.claim('session-a')).toBe('session-a:mutation-1')
|
||||
// A failed attempt keeps the key so the host can dedup the retry.
|
||||
expect(registry.claim('session-a')).toBe('session-a:mutation-1')
|
||||
|
||||
registry.releaseOnSuccess('session-a')
|
||||
// A resume after success mints fresh so the user can fork intentionally.
|
||||
expect(registry.claim('session-a')).toBe('session-a:mutation-2')
|
||||
})
|
||||
|
||||
it('tracks sessions independently', () => {
|
||||
const registry = createMobileAiVaultResumeMutationRegistry((sessionId) => `${sessionId}:id`)
|
||||
expect(registry.claim('session-a')).toBe('session-a:id')
|
||||
expect(registry.claim('session-b')).toBe('session-b:id')
|
||||
registry.releaseOnSuccess('session-b')
|
||||
expect(registry.claim('session-a')).toBe('session-a:id')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resume platform helpers', () => {
|
||||
it('reads a valid host platform from status.get', () => {
|
||||
expect(readMobileRuntimeHostPlatform({ hostPlatform: 'win32' })).toBe('win32')
|
||||
expect(readMobileRuntimeHostPlatform({ hostPlatform: 'not-a-platform' })).toBeNull()
|
||||
})
|
||||
|
||||
it('reads the host Windows terminal shell from status.get', () => {
|
||||
expect(readMobileRuntimeTerminalWindowsShell({ terminalWindowsShell: 'wsl.exe' })).toBe(
|
||||
'wsl.exe'
|
||||
)
|
||||
expect(readMobileRuntimeTerminalWindowsShell({ terminalWindowsShell: '' })).toBeNull()
|
||||
})
|
||||
|
||||
it('uses Linux/POSIX construction for SSH targets and host platform for local targets', () => {
|
||||
expect(resolveMobileAiVaultResumePlatform('ssh', 'win32')).toBe('linux')
|
||||
expect(resolveMobileAiVaultResumePlatform('local', 'darwin')).toBe('darwin')
|
||||
expect(
|
||||
resolveMobileAiVaultResumePlatform(
|
||||
'local',
|
||||
'win32',
|
||||
'\\\\wsl.localhost\\Ubuntu\\home\\ada\\repo'
|
||||
)
|
||||
).toBe('linux')
|
||||
expect(resolveMobileAiVaultResumePlatform('local', 'win32', 'C:\\repo', 'linux')).toBe('linux')
|
||||
expect(resolveMobileAiVaultResumePlatform('runtime', 'linux')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,269 @@
|
||||
import type { AiVaultSession } from '../../../src/shared/ai-vault-types'
|
||||
import {
|
||||
buildAiVaultResumeCommand,
|
||||
buildAiVaultResumeShellCommand
|
||||
} from '../../../src/shared/ai-vault-types'
|
||||
import { isResumableTuiAgent } from '../../../src/shared/agent-session-resume'
|
||||
import type { SleepingAgentLaunchConfig } from '../../../src/shared/agent-session-resume'
|
||||
import { buildAgentResumeStartupPlan } from '../../../src/shared/tui-agent-startup'
|
||||
import {
|
||||
resolveTuiAgentLaunchArgs,
|
||||
resolveTuiAgentLaunchEnv
|
||||
} from '../../../src/shared/tui-agent-launch-defaults'
|
||||
import type { TuiAgent } from '../../../src/shared/types'
|
||||
import { parseWslUncPath } from '../../../src/shared/wsl-paths'
|
||||
import { resolveWindowsShellStartupFamily } from '../../../src/shared/windows-terminal-shell'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import {
|
||||
readMobileReviewCreatedTerminal,
|
||||
readMobileReviewTerminalSendAccepted,
|
||||
type MobileReviewTerminalTab
|
||||
} from './mobile-diff-review-rpc'
|
||||
import type { MobileAiVaultResumeTargetStatus } from '../agent-history/agent-history-resume-target'
|
||||
|
||||
const NODE_PLATFORMS = new Set<NodeJS.Platform>([
|
||||
'aix',
|
||||
'android',
|
||||
'darwin',
|
||||
'freebsd',
|
||||
'haiku',
|
||||
'linux',
|
||||
'openbsd',
|
||||
'sunos',
|
||||
'win32',
|
||||
'cygwin',
|
||||
'netbsd'
|
||||
])
|
||||
|
||||
export function buildMobileAiVaultResumeCommand(args: {
|
||||
session: Pick<AiVaultSession, 'agent' | 'sessionId' | 'cwd' | 'codexHome'> &
|
||||
Partial<Pick<AiVaultSession, 'filePath'>>
|
||||
hostPlatform: NodeJS.Platform
|
||||
hostTerminalWindowsShell?: string | null
|
||||
commandOverride?: string | null
|
||||
}): string {
|
||||
// Why: this command is typed into the freshly created host terminal, so on
|
||||
// Windows it must match the host's live shell instead of the phone platform.
|
||||
const shell =
|
||||
args.hostPlatform === 'win32'
|
||||
? resolveWindowsShellStartupFamily(args.hostTerminalWindowsShell)
|
||||
: undefined
|
||||
return buildAiVaultResumeCommand({
|
||||
agent: args.session.agent,
|
||||
sessionId: args.session.sessionId,
|
||||
// Why: OMP resumes by absolute transcript path (custom OMP dir / WSL-store
|
||||
// sessions miss on an id lookup), so mobile forwards it like desktop does.
|
||||
resumeFilePath: args.session.filePath,
|
||||
cwd: args.session.cwd,
|
||||
platform: args.hostPlatform,
|
||||
commandOverride: args.commandOverride,
|
||||
codexHome: getMobileAiVaultResumeCodexHome(args.session.codexHome, args.hostPlatform),
|
||||
shell
|
||||
})
|
||||
}
|
||||
|
||||
export type MobileAiVaultResumeSettings = {
|
||||
agentCmdOverrides?: Partial<Record<TuiAgent, string | null>>
|
||||
agentDefaultArgs?: Partial<Record<TuiAgent, string>>
|
||||
agentDefaultEnv?: Partial<Record<TuiAgent, Record<string, string>>>
|
||||
}
|
||||
|
||||
export type MobileAiVaultResumeLaunch = {
|
||||
command: string
|
||||
env?: Record<string, string>
|
||||
launchConfig?: SleepingAgentLaunchConfig
|
||||
launchAgent?: TuiAgent
|
||||
}
|
||||
|
||||
export function buildMobileAiVaultResumeLaunch(args: {
|
||||
session: Pick<AiVaultSession, 'agent' | 'sessionId' | 'cwd' | 'codexHome'> &
|
||||
Partial<Pick<AiVaultSession, 'filePath'>>
|
||||
hostPlatform: NodeJS.Platform
|
||||
hostTerminalWindowsShell?: string | null
|
||||
settings?: MobileAiVaultResumeSettings | null
|
||||
}): MobileAiVaultResumeLaunch {
|
||||
const shell =
|
||||
args.hostPlatform === 'win32'
|
||||
? resolveWindowsShellStartupFamily(args.hostTerminalWindowsShell)
|
||||
: undefined
|
||||
const codexHome = getMobileAiVaultResumeCodexHome(args.session.codexHome, args.hostPlatform)
|
||||
const cmdOverrides = normalizeMobileAiVaultResumeCommandOverrides(
|
||||
args.settings?.agentCmdOverrides
|
||||
)
|
||||
const commandOverride = cmdOverrides[args.session.agent] ?? null
|
||||
if (isResumableTuiAgent(args.session.agent)) {
|
||||
const startupPlan = buildAgentResumeStartupPlan({
|
||||
agent: args.session.agent,
|
||||
providerSession: { key: 'session_id', id: args.session.sessionId },
|
||||
cmdOverrides,
|
||||
platform: args.hostPlatform,
|
||||
shell,
|
||||
agentArgs: resolveTuiAgentLaunchArgs(args.session.agent, args.settings?.agentDefaultArgs),
|
||||
agentEnv: resolveTuiAgentLaunchEnv(args.session.agent, args.settings?.agentDefaultEnv)
|
||||
})
|
||||
if (startupPlan) {
|
||||
return {
|
||||
command: buildAiVaultResumeShellCommand({
|
||||
resumeCommand: startupPlan.launchCommand,
|
||||
cwd: args.session.cwd,
|
||||
platform: args.hostPlatform,
|
||||
codexHome,
|
||||
shell
|
||||
}),
|
||||
...(startupPlan.env ? { env: startupPlan.env } : {}),
|
||||
launchConfig: startupPlan.launchConfig,
|
||||
launchAgent: startupPlan.agent
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
command: buildMobileAiVaultResumeCommand({
|
||||
session: args.session,
|
||||
hostPlatform: args.hostPlatform,
|
||||
hostTerminalWindowsShell: args.hostTerminalWindowsShell,
|
||||
commandOverride
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeMobileAiVaultResumeCommandOverrides(
|
||||
overrides: Partial<Record<TuiAgent, string | null>> | null | undefined
|
||||
): Partial<Record<TuiAgent, string>> {
|
||||
const normalized: Partial<Record<TuiAgent, string>> = {}
|
||||
if (!overrides) {
|
||||
return normalized
|
||||
}
|
||||
for (const [agent, command] of Object.entries(overrides) as [TuiAgent, string | null][]) {
|
||||
if (typeof command === 'string' && command.trim()) {
|
||||
normalized[agent] = command
|
||||
}
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
// Why: without an explicit timeout, a socket drop mid-resume parks the request
|
||||
// on the reconnect waiter for the full reconnect budget, pinning the spinner.
|
||||
export const RESUME_RPC_TIMEOUT_MS = 30_000
|
||||
|
||||
export async function resumeAiVaultSessionInTerminal(
|
||||
client: Pick<RpcClient, 'sendRequest'>,
|
||||
worktreeId: string,
|
||||
launch: MobileAiVaultResumeLaunch & { clientMutationId?: string }
|
||||
): Promise<MobileReviewTerminalTab> {
|
||||
const created = await client.sendRequest(
|
||||
'session.tabs.createTerminal',
|
||||
{
|
||||
worktree: `id:${worktreeId}`,
|
||||
...(launch.env ? { env: launch.env } : {}),
|
||||
...(launch.launchConfig ? { launchConfig: launch.launchConfig } : {}),
|
||||
...(launch.launchAgent ? { launchAgent: launch.launchAgent } : {}),
|
||||
...(launch.clientMutationId ? { clientMutationId: launch.clientMutationId } : {})
|
||||
},
|
||||
{ timeoutMs: RESUME_RPC_TIMEOUT_MS }
|
||||
)
|
||||
if (!created.ok) {
|
||||
throw new Error(created.error?.message || 'Failed to create terminal')
|
||||
}
|
||||
const terminalTab = readMobileReviewCreatedTerminal(created.result)
|
||||
if (!terminalTab) {
|
||||
throw new Error('Created terminal response was invalid')
|
||||
}
|
||||
const sent = await client.sendRequest(
|
||||
'terminal.send',
|
||||
{
|
||||
terminal: terminalTab.terminal,
|
||||
text: launch.command,
|
||||
enter: true
|
||||
},
|
||||
{ timeoutMs: RESUME_RPC_TIMEOUT_MS }
|
||||
)
|
||||
if (!sent.ok) {
|
||||
throw new Error(sent.error?.message || 'Failed to send resume command')
|
||||
}
|
||||
if (!readMobileReviewTerminalSendAccepted(sent.result)) {
|
||||
throw new Error('Terminal input is locked')
|
||||
}
|
||||
return terminalTab
|
||||
}
|
||||
|
||||
export type MobileAiVaultResumeMutationRegistry = {
|
||||
claim(sessionId: string): string
|
||||
releaseOnSuccess(sessionId: string): void
|
||||
}
|
||||
|
||||
// Why: a retry after a failed/interrupted resume must reuse the same
|
||||
// idempotency key so the host dedups the create, while a resume after success
|
||||
// mints a fresh key so the user can intentionally fork the session.
|
||||
export function createMobileAiVaultResumeMutationRegistry(
|
||||
mintId: (sessionId: string) => string
|
||||
): MobileAiVaultResumeMutationRegistry {
|
||||
const bySessionId = new Map<string, string>()
|
||||
return {
|
||||
claim(sessionId: string): string {
|
||||
const existing = bySessionId.get(sessionId)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
const minted = mintId(sessionId)
|
||||
bySessionId.set(sessionId, minted)
|
||||
return minted
|
||||
},
|
||||
releaseOnSuccess(sessionId: string): void {
|
||||
bySessionId.delete(sessionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function readMobileRuntimeHostPlatform(statusResult: unknown): NodeJS.Platform | null {
|
||||
if (!statusResult || typeof statusResult !== 'object') {
|
||||
return null
|
||||
}
|
||||
const hostPlatform = (statusResult as { hostPlatform?: unknown }).hostPlatform
|
||||
return typeof hostPlatform === 'string' && NODE_PLATFORMS.has(hostPlatform as NodeJS.Platform)
|
||||
? (hostPlatform as NodeJS.Platform)
|
||||
: null
|
||||
}
|
||||
|
||||
export function readMobileRuntimeTerminalWindowsShell(statusResult: unknown): string | null {
|
||||
if (!statusResult || typeof statusResult !== 'object') {
|
||||
return null
|
||||
}
|
||||
const shell = (statusResult as { terminalWindowsShell?: unknown }).terminalWindowsShell
|
||||
return typeof shell === 'string' && shell.trim().length > 0 ? shell : null
|
||||
}
|
||||
|
||||
export function resolveMobileAiVaultResumePlatform(
|
||||
targetStatus: MobileAiVaultResumeTargetStatus,
|
||||
hostPlatform: NodeJS.Platform | null,
|
||||
workspacePath?: string | null,
|
||||
terminalPlatform?: NodeJS.Platform | null
|
||||
): NodeJS.Platform | null {
|
||||
if (targetStatus === 'ssh') {
|
||||
// Why: desktop builds SSH resume commands for the remote POSIX execution
|
||||
// host instead of the phone or local desktop platform.
|
||||
return 'linux'
|
||||
}
|
||||
if (targetStatus === 'local') {
|
||||
if (terminalPlatform === 'linux' && hostPlatform === 'win32') {
|
||||
// Why: Windows-hosted WSL project terminals run a POSIX shell even when
|
||||
// the visible workspace path is a normal Windows path.
|
||||
return 'linux'
|
||||
}
|
||||
if (workspacePath && parseWslUncPath(workspacePath)) {
|
||||
// Why: a WSL UNC workspace on a Windows host runs in a Linux shell.
|
||||
return 'linux'
|
||||
}
|
||||
return hostPlatform
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function getMobileAiVaultResumeCodexHome(
|
||||
codexHome: string | null,
|
||||
platform: NodeJS.Platform
|
||||
): string | null {
|
||||
if (!codexHome || platform !== 'linux') {
|
||||
return codexHome
|
||||
}
|
||||
return parseWslUncPath(codexHome)?.linuxPath ?? codexHome
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { TerminalModes } from '../terminal/TerminalWebView'
|
||||
import type { TerminalModes } from '../terminal/terminal-webview-contract'
|
||||
import type { ConnectionState } from '../transport/types'
|
||||
|
||||
export const MOBILE_SESSION_STATUS_LABELS: Record<ConnectionState, string> = {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
getTerminalRecordsFromSessionTabs,
|
||||
mergeTerminalListWithKnownRecords,
|
||||
mergeTerminalRecordsByCurrentOrder,
|
||||
mobileSessionTabsEqual,
|
||||
type MobileTerminalSessionTab,
|
||||
type TerminalRecord
|
||||
@@ -24,6 +25,17 @@ const darkTheme = {
|
||||
}
|
||||
|
||||
describe('mobile terminal records', () => {
|
||||
it('keeps the known theme when a session-tab snapshot omits it', () => {
|
||||
const known: TerminalRecord[] = [
|
||||
{ handle: 'pty-1', title: 'Old title', terminalTheme: darkTheme, isActive: false }
|
||||
]
|
||||
const snapshot: TerminalRecord[] = [{ handle: 'pty-1', title: 'Current title', isActive: true }]
|
||||
|
||||
expect(mergeTerminalRecordsByCurrentOrder(snapshot, known)).toEqual([
|
||||
{ handle: 'pty-1', title: 'Current title', terminalTheme: darkTheme, isActive: true }
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps session-tab terminal themes when terminal.list omits them', () => {
|
||||
const terminalList: TerminalRecord[] = [
|
||||
{ handle: 'pty-1', title: 'Terminal', isActive: true },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { MobileTerminalTheme } from '../terminal/TerminalWebView'
|
||||
import type { MobileTerminalTheme } from '../terminal/terminal-webview-contract'
|
||||
import type { AgentStatusEntry } from '../../../src/shared/agent-status-types'
|
||||
|
||||
export type TerminalRecord = {
|
||||
@@ -116,6 +116,20 @@ function mobileSessionTabEqual(
|
||||
}
|
||||
}
|
||||
|
||||
// Reconcile a partial session snapshot against the last known record for the
|
||||
// same terminal. The snapshot owns live fields (title, activity); the known
|
||||
// theme only survives when the snapshot omits it, since lightweight snapshots
|
||||
// can drop it.
|
||||
function mergeTerminalSnapshotWithKnownRecord(
|
||||
snapshot: TerminalRecord,
|
||||
known: TerminalRecord
|
||||
): TerminalRecord {
|
||||
return {
|
||||
...snapshot,
|
||||
terminalTheme: snapshot.terminalTheme ?? known.terminalTheme
|
||||
}
|
||||
}
|
||||
|
||||
export function mergeTerminalRecordsByCurrentOrder(
|
||||
terminalTabs: TerminalRecord[],
|
||||
currentTerminals: TerminalRecord[]
|
||||
@@ -126,7 +140,12 @@ export function mergeTerminalRecordsByCurrentOrder(
|
||||
const terminalTabsByHandle = new Map(terminalTabs.map((tab) => [tab.handle, tab]))
|
||||
const currentHandles = new Set(currentTerminals.map((terminal) => terminal.handle))
|
||||
return [
|
||||
...currentTerminals.map((terminal) => terminalTabsByHandle.get(terminal.handle) ?? terminal),
|
||||
...currentTerminals.map((terminal) => {
|
||||
const snapshotTerminal = terminalTabsByHandle.get(terminal.handle)
|
||||
return snapshotTerminal
|
||||
? mergeTerminalSnapshotWithKnownRecord(snapshotTerminal, terminal)
|
||||
: terminal
|
||||
}),
|
||||
...terminalTabs.filter((terminal) => !currentHandles.has(terminal.handle))
|
||||
]
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
nextActivePanel,
|
||||
resolvePanelAction,
|
||||
panelRouteDescriptor,
|
||||
shouldShowSessionHeaderChecksAction,
|
||||
type ActivePanel
|
||||
} from './session-panel-host'
|
||||
|
||||
@@ -77,6 +78,48 @@ describe('canDockSessionPanel', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldShowSessionHeaderChecksAction', () => {
|
||||
it('shows Checks on git worktree routes once a checks-capable provider is confirmed', () => {
|
||||
expect(
|
||||
shouldShowSessionHeaderChecksAction({
|
||||
isFolderWorkspaceRoute: false,
|
||||
repoContextLoaded: true,
|
||||
hostedChecksSupported: true
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('hides Checks for providers without hosted checks (pr-panel guard would no-op it)', () => {
|
||||
expect(
|
||||
shouldShowSessionHeaderChecksAction({
|
||||
isFolderWorkspaceRoute: false,
|
||||
repoContextLoaded: true,
|
||||
hostedChecksSupported: false
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('hides Checks until the provider probe resolves', () => {
|
||||
expect(
|
||||
shouldShowSessionHeaderChecksAction({
|
||||
isFolderWorkspaceRoute: false,
|
||||
repoContextLoaded: false,
|
||||
hostedChecksSupported: false
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('hides Checks on folder workspace session routes', () => {
|
||||
expect(
|
||||
shouldShowSessionHeaderChecksAction({
|
||||
isFolderWorkspaceRoute: true,
|
||||
repoContextLoaded: true,
|
||||
hostedChecksSupported: true
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('panelRouteDescriptor', () => {
|
||||
it('maps each panel to its expo-router pathname', () => {
|
||||
expect(panelRouteDescriptor('sourceControl')).toEqual({
|
||||
|
||||
@@ -19,6 +19,17 @@ export type PanelAction =
|
||||
|
||||
export const SESSION_DOCK_MIN_MAIN_WIDTH = 360
|
||||
|
||||
export function shouldShowSessionHeaderChecksAction(args: {
|
||||
isFolderWorkspaceRoute: boolean
|
||||
repoContextLoaded: boolean
|
||||
hostedChecksSupported: boolean
|
||||
}): boolean {
|
||||
// Why: the hosted checks panel is provider-gated and the pr-panel guard
|
||||
// force-closes it for unsupported providers, so offering the action there
|
||||
// (or before the provider probe resolves) would be a silent no-op.
|
||||
return !args.isFolderWorkspaceRoute && args.repoContextLoaded && args.hostedChecksSupported
|
||||
}
|
||||
|
||||
export function canDockSessionPanel(args: {
|
||||
isWideLayout: boolean
|
||||
availableWidth: number
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { useLiveWorktreeName } from './use-live-worktree-name'
|
||||
|
||||
vi.mock('expo-router', async () => {
|
||||
const React = await import('react')
|
||||
return {
|
||||
useFocusEffect(effect: () => void | (() => void)): void {
|
||||
React.useEffect(effect, [effect])
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function suppressReactTestRendererDeprecationWarning(): () => void {
|
||||
const originalConsoleError = console.error
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
const firstArg = args[0]
|
||||
if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
originalConsoleError(...args)
|
||||
})
|
||||
return () => consoleErrorSpy.mockRestore()
|
||||
}
|
||||
|
||||
describe('useLiveWorktreeName request volume', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
let eventListener: ((payload: unknown) => void) | null = null
|
||||
const unsubscribeStream = vi.fn()
|
||||
const sendRequest = vi.fn().mockResolvedValue({
|
||||
id: 'worktree-show',
|
||||
ok: true,
|
||||
result: { worktree: { id: 'repo-1::/worktree', displayName: 'Live name' } },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
const subscribe = vi.fn(
|
||||
(_method: string, _params: unknown, listener: (payload: unknown) => void) => {
|
||||
eventListener = listener
|
||||
return unsubscribeStream
|
||||
}
|
||||
)
|
||||
const client = { sendRequest, subscribe } as unknown as RpcClient
|
||||
|
||||
async function mountHarness(): Promise<void> {
|
||||
function Harness(): null {
|
||||
useLiveWorktreeName({
|
||||
client,
|
||||
connState: 'connected',
|
||||
routeName: 'Route name',
|
||||
worktreeId: 'repo-1::/worktree'
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(createElement(Harness))
|
||||
await Promise.resolve()
|
||||
})
|
||||
} finally {
|
||||
restoreConsoleError()
|
||||
}
|
||||
}
|
||||
|
||||
async function emitEvent(payload: unknown): Promise<void> {
|
||||
await act(async () => {
|
||||
eventListener?.(payload)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
eventListener = null
|
||||
sendRequest.mockClear().mockResolvedValue({
|
||||
id: 'worktree-show',
|
||||
ok: true,
|
||||
result: { worktree: { id: 'repo-1::/worktree', displayName: 'Live name' } },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
subscribe.mockClear()
|
||||
unsubscribeStream.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('cuts thirty-second idle request volume from eleven calls to one', async () => {
|
||||
await mountHarness()
|
||||
expect(subscribe).toHaveBeenCalledWith(
|
||||
'runtime.clientEvents.subscribe',
|
||||
null,
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(sendRequest).toHaveBeenCalledTimes(1)
|
||||
|
||||
await emitEvent({ type: 'ready', subscriptionId: 'runtime-events-1' })
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
})
|
||||
// The retired 3s interval made one initial request plus ten idle ticks here.
|
||||
expect(sendRequest).toHaveBeenCalledTimes(1)
|
||||
|
||||
await emitEvent({ type: 'worktreesChanged', repoId: 'repo-2' })
|
||||
expect(sendRequest).toHaveBeenCalledTimes(1)
|
||||
|
||||
await emitEvent({ type: 'worktreesChanged', repoId: 'repo-1' })
|
||||
expect(sendRequest).toHaveBeenCalledTimes(2)
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
})
|
||||
expect(sendRequest).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('keeps polling when an older runtime rejects the event stream', async () => {
|
||||
await mountHarness()
|
||||
await emitEvent({ type: 'error', message: 'Unknown method' })
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(9_000)
|
||||
})
|
||||
|
||||
expect(sendRequest).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
|
||||
it('retries a failed initial read even after the event stream is ready', async () => {
|
||||
sendRequest.mockRejectedValueOnce(new Error('transient')).mockResolvedValue({
|
||||
id: 'worktree-show',
|
||||
ok: true,
|
||||
result: { worktree: { id: 'repo-1::/worktree', displayName: 'Live name' } },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
await mountHarness()
|
||||
await emitEvent({ type: 'ready', subscriptionId: 'runtime-events-1' })
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(3_000)
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
})
|
||||
|
||||
expect(sendRequest).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('refreshes once when a reconnected stream becomes ready again', async () => {
|
||||
await mountHarness()
|
||||
await emitEvent({ type: 'ready', subscriptionId: 'runtime-events-1' })
|
||||
await emitEvent({ type: 'ready', subscriptionId: 'runtime-events-2' })
|
||||
|
||||
expect(sendRequest).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('unsubscribes when the screen loses focus', async () => {
|
||||
await mountHarness()
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
|
||||
expect(unsubscribeStream).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -1,9 +1,13 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useFocusEffect } from 'expo-router'
|
||||
import type { RuntimeClientEventStreamMessage } from '../../../src/shared/runtime-client-events'
|
||||
import { getRepoIdFromWorktreeId } from '../../../src/shared/worktree-id'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { ConnectionState, RpcSuccess } from '../transport/types'
|
||||
import { getLiveWorktreeDisplayName, type WorktreeDisplayNameSource } from './worktree-display-name'
|
||||
|
||||
const WORKTREE_NAME_FALLBACK_POLL_MS = 3000
|
||||
|
||||
type Params = {
|
||||
client: RpcClient | null
|
||||
connState: ConnectionState
|
||||
@@ -24,12 +28,27 @@ export function useLiveWorktreeName({ client, connState, routeName, worktreeId }
|
||||
return
|
||||
}
|
||||
let stale = false
|
||||
const refreshWorktreeName = async () => {
|
||||
let eventStreamReady = false
|
||||
let hasSuccessfulRefresh = false
|
||||
let fallbackInterval: ReturnType<typeof setInterval> | null = null
|
||||
let refreshGeneration = 0
|
||||
const repoId = getRepoIdFromWorktreeId(worktreeId)
|
||||
|
||||
const stopFallbackPoll = (): void => {
|
||||
if (fallbackInterval !== null) {
|
||||
clearInterval(fallbackInterval)
|
||||
fallbackInterval = null
|
||||
}
|
||||
}
|
||||
const refreshWorktreeName = async (): Promise<void> => {
|
||||
// Why: an event-driven refresh can overtake a slow fallback request;
|
||||
// only the newest read may publish or stop the retry poll.
|
||||
const generation = ++refreshGeneration
|
||||
try {
|
||||
const response = await client.sendRequest('worktree.show', {
|
||||
worktree: `id:${worktreeId}`
|
||||
})
|
||||
if (stale || !response.ok) {
|
||||
if (stale || generation !== refreshGeneration || !response.ok) {
|
||||
return
|
||||
}
|
||||
const result = (response as RpcSuccess).result as {
|
||||
@@ -41,17 +60,73 @@ export function useLiveWorktreeName({ client, connState, routeName, worktreeId }
|
||||
if (liveName) {
|
||||
setWorktreeName((current) => (current === liveName ? current : liveName))
|
||||
}
|
||||
hasSuccessfulRefresh = true
|
||||
if (eventStreamReady) {
|
||||
stopFallbackPoll()
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal: the route param remains a usable label until the next refresh.
|
||||
}
|
||||
}
|
||||
|
||||
const startFallbackPoll = (): void => {
|
||||
if (stale || fallbackInterval !== null) {
|
||||
return
|
||||
}
|
||||
fallbackInterval = setInterval(
|
||||
() => void refreshWorktreeName(),
|
||||
WORKTREE_NAME_FALLBACK_POLL_MS
|
||||
)
|
||||
}
|
||||
const invalidateAndRefresh = (): void => {
|
||||
hasSuccessfulRefresh = false
|
||||
startFallbackPoll()
|
||||
void refreshWorktreeName()
|
||||
}
|
||||
|
||||
startFallbackPoll()
|
||||
const unsubscribe = client.subscribe(
|
||||
'runtime.clientEvents.subscribe',
|
||||
null,
|
||||
(payload: unknown) => {
|
||||
if (stale || !payload || typeof payload !== 'object') {
|
||||
return
|
||||
}
|
||||
const event = payload as RuntimeClientEventStreamMessage | { type: 'error' }
|
||||
if (event.type === 'ready') {
|
||||
const replayedAfterReconnect = eventStreamReady
|
||||
eventStreamReady = true
|
||||
if (hasSuccessfulRefresh) {
|
||||
stopFallbackPoll()
|
||||
}
|
||||
if (replayedAfterReconnect) {
|
||||
// Why: client events are not queued while disconnected, so replay
|
||||
// readiness must re-read the title once to close that event gap.
|
||||
invalidateAndRefresh()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (event.type === 'end' || event.type === 'error') {
|
||||
eventStreamReady = false
|
||||
startFallbackPoll()
|
||||
return
|
||||
}
|
||||
if (
|
||||
event.type === 'reposChanged' ||
|
||||
(event.type === 'worktreesChanged' && event.repoId === repoId)
|
||||
) {
|
||||
invalidateAndRefresh()
|
||||
}
|
||||
}
|
||||
)
|
||||
// Why: route params are only an entry hint. The desktop/runtime owns
|
||||
// displayName, including task-generated names that may settle after open.
|
||||
// displayName. Modern runtimes push invalidations; the poll remains only
|
||||
// until that stream proves available, preserving older-runtime behavior.
|
||||
void refreshWorktreeName()
|
||||
const interval = setInterval(() => void refreshWorktreeName(), 3000)
|
||||
return () => {
|
||||
stale = true
|
||||
clearInterval(interval)
|
||||
stopFallbackPoll()
|
||||
unsubscribe()
|
||||
}
|
||||
}, [client, connState, worktreeId])
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, type RefObject } from 'react'
|
||||
import * as Clipboard from 'expo-clipboard'
|
||||
import { File as FsFile, Paths } from 'expo-file-system'
|
||||
import { ImageManipulator, SaveFormat } from 'expo-image-manipulator'
|
||||
import type { TerminalModes } from '../terminal/TerminalWebView'
|
||||
import type { TerminalModes } from '../terminal/terminal-webview-contract'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { ConnectionState } from '../transport/types'
|
||||
import {
|
||||
|
||||
@@ -55,23 +55,28 @@ export function MobileSourceControlBranchCard({
|
||||
<Text style={styles.countText}>{unstagedCount} changed</Text>
|
||||
<Text style={styles.countText}>{stagedCount} staged</Text>
|
||||
{branchCount > 0 ? <Text style={styles.countText}>{branchCount} on branch</Text> : null}
|
||||
{showConflict ? (
|
||||
<View style={styles.conflictRow}>
|
||||
<Text style={styles.conflictText}>{conflictOperation}</Text>
|
||||
{conflictOperation === 'merge' || conflictOperation === 'rebase' ? (
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.abortButton, pressed && styles.abortPressed]}
|
||||
disabled={conflictBusy}
|
||||
onPress={() => onAbortConflict(conflictOperation)}
|
||||
>
|
||||
<Text style={styles.abortText}>
|
||||
{mobileConflictAbortLabel(conflictOperation, conflictAborting)}
|
||||
</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
{/* Own row so Abort never overflows past the card when counts are long. */}
|
||||
{showConflict ? (
|
||||
<View style={styles.conflictRow}>
|
||||
<Text style={styles.conflictText}>{conflictOperation}</Text>
|
||||
{conflictOperation === 'merge' || conflictOperation === 'rebase' ? (
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.abortButton,
|
||||
conflictBusy && styles.abortButtonDisabled,
|
||||
pressed && !conflictBusy && styles.abortPressed
|
||||
]}
|
||||
disabled={conflictBusy}
|
||||
onPress={() => onAbortConflict(conflictOperation)}
|
||||
>
|
||||
<Text style={styles.abortText}>
|
||||
{mobileConflictAbortLabel(conflictOperation, conflictAborting)}
|
||||
</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
{prChip ? <MobileSourceControlPrChip summary={prChip} onPress={onOpenPr} /> : null}
|
||||
</View>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { ActivityIndicator, Pressable, SectionList, Text, TextInput, View } from 'react-native'
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
SectionList,
|
||||
Text,
|
||||
TextInput,
|
||||
View
|
||||
} from 'react-native'
|
||||
import { Minus, MoreHorizontal, Plus, Sparkles } from 'lucide-react-native'
|
||||
import { colors, spacing } from '../theme/mobile-theme'
|
||||
import { MobileSourceControlCreatePrEntry } from './MobileSourceControlCreatePrEntry'
|
||||
@@ -13,7 +21,8 @@ type Props = {
|
||||
state: MobileSourceControlState
|
||||
}
|
||||
|
||||
// The ready-state Changes segment body: quick actions, changed-files list, and commit bar.
|
||||
// Changes tab: local file changes only — uncommitted (staged/unstaged) plus
|
||||
// committed-on-branch vs base. PR conflicts and push status live elsewhere.
|
||||
export function MobileSourceControlContent({ state }: Props) {
|
||||
const {
|
||||
insets,
|
||||
@@ -49,6 +58,21 @@ export function MobileSourceControlContent({ state }: Props) {
|
||||
const shouldShowGenerateButton = stagedCount > 0 || generatingMessage
|
||||
const createPrHeroActive =
|
||||
createPrAction.visible && !createPrAction.disabled && !createPrAction.pushFirst
|
||||
const branchCompareFooter = (
|
||||
<BranchCompareFooter
|
||||
state={{
|
||||
shouldShowBranchCompareSection: state.shouldShowBranchCompareSection,
|
||||
branchCompareSummaryText: state.branchCompareSummaryText,
|
||||
branchEntries: state.branchEntries,
|
||||
branchCompareState: state.branchCompareState,
|
||||
branchCompareResult: state.branchCompareResult,
|
||||
busyAction,
|
||||
openBranchDiff,
|
||||
openingBranchPath,
|
||||
openingPath
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -126,9 +150,15 @@ export function MobileSourceControlContent({ state }: Props) {
|
||||
|
||||
{!hasVisibleChanges ? (
|
||||
<View style={styles.state}>
|
||||
<Text style={styles.stateTitle}>No Changes</Text>
|
||||
<Text style={styles.stateTitle}>No local changes</Text>
|
||||
<Text style={styles.stateText}>Working tree is clean.</Text>
|
||||
</View>
|
||||
) : sections.length === 0 ? (
|
||||
// Why: RN SectionList with empty `sections` often skips ListFooterComponent,
|
||||
// which hid "Committed on Branch" when only branch files remain.
|
||||
<ScrollView style={hubStyles.tabBody} contentContainerStyle={styles.listContent}>
|
||||
{branchCompareFooter}
|
||||
</ScrollView>
|
||||
) : (
|
||||
<SectionList
|
||||
style={hubStyles.tabBody}
|
||||
@@ -148,21 +178,7 @@ export function MobileSourceControlContent({ state }: Props) {
|
||||
<Text style={styles.sectionCount}>{section.data.length}</Text>
|
||||
</View>
|
||||
)}
|
||||
ListFooterComponent={
|
||||
<BranchCompareFooter
|
||||
state={{
|
||||
shouldShowBranchCompareSection: state.shouldShowBranchCompareSection,
|
||||
branchCompareSummaryText: state.branchCompareSummaryText,
|
||||
branchEntries: state.branchEntries,
|
||||
branchCompareState: state.branchCompareState,
|
||||
branchCompareResult: state.branchCompareResult,
|
||||
busyAction,
|
||||
openBranchDiff,
|
||||
openingBranchPath,
|
||||
openingPath
|
||||
}}
|
||||
/>
|
||||
}
|
||||
ListFooterComponent={branchCompareFooter}
|
||||
stickySectionHeadersEnabled={false}
|
||||
contentContainerStyle={styles.listContent}
|
||||
/>
|
||||
|
||||
@@ -92,6 +92,7 @@ const baseStyles = StyleSheet.create({
|
||||
},
|
||||
countRow: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: spacing.md,
|
||||
marginTop: spacing.sm
|
||||
},
|
||||
@@ -99,29 +100,43 @@ const baseStyles = StyleSheet.create({
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.metaSize
|
||||
},
|
||||
// Separate line under counts — keeps Abort inside the card on narrow phones.
|
||||
conflictRow: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm
|
||||
gap: spacing.sm,
|
||||
marginTop: spacing.sm,
|
||||
alignSelf: 'flex-start',
|
||||
maxWidth: '100%'
|
||||
},
|
||||
conflictText: {
|
||||
color: colors.statusAmber,
|
||||
fontSize: typography.metaSize,
|
||||
textTransform: 'capitalize'
|
||||
},
|
||||
// Match bulk-action hit target so Abort reads as a real control, not a chip.
|
||||
abortButton: {
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 2,
|
||||
minHeight: 32,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: radii.button,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.statusAmber
|
||||
borderColor: colors.statusAmber,
|
||||
backgroundColor: colors.bgRaised,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0
|
||||
},
|
||||
abortPressed: {
|
||||
backgroundColor: colors.bgRaised
|
||||
opacity: 0.75
|
||||
},
|
||||
abortButtonDisabled: {
|
||||
opacity: 0.45
|
||||
},
|
||||
abortText: {
|
||||
color: colors.statusAmber,
|
||||
fontSize: typography.metaSize,
|
||||
fontSize: typography.bodySize,
|
||||
fontWeight: '600',
|
||||
textTransform: 'capitalize'
|
||||
},
|
||||
|
||||
@@ -104,8 +104,18 @@ export function useMobileSourceControlLoaders(params: Params): MobileSourceContr
|
||||
return false
|
||||
}
|
||||
if (!baseRef) {
|
||||
setBranchCompareState({ kind: 'idle' })
|
||||
return true
|
||||
// Why: wiping a prior ready compare to idle makes Changes say "No
|
||||
// Changes" even when commits still exist (e.g. after abort-merge refresh).
|
||||
setBranchCompareState((prev) => {
|
||||
if (options?.preserveReadyOnFailure && prev.kind === 'ready') {
|
||||
return prev
|
||||
}
|
||||
return {
|
||||
kind: 'error',
|
||||
message: 'Unable to resolve the base branch for comparison.'
|
||||
}
|
||||
})
|
||||
return false
|
||||
}
|
||||
const response = await client.sendRequest('git.branchCompare', {
|
||||
worktree: `id:${worktreeId}`,
|
||||
@@ -116,7 +126,12 @@ export function useMobileSourceControlLoaders(params: Params): MobileSourceContr
|
||||
}
|
||||
if (!response.ok) {
|
||||
if (isMobileGitUnavailable(response.error?.code, response.error?.message)) {
|
||||
setBranchCompareState({ kind: 'idle' })
|
||||
setBranchCompareState((prev) => {
|
||||
if (options?.preserveReadyOnFailure && prev.kind === 'ready') {
|
||||
return prev
|
||||
}
|
||||
return { kind: 'idle' }
|
||||
})
|
||||
return false
|
||||
}
|
||||
throw new Error(response.error?.message || 'Unable to load committed changes')
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
canOpenMobileBranchCompareDiff,
|
||||
formatMobileBranchCompareSummary
|
||||
} from './mobile-branch-compare'
|
||||
|
||||
import {
|
||||
buildMobileSourceControlSections,
|
||||
countStagedEntries,
|
||||
@@ -140,6 +141,7 @@ export function useMobileSourceControlState(params: MobileSourceControlStatePara
|
||||
})),
|
||||
[branchCompareCanOpen, branchCompareSection]
|
||||
)
|
||||
// Local changes only: dirty files + committed file diffs vs base (not PR/push).
|
||||
const shouldShowBranchCompareSection =
|
||||
branchEntries.length > 0 ||
|
||||
branchCompareState.kind === 'loading' ||
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import { useRef, useCallback, forwardRef, useImperativeHandle, useEffect, useMemo } from 'react'
|
||||
import { View, type StyleProp, type ViewStyle } from 'react-native'
|
||||
import { Platform, View } from 'react-native'
|
||||
import { WebView } from 'react-native-webview'
|
||||
import type { WebViewMessageEvent } from 'react-native-webview'
|
||||
import type { TerminalOscLinkRange } from './terminal-osc-link-ranges'
|
||||
import type {
|
||||
MobileTerminalTheme,
|
||||
TerminalSelectionEvents,
|
||||
TerminalWebViewHandle
|
||||
} from './terminal-webview-contract'
|
||||
import type { TerminalWebViewHandle, TerminalWebViewProps } from './terminal-webview-contract'
|
||||
import {
|
||||
TerminalWebViewEngineErrorOverlay,
|
||||
useTerminalWebViewEngineErrorState
|
||||
@@ -18,23 +14,9 @@ import { XTERM_WEBVIEW_SOURCE } from './terminal-webview-html'
|
||||
import type { TerminalWebViewCommand } from './terminal-webview-messages'
|
||||
import { createTerminalWebViewPendingMessages } from './terminal-webview-pending-messages'
|
||||
|
||||
export type {
|
||||
MobileTerminalTheme,
|
||||
TerminalKeyboardAvoidanceMetrics,
|
||||
TerminalModes,
|
||||
TerminalSelectionEvents,
|
||||
TerminalWebViewHandle
|
||||
} from './terminal-webview-contract'
|
||||
type Props = TerminalWebViewProps
|
||||
|
||||
type Props = {
|
||||
style?: StyleProp<ViewStyle>
|
||||
terminalTheme?: MobileTerminalTheme
|
||||
// Why: baseline zoom multiplier ("text size") applied on top of the fit-to-width
|
||||
// scale; raw xterm fontSize can't drive apparent size because the fit cancels it.
|
||||
textScale?: number
|
||||
onWebReady?: () => void
|
||||
onEngineError?: (message: string) => void
|
||||
} & TerminalSelectionEvents
|
||||
export type { TerminalWebViewHandle } from './terminal-webview-contract'
|
||||
|
||||
export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function TerminalWebView(
|
||||
{
|
||||
@@ -61,6 +43,7 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
|
||||
const isWebReadyRef = useRef(false)
|
||||
const pendingMessages = useMemo(() => createTerminalWebViewPendingMessages(), [])
|
||||
const messageIdRef = useRef(0)
|
||||
const pendingPingIdRef = useRef<number | null>(null)
|
||||
const terminalThemeKey = useMemo(() => JSON.stringify(terminalTheme ?? null), [terminalTheme])
|
||||
const measureResolveRef = useRef<
|
||||
((result: { cols: number; rows: number } | null) => void) | null
|
||||
@@ -80,7 +63,9 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
|
||||
|
||||
const sendToWebView = useCallback((msg: TerminalWebViewCommand) => {
|
||||
messageIdRef.current += 1
|
||||
webViewRef.current?.postMessage(JSON.stringify({ ...msg, id: messageIdRef.current }))
|
||||
const id = messageIdRef.current
|
||||
webViewRef.current?.postMessage(JSON.stringify({ ...msg, id }))
|
||||
return id
|
||||
}, [])
|
||||
|
||||
const flushPendingMessages = useCallback(() => {
|
||||
@@ -98,6 +83,30 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
|
||||
[pendingMessages, sendToWebView]
|
||||
)
|
||||
|
||||
const confirmWebReady = useCallback(
|
||||
(notifyParent: boolean) => {
|
||||
pendingPingIdRef.current = null
|
||||
isWebReadyRef.current = true
|
||||
clearWebReadyWatchdog()
|
||||
clearEngineError()
|
||||
if (notifyParent) {
|
||||
onWebReady?.()
|
||||
}
|
||||
// Why: reload clears queued commands, so readiness must always restore the
|
||||
// native-selected theme even when its value did not change in React.
|
||||
sendToWebView({ type: 'set-theme', terminalTheme })
|
||||
flushPendingMessages()
|
||||
},
|
||||
[
|
||||
clearEngineError,
|
||||
clearWebReadyWatchdog,
|
||||
flushPendingMessages,
|
||||
onWebReady,
|
||||
sendToWebView,
|
||||
terminalTheme
|
||||
]
|
||||
)
|
||||
|
||||
const handleMessage = useCallback(
|
||||
(event: WebViewMessageEvent) => {
|
||||
let msg: Record<string, unknown>
|
||||
@@ -108,11 +117,13 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
|
||||
}
|
||||
|
||||
if (msg.type === 'web-ready') {
|
||||
isWebReadyRef.current = true
|
||||
clearWebReadyWatchdog()
|
||||
clearEngineError()
|
||||
onWebReady?.()
|
||||
flushPendingMessages()
|
||||
confirmWebReady(true)
|
||||
} else if (
|
||||
msg.type === 'pong' &&
|
||||
typeof msg.pingId === 'number' &&
|
||||
msg.pingId === pendingPingIdRef.current
|
||||
) {
|
||||
confirmWebReady(false)
|
||||
} else if (msg.type === 'ready') {
|
||||
// Why: the WebView's init() rAF chain has run — term is open,
|
||||
// renderService is populated, first paint has happened. Resolve
|
||||
@@ -208,11 +219,8 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
|
||||
}
|
||||
},
|
||||
[
|
||||
flushPendingMessages,
|
||||
clearEngineError,
|
||||
clearWebReadyWatchdog,
|
||||
confirmWebReady,
|
||||
reportEngineError,
|
||||
onWebReady,
|
||||
onSelectionMode,
|
||||
onSelectionCopy,
|
||||
onSelectionEvicted,
|
||||
@@ -229,6 +237,7 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
|
||||
|
||||
const handleLoadStart = useCallback(() => {
|
||||
isWebReadyRef.current = false
|
||||
pendingPingIdRef.current = null
|
||||
armWebReadyWatchdog()
|
||||
// Why: messages queued for a previous WebView generation are stale after a reload;
|
||||
// dropping them avoids replaying terminal chunks before the next init snapshot.
|
||||
@@ -240,6 +249,17 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
|
||||
webViewRef.current?.reload()
|
||||
}, [clearEngineError])
|
||||
|
||||
const handleContentProcessDidTerminate = useCallback(() => {
|
||||
// Why: WKWebView content-process loss is recoverable; stale commands belong
|
||||
// to the dead document and the replacement must prove readiness before replay.
|
||||
isWebReadyRef.current = false
|
||||
pendingPingIdRef.current = null
|
||||
pendingMessages.clear()
|
||||
clearEngineError()
|
||||
armWebReadyWatchdog()
|
||||
webViewRef.current?.reload()
|
||||
}, [armWebReadyWatchdog, clearEngineError, pendingMessages])
|
||||
|
||||
useEffect(() => {
|
||||
postMessage({ type: 'set-theme', terminalTheme })
|
||||
}, [postMessage, terminalThemeKey, terminalTheme])
|
||||
@@ -253,6 +273,16 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
prepareForForegroundRecovery() {
|
||||
if (Platform.OS !== 'ios') {
|
||||
return
|
||||
}
|
||||
// Why: direct ping is the only command allowed through while readiness is
|
||||
// invalid; init/write commands queue until this exact document answers.
|
||||
isWebReadyRef.current = false
|
||||
armWebReadyWatchdog()
|
||||
pendingPingIdRef.current = sendToWebView({ type: 'ping' })
|
||||
},
|
||||
write(data: string) {
|
||||
postMessage({ type: 'write', data })
|
||||
},
|
||||
@@ -363,7 +393,7 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
|
||||
})
|
||||
}
|
||||
}),
|
||||
[postMessage, sendToWebView, terminalTheme, textScale]
|
||||
[armWebReadyWatchdog, postMessage, sendToWebView, terminalTheme, textScale]
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -389,9 +419,7 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
|
||||
onRenderProcessGone={(event) =>
|
||||
reportNativeEngineError('Terminal WebView render process ended', event)
|
||||
}
|
||||
onContentProcessDidTerminate={(event) =>
|
||||
reportNativeEngineError('Terminal WebView content process ended', event)
|
||||
}
|
||||
onContentProcessDidTerminate={handleContentProcessDidTerminate}
|
||||
/>
|
||||
{engineError ? (
|
||||
<TerminalWebViewEngineErrorOverlay message={engineError} onReload={handleReload} />
|
||||
|
||||
@@ -153,6 +153,12 @@ describe('terminal foreground recovery', () => {
|
||||
expect(foregroundPredicate).toContain('Platform.OS')
|
||||
expect(sessionSource).toContain('recoverActiveTerminalAfterForeground({')
|
||||
expect(sessionSource).toContain("AppState.addEventListener('change'")
|
||||
const readinessInvalidation = sessionSource.indexOf(
|
||||
'terminalRef.prepareForForegroundRecovery()'
|
||||
)
|
||||
const replay = sessionSource.indexOf('recoverActiveTerminalAfterForeground({')
|
||||
expect(readinessInvalidation).toBeGreaterThanOrEqual(0)
|
||||
expect(replay).toBeGreaterThan(readinessInvalidation)
|
||||
})
|
||||
|
||||
it('re-runs a deferred recovery once the session screen reconnects', () => {
|
||||
|
||||
@@ -52,6 +52,36 @@ describe('terminal viewport refit', () => {
|
||||
expect(sessionSource).toContain('useTerminalViewportRefit({')
|
||||
expect(sessionSource).toContain('tabStripVisible: terminals.length > 1')
|
||||
expect(sessionSource).toContain('textScale: terminalTextScale')
|
||||
expect(sessionSource).toContain('connState,')
|
||||
})
|
||||
|
||||
it('forces a refit on iOS foreground and connection recovery', () => {
|
||||
const foregroundEffect = hookSource.slice(
|
||||
hookSource.indexOf("if (Platform.OS !== 'ios')"),
|
||||
hookSource.indexOf('const previousConnStateRef')
|
||||
)
|
||||
const reconnectEffect = hookSource.slice(
|
||||
hookSource.indexOf('const previousConnStateRef'),
|
||||
hookSource.indexOf('disposedRef.current = false')
|
||||
)
|
||||
|
||||
expect(foregroundEffect).toContain("AppState.addEventListener('change'")
|
||||
expect(foregroundEffect).toContain('viewportMeasuredRef.current = false')
|
||||
expect(foregroundEffect).toContain('scheduleForcedViewportRefit()')
|
||||
expect(reconnectEffect).toContain("connState !== 'connected'")
|
||||
expect(reconnectEffect).toContain('viewportMeasuredRef.current = false')
|
||||
expect(reconnectEffect).toContain('scheduleForcedViewportRefit()')
|
||||
})
|
||||
|
||||
it('bypasses the equal-dimensions guard for resume and reconnect refits', () => {
|
||||
expect(hookSource).toContain('const forceRefit = forceNextRefitRef.current')
|
||||
expect(hookSource).toContain(
|
||||
'if (!forceRefit && prev && prev.cols === dims.cols && prev.rows === dims.rows)'
|
||||
)
|
||||
const forceRead = hookSource.indexOf('const forceRefit = forceNextRefitRef.current')
|
||||
const updateViewport = hookSource.indexOf("sendRequest('terminal.updateViewport'")
|
||||
expect(forceRead).toBeGreaterThanOrEqual(0)
|
||||
expect(updateViewport).toBeGreaterThan(forceRead)
|
||||
})
|
||||
|
||||
it('prefers the in-place updateViewport RPC over resubscribe', () => {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useCallback, useEffect, useRef, type RefObject } from 'react'
|
||||
import { useWindowDimensions } from 'react-native'
|
||||
import { AppState, Platform, useWindowDimensions, type AppStateStatus } from 'react-native'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { ConnectionState } from '../transport/types'
|
||||
import type { TerminalWebViewHandle } from './TerminalWebView'
|
||||
import { shouldRecoverTerminalOnAppStateChange } from './terminal-foreground-recovery'
|
||||
import {
|
||||
isTerminalUpdateViewportApplied,
|
||||
isTerminalUpdateViewportUpdated,
|
||||
@@ -19,6 +21,7 @@ type TerminalViewportRefitOptions = {
|
||||
clientRef: RefObject<RpcClient | null>
|
||||
deviceTokenRef: RefObject<string | null>
|
||||
initializedHandlesRef: RefObject<Set<string>>
|
||||
connState: ConnectionState
|
||||
tabStripVisible: boolean
|
||||
// Why: terminal text size (font scale) — changing it changes the cell size, so
|
||||
// the PTY must be re-fitted to a new column count and reflowed.
|
||||
@@ -49,6 +52,7 @@ export function useTerminalViewportRefit(options: TerminalViewportRefitOptions):
|
||||
clientRef,
|
||||
deviceTokenRef,
|
||||
initializedHandlesRef,
|
||||
connState,
|
||||
tabStripVisible,
|
||||
textScale,
|
||||
terminalFrameWidth,
|
||||
@@ -58,6 +62,7 @@ export function useTerminalViewportRefit(options: TerminalViewportRefitOptions):
|
||||
|
||||
const refitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const refitRunSeqRef = useRef(0)
|
||||
const forceNextRefitRef = useRef(false)
|
||||
const disposedRef = useRef(false)
|
||||
const scheduleViewportRefit = useCallback(() => {
|
||||
if (refitTimerRef.current) {
|
||||
@@ -93,8 +98,10 @@ export function useTerminalViewportRefit(options: TerminalViewportRefitOptions):
|
||||
if (!dims) {
|
||||
return
|
||||
}
|
||||
const forceRefit = forceNextRefitRef.current
|
||||
forceNextRefitRef.current = false
|
||||
const prev = viewportRef.current
|
||||
if (prev && prev.cols === dims.cols && prev.rows === dims.rows) {
|
||||
if (!forceRefit && prev && prev.cols === dims.cols && prev.rows === dims.rows) {
|
||||
return
|
||||
}
|
||||
viewportRef.current = dims
|
||||
@@ -152,6 +159,10 @@ export function useTerminalViewportRefit(options: TerminalViewportRefitOptions):
|
||||
unsubscribeTerminal,
|
||||
subscribeToTerminal
|
||||
])
|
||||
const scheduleForcedViewportRefit = useCallback(() => {
|
||||
forceNextRefitRef.current = true
|
||||
scheduleViewportRefit()
|
||||
}, [scheduleViewportRefit])
|
||||
|
||||
// Why: the tab strip is hidden when only one terminal exists and shown
|
||||
// once a second is created. Crossing the 1↔2 boundary changes the
|
||||
@@ -215,6 +226,42 @@ export function useTerminalViewportRefit(options: TerminalViewportRefitOptions):
|
||||
scheduleViewportRefit()
|
||||
}, [terminalFrameWidth, viewportMeasuredRef, scheduleViewportRefit])
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== 'ios') {
|
||||
return
|
||||
}
|
||||
let previousAppState: AppStateStatus | null = AppState.currentState
|
||||
const sub = AppState.addEventListener('change', (nextAppState: AppStateStatus) => {
|
||||
const shouldRefit = shouldRecoverTerminalOnAppStateChange(
|
||||
previousAppState,
|
||||
nextAppState,
|
||||
Platform.OS
|
||||
)
|
||||
previousAppState = nextAppState
|
||||
if (!shouldRefit) {
|
||||
return
|
||||
}
|
||||
// Why: the cached grid can match while the host PTY changed in background;
|
||||
// reasserting equal dimensions is the convergence signal after iOS resume.
|
||||
viewportMeasuredRef.current = false
|
||||
scheduleForcedViewportRefit()
|
||||
})
|
||||
return () => sub.remove()
|
||||
}, [viewportMeasuredRef, scheduleForcedViewportRefit])
|
||||
|
||||
const previousConnStateRef = useRef(connState)
|
||||
useEffect(() => {
|
||||
const previous = previousConnStateRef.current
|
||||
previousConnStateRef.current = connState
|
||||
if (previous === 'connected' || connState !== 'connected') {
|
||||
return
|
||||
}
|
||||
// Why: reconnect can restore a PTY whose host-side size changed while the
|
||||
// socket was down, so equal cached dimensions still need reassertion.
|
||||
viewportMeasuredRef.current = false
|
||||
scheduleForcedViewportRefit()
|
||||
}, [connState, viewportMeasuredRef, scheduleForcedViewportRefit])
|
||||
|
||||
useEffect(() => {
|
||||
disposedRef.current = false
|
||||
return () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-types'
|
||||
import type { StyleProp, ViewStyle } from 'react-native'
|
||||
import type { TerminalOscLinkRange } from './terminal-osc-link-ranges'
|
||||
|
||||
type TerminalMouseTrackingMode = 'none' | 'x10' | 'vt200' | 'drag' | 'any'
|
||||
@@ -37,7 +38,20 @@ export type TerminalSelectionEvents = {
|
||||
onTextScaleChange?: (scale: number) => void
|
||||
}
|
||||
|
||||
export type TerminalWebViewProps = {
|
||||
style?: StyleProp<ViewStyle>
|
||||
terminalTheme?: MobileTerminalTheme
|
||||
// Why: baseline zoom multiplier applied on top of fit-to-width scale; raw
|
||||
// xterm fontSize alone cannot drive apparent size because fitting cancels it.
|
||||
textScale?: number
|
||||
onWebReady?: () => void
|
||||
onEngineError?: (message: string) => void
|
||||
} & TerminalSelectionEvents
|
||||
|
||||
export type TerminalWebViewHandle = {
|
||||
// Why: iOS can preserve the native view while discarding its JS/backing-store
|
||||
// state; foreground recovery must wait for the document to answer before replay.
|
||||
prepareForForegroundRecovery: () => void
|
||||
write: (data: string) => void
|
||||
init: (
|
||||
cols: number,
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { createElement } from 'react'
|
||||
import { createElement, createRef } from 'react'
|
||||
import { Platform } from 'react-native'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TerminalWebView } from './TerminalWebView'
|
||||
import type { TerminalWebViewHandle } from './terminal-webview-contract'
|
||||
|
||||
const nativeWebViewMethods = vi.hoisted(() => ({
|
||||
postMessage: vi.fn<(message: string) => void>(),
|
||||
reload: vi.fn<() => void>()
|
||||
}))
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
AppState: { currentState: 'active' },
|
||||
Platform: { OS: 'ios' },
|
||||
Pressable: 'Pressable',
|
||||
StyleSheet: {
|
||||
absoluteFillObject: {
|
||||
@@ -20,10 +28,14 @@ vi.mock('react-native', () => ({
|
||||
View: 'View'
|
||||
}))
|
||||
|
||||
vi.mock('react-native-webview', () => ({
|
||||
WebView: 'WebView',
|
||||
default: 'WebView'
|
||||
}))
|
||||
vi.mock('react-native-webview', async () => {
|
||||
const React = await import('react')
|
||||
const WebView = React.forwardRef((props: Record<string, unknown>, ref) => {
|
||||
React.useImperativeHandle(ref, () => nativeWebViewMethods)
|
||||
return React.createElement('WebView', props)
|
||||
})
|
||||
return { WebView, default: WebView }
|
||||
})
|
||||
|
||||
vi.mock('lucide-react-native', () => ({
|
||||
RefreshCw: 'RefreshCw'
|
||||
@@ -45,12 +57,15 @@ function suppressReactTestRendererDeprecationWarning(): () => void {
|
||||
// unmount so the timer can't survive into later tests and fire in teardown.
|
||||
let activeRenderer: ReactTestRenderer | null = null
|
||||
|
||||
function createTerminalWebViewRenderer(onEngineError = vi.fn()) {
|
||||
function createTerminalWebViewRenderer(
|
||||
onEngineError = vi.fn(),
|
||||
props: Record<string, unknown> = {}
|
||||
) {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
|
||||
try {
|
||||
act(() => {
|
||||
renderer = create(createElement(TerminalWebView, { onEngineError }))
|
||||
renderer = create(createElement(TerminalWebView, { onEngineError, ...props }))
|
||||
})
|
||||
} finally {
|
||||
restoreConsoleError()
|
||||
@@ -76,6 +91,10 @@ function renderedText(renderer: ReactTestRenderer): string {
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
function postedCommands(): Array<Record<string, unknown>> {
|
||||
return nativeWebViewMethods.postMessage.mock.calls.map(([message]) => JSON.parse(message))
|
||||
}
|
||||
|
||||
describe('TerminalWebView engine errors', () => {
|
||||
afterEach(() => {
|
||||
if (activeRenderer) {
|
||||
@@ -84,6 +103,7 @@ describe('TerminalWebView engine errors', () => {
|
||||
})
|
||||
activeRenderer = null
|
||||
}
|
||||
vi.clearAllMocks()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
@@ -175,4 +195,78 @@ describe('TerminalWebView engine errors', () => {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('queues iOS foreground traffic until the current document answers its ping', () => {
|
||||
const terminalRef = createRef<TerminalWebViewHandle>()
|
||||
const onWebReady = vi.fn()
|
||||
const terminalTheme = {
|
||||
mode: 'dark',
|
||||
theme: { background: '#111111', foreground: '#eeeeee' }
|
||||
}
|
||||
const { renderer } = createTerminalWebViewRenderer(vi.fn(), {
|
||||
ref: terminalRef,
|
||||
onWebReady,
|
||||
terminalTheme
|
||||
})
|
||||
postWebViewMessage(renderer, { type: 'web-ready' })
|
||||
nativeWebViewMethods.postMessage.mockClear()
|
||||
|
||||
act(() => {
|
||||
terminalRef.current?.prepareForForegroundRecovery()
|
||||
terminalRef.current?.write('queued output')
|
||||
})
|
||||
|
||||
const ping = postedCommands()[0]
|
||||
expect(postedCommands().map((command) => command.type)).toEqual(['ping'])
|
||||
postWebViewMessage(renderer, { type: 'pong', pingId: Number(ping?.id) + 1 })
|
||||
expect(postedCommands().map((command) => command.type)).toEqual(['ping'])
|
||||
|
||||
postWebViewMessage(renderer, { type: 'pong', pingId: ping?.id })
|
||||
expect(postedCommands().map((command) => command.type)).toEqual(['ping', 'set-theme', 'write'])
|
||||
expect(onWebReady).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps Android foreground traffic on the existing ready document', () => {
|
||||
const terminalRef = createRef<TerminalWebViewHandle>()
|
||||
const { renderer } = createTerminalWebViewRenderer(vi.fn(), { ref: terminalRef })
|
||||
postWebViewMessage(renderer, { type: 'web-ready' })
|
||||
nativeWebViewMethods.postMessage.mockClear()
|
||||
const mutablePlatform = Platform as { OS: string }
|
||||
mutablePlatform.OS = 'android'
|
||||
try {
|
||||
act(() => {
|
||||
terminalRef.current?.prepareForForegroundRecovery()
|
||||
terminalRef.current?.write('android output')
|
||||
})
|
||||
expect(postedCommands().map((command) => command.type)).toEqual(['write'])
|
||||
} finally {
|
||||
mutablePlatform.OS = 'ios'
|
||||
}
|
||||
})
|
||||
|
||||
it('reloads a terminated iOS content process and restores theme on readiness', () => {
|
||||
const terminalRef = createRef<TerminalWebViewHandle>()
|
||||
const { onEngineError, renderer } = createTerminalWebViewRenderer(vi.fn(), {
|
||||
ref: terminalRef,
|
||||
terminalTheme: {
|
||||
mode: 'light',
|
||||
theme: { background: '#ffffff', foreground: '#111111' }
|
||||
}
|
||||
})
|
||||
postWebViewMessage(renderer, { type: 'web-ready' })
|
||||
nativeWebViewMethods.postMessage.mockClear()
|
||||
const webView = renderer.root.findByType('WebView')
|
||||
|
||||
act(() => {
|
||||
webView.props.onContentProcessDidTerminate({ nativeEvent: {} })
|
||||
terminalRef.current?.write('after termination')
|
||||
})
|
||||
|
||||
expect(nativeWebViewMethods.reload).toHaveBeenCalledTimes(1)
|
||||
expect(nativeWebViewMethods.postMessage).not.toHaveBeenCalled()
|
||||
expect(onEngineError).not.toHaveBeenCalled()
|
||||
|
||||
postWebViewMessage(renderer, { type: 'web-ready' })
|
||||
expect(postedCommands().map((command) => command.type)).toEqual(['set-theme', 'write'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,15 +1,95 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { Script } from 'node:vm'
|
||||
import { parse } from 'acorn'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { XTERM_ENGINE_CSS, XTERM_ENGINE_JS } from './terminal-webview-engine.generated'
|
||||
import { XTERM_HTML } from './terminal-webview-html'
|
||||
import { TERMINAL_WEBGL_RECOVERY_JS } from './terminal-webview-webgl-recovery-injected'
|
||||
|
||||
const terminalHtmlSource = readFileSync(
|
||||
new URL('./terminal-webview-html.ts', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
function createWebglRecoveryHarness(failSecondAttach = false) {
|
||||
const variablesStart = terminalHtmlSource.indexOf(' var webglAddon = null;')
|
||||
const variablesEnd = terminalHtmlSource.indexOf(
|
||||
'\n',
|
||||
terminalHtmlSource.indexOf(' var webglRecoveryTimer = null;')
|
||||
)
|
||||
expect(variablesStart).toBeGreaterThanOrEqual(0)
|
||||
expect(variablesEnd).toBeGreaterThan(variablesStart)
|
||||
|
||||
const timers: Array<() => void> = []
|
||||
const addons: Array<{
|
||||
clearTextureAtlas: ReturnType<typeof vi.fn>
|
||||
dispose: ReturnType<typeof vi.fn>
|
||||
fireContextLoss: () => void
|
||||
}> = []
|
||||
const term = {
|
||||
rows: 24,
|
||||
refresh: vi.fn(),
|
||||
loadAddon: vi.fn(() => {
|
||||
if (failSecondAttach && addons.length === 2) {
|
||||
throw new Error('retry unavailable')
|
||||
}
|
||||
})
|
||||
}
|
||||
function WebglAddon() {
|
||||
let contextLoss = () => {}
|
||||
const addon = {
|
||||
clearTextureAtlas: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
fireContextLoss: () => contextLoss()
|
||||
}
|
||||
addons.push(addon)
|
||||
return Object.assign(addon, {
|
||||
onContextLoss: (listener: () => void) => {
|
||||
contextLoss = listener
|
||||
}
|
||||
})
|
||||
}
|
||||
let visibilityChange = () => {}
|
||||
const document = {
|
||||
addEventListener: vi.fn((eventName: string, listener: () => void) => {
|
||||
if (eventName === 'visibilitychange') {
|
||||
visibilityChange = listener
|
||||
}
|
||||
}),
|
||||
visibilityState: 'hidden'
|
||||
}
|
||||
const applyTerminalTheme = vi.fn()
|
||||
const flog = vi.fn()
|
||||
const terminalThemeInput = { mode: 'dark' }
|
||||
const context = {
|
||||
applyTerminalTheme,
|
||||
clearTimeout: vi.fn(),
|
||||
document,
|
||||
flog,
|
||||
setTimeout: (callback: () => void) => {
|
||||
timers.push(callback)
|
||||
return timers.length
|
||||
},
|
||||
term,
|
||||
terminalGeneration: 1,
|
||||
terminalThemeInput,
|
||||
window: { WebglAddon: { WebglAddon } }
|
||||
}
|
||||
new Script(`${terminalHtmlSource.slice(variablesStart, variablesEnd)}
|
||||
${TERMINAL_WEBGL_RECOVERY_JS}
|
||||
attachWebglAddon(true);`).runInNewContext(context)
|
||||
return {
|
||||
addons,
|
||||
applyTerminalTheme,
|
||||
document,
|
||||
fireVisibilityChange: () => visibilityChange(),
|
||||
flog,
|
||||
term,
|
||||
terminalThemeInput,
|
||||
timers
|
||||
}
|
||||
}
|
||||
|
||||
describe('terminal WebView bundled engine', () => {
|
||||
it('keeps the assembled terminal HTML free of external engine URLs', () => {
|
||||
expect(XTERM_HTML).not.toMatch(/\bhttps?:\/\//)
|
||||
@@ -106,4 +186,57 @@ describe('terminal WebView bundled engine', () => {
|
||||
expect(capSites.length).toBe(2)
|
||||
expect(terminalHtmlSource).toContain('nonFatalErrorNotifies > 5')
|
||||
})
|
||||
|
||||
it('recreates WebGL once after context loss, then stays on the DOM renderer', () => {
|
||||
const { addons, flog, term, timers } = createWebglRecoveryHarness()
|
||||
|
||||
expect(addons).toHaveLength(1)
|
||||
addons[0]?.fireContextLoss()
|
||||
expect(flog).toHaveBeenCalledWith(
|
||||
'webgl-context-loss',
|
||||
expect.objectContaining({ retry: true })
|
||||
)
|
||||
expect(addons[0]?.dispose).toHaveBeenCalledTimes(1)
|
||||
expect(term.refresh).toHaveBeenCalledTimes(1)
|
||||
expect(timers).toHaveLength(1)
|
||||
|
||||
timers.shift()?.()
|
||||
expect(addons).toHaveLength(2)
|
||||
expect(addons[1]?.clearTextureAtlas).toHaveBeenCalledTimes(1)
|
||||
expect(term.refresh).toHaveBeenCalledTimes(2)
|
||||
addons[1]?.fireContextLoss()
|
||||
expect(addons[1]?.dispose).toHaveBeenCalledTimes(1)
|
||||
expect(term.refresh).toHaveBeenCalledTimes(3)
|
||||
expect(timers).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('falls back to a refreshed DOM renderer when the delayed WebGL retry fails', () => {
|
||||
const { addons, term, timers } = createWebglRecoveryHarness(true)
|
||||
|
||||
addons[0]?.fireContextLoss()
|
||||
timers.shift()?.()
|
||||
|
||||
expect(addons).toHaveLength(2)
|
||||
expect(addons[1]?.dispose).toHaveBeenCalledTimes(1)
|
||||
expect(term.refresh).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('reapplies theme, clears the active atlas, and refreshes when visible', () => {
|
||||
const harness = createWebglRecoveryHarness()
|
||||
|
||||
harness.fireVisibilityChange()
|
||||
expect(harness.applyTerminalTheme).not.toHaveBeenCalled()
|
||||
|
||||
harness.document.visibilityState = 'visible'
|
||||
harness.fireVisibilityChange()
|
||||
|
||||
expect(harness.applyTerminalTheme).toHaveBeenCalledWith(harness.terminalThemeInput)
|
||||
expect(harness.addons[0]?.clearTextureAtlas).toHaveBeenCalledTimes(1)
|
||||
expect(harness.term.refresh).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('answers native readiness probes from the live document', () => {
|
||||
expect(terminalHtmlSource).toContain("if (msg.type === 'ping')")
|
||||
expect(terminalHtmlSource).toContain("notify({ type: 'pong', pingId: msg.id })")
|
||||
})
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user