diff --git a/.github/workflows/computer-e2e.yml b/.github/workflows/computer-e2e.yml
index 3023d414abe..784172d699e 100644
--- a/.github/workflows/computer-e2e.yml
+++ b/.github/workflows/computer-e2e.yml
@@ -117,6 +117,7 @@ jobs:
- run: pnpm verify:computer-native
- run: pnpm build:cli
- run: pnpm build:electron-vite
+ - run: pnpm verify:cli-runtime
# Why: boot the BUILT daemon-entry under plain Node the way production
# forks it. v1.4.129-rc.1 shipped a daemon that exited at module load
# (leaked electron require) while every other check passed; this fails
@@ -158,6 +159,7 @@ jobs:
- run: pnpm verify:computer-native
- run: pnpm build:cli
- run: pnpm build:electron-vite
+ - run: pnpm verify:cli-runtime
- run: pnpm test:e2e:computer --reporter=verbose tests/e2e/computer-mac.e2e.ts tests/e2e/computer-mac-safari.e2e.ts
linux:
@@ -185,6 +187,7 @@ jobs:
- run: pnpm verify:computer-native
- run: pnpm build:cli
- run: pnpm build:electron-vite
+ - run: pnpm verify:cli-runtime
- run: xvfb-run --auto-servernum dbus-run-session -- pnpm test:e2e:computer --reporter=verbose tests/e2e/computer-linux.e2e.ts
windows:
@@ -204,4 +207,5 @@ jobs:
- run: pnpm verify:computer-native
- run: pnpm build:cli
- run: pnpm build:electron-vite
+ - run: pnpm verify:cli-runtime
- run: pnpm test:e2e:computer --reporter=verbose tests/e2e/computer-windows.e2e.ts tests/e2e/computer-windows-store.e2e.ts
diff --git a/.github/workflows/mobile-ios-release.yml b/.github/workflows/mobile-ios-release.yml
index b38a79b9267..f759bd3efee 100644
--- a/.github/workflows/mobile-ios-release.yml
+++ b/.github/workflows/mobile-ios-release.yml
@@ -10,7 +10,7 @@ on:
workflow_dispatch:
inputs:
bump_patch_version:
- description: 'Bump the iOS marketing version patch number before release. Tick this after a version has shipped to the App Store (the release fails fast if the current version''s train is already closed).'
+ description: 'Use the first open iOS patch version after the checked-in version, skipping versions already closed on the App Store.'
required: false
default: false
type: boolean
diff --git a/.github/workflows/mobile.yml b/.github/workflows/mobile.yml
index 2998d1dcce7..e9683904be2 100644
--- a/.github/workflows/mobile.yml
+++ b/.github/workflows/mobile.yml
@@ -30,6 +30,11 @@ jobs:
with:
node-version-file: package.json
+ - name: Setup Ruby
+ uses: ruby/setup-ruby@v1
+ with:
+ ruby-version: '3.3'
+
- name: Setup pnpm
uses: pnpm/action-setup@v6
with:
@@ -56,6 +61,9 @@ jobs:
- name: Test
run: pnpm test
+ - name: Test iOS release version resolution
+ run: ruby fastlane/ios_release_version_test.rb
+
- name: Lint
run: pnpm lint
diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml
index 2c48d466305..0142518c4cb 100644
--- a/.github/workflows/release-cut.yml
+++ b/.github/workflows/release-cut.yml
@@ -1330,7 +1330,8 @@ jobs:
}
Copy-Item -Path $signedInstaller.FullName -Destination 'dist/orca-windows-setup.exe' -Force
- & 'node_modules/app-builder-bin/win/x64/app-builder.exe' blockmap --input 'dist/orca-windows-setup.exe' --output 'dist/orca-windows-setup.exe.blockmap'
+ node config/scripts/generate-windows-blockmap.mjs 'dist/orca-windows-setup.exe' 'dist/orca-windows-setup.exe.blockmap'
+ if ($LASTEXITCODE -ne 0) { throw "blockmap generation failed with exit code $LASTEXITCODE" }
$installer = Get-Item 'dist/orca-windows-setup.exe'
$blockmap = Get-Item 'dist/orca-windows-setup.exe.blockmap'
diff --git a/.github/workflows/windows-signing-rehearsal.yml b/.github/workflows/windows-signing-rehearsal.yml
index 24b4307a83b..4ca080d803d 100644
--- a/.github/workflows/windows-signing-rehearsal.yml
+++ b/.github/workflows/windows-signing-rehearsal.yml
@@ -271,7 +271,7 @@ jobs:
throw 'Signed Windows installer was not returned by SignPath.'
}
Copy-Item -Path $signedInstaller.FullName -Destination 'dist/orca-windows-setup.exe' -Force
- & 'node_modules/app-builder-bin/win/x64/app-builder.exe' blockmap --input 'dist/orca-windows-setup.exe' --output 'dist/orca-windows-setup.exe.blockmap'
+ node config/scripts/generate-windows-blockmap.mjs 'dist/orca-windows-setup.exe' 'dist/orca-windows-setup.exe.blockmap'
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
$installer = Get-Item 'dist/orca-windows-setup.exe'
diff --git a/README.md b/README.md
index 1a434350bd0..aa4ee905281 100644
--- a/README.md
+++ b/README.md
@@ -238,7 +238,7 @@ Pair with your desktop app to monitor and steer your agents from your phone.
- **Discord:** Join the community on **[Discord](https://discord.gg/fzjDKHxv8Q)**.
- **Twitter / X:** Follow **[@orca_build](https://x.com/orca_build)** for updates and announcements.
-- **WeChat:** Groups 1 and 2 are both full — now you can join the third one.
+- **WeChat:** All other groups are full, now we're on group 5.
diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc
index 5db7f6ca20e..7ec61073b60 100644
--- a/config/reliability-gates.jsonc
+++ b/config/reliability-gates.jsonc
@@ -1390,6 +1390,7 @@
"headed desktop remote-server pairing",
"headless remote-server parity",
"daemon and relay reconnect",
+ "remote completion classification across disconnect and reconnect",
"terminal exit retirement and restart restore",
"mixed-version fallback"
],
@@ -1412,22 +1413,39 @@
"local",
"daemon",
"ssh",
+ "wsl",
"remote-runtime"
],
- "coverageNotes": "Deterministic macOS tests cover controller claims, daemon and SSH/relay operation replay, mixed-version selection, runtime ownership, exact provisional handoff, durable terminal retirement, and two independent viewer mirrors. The secondary parity repro runs independent clients against one headless remote Orca runtime over encrypted pairing and a real daemon-backed PTY. A headed Orca desktop server paired to a separate client is the primary live user topology and remains uncollected for this issue. SSH coverage is provider/relay contract and fault-injection coverage only; it does not substitute for paired-server coverage. WSL and live SSH hosts remain gaps.",
+ "coverageNotes": "Deterministic macOS tests cover controller claims, daemon and SSH/relay operation replay, mixed-version selection, runtime ownership, exact provisional handoff, durable terminal retirement, two independent viewer mirrors, guarded adoption of legacy live PTYs, and completion classification when either the outer remote transport or authoritative host/provider process inspection becomes unreachable. The adoption harness models v1.4.150 agent/setup/shell tabs, current-generation restart and reconnect, exact handle/incarnation/worktree/host checks, topology CAS, competing clients, split-pane/group restoration, WSL ownership, and SSH owner rejection. The secondary parity repro runs independent clients against one headless remote Orca runtime over encrypted pairing and a real daemon-backed PTY. The primary headed topology was exercised with an isolated macOS Orca desktop server, a separate paired Edge client, a real Codex process, injected WebSocket loss, reconnect, and explicit stop; host inspection and visible client state agreed throughout. SSH coverage is provider/relay contract and fault-injection coverage only; it does not substitute for paired-server coverage. Live Windows, Linux, WSL, and SSH hosts remain gaps.",
"motivatingLinks": [
"https://github.com/stablyai/orca/issues/8878",
+ "https://github.com/stablyai/orca/issues/9151",
"https://github.com/stablyai/orca/issues/9352",
"https://github.com/stablyai/orca/pull/9687"
],
- "invariant": "For every claim-capable execution route, one provider-session identity has at most one live PTY owner and one canonical host surface across concurrent clients, retries, reconnects, and stale publications. A physical exit retires that exact incarnation durably so stale client state and host restart cannot recreate it. Mixed-version routes select the unchanged legacy request before any authority side effect or execution-owner-local filesystem access.",
- "oracle": "Race independent clients and repeated operation IDs, then assert one physical spawn and one canonical PTY/surface; inject exit-before-reply, provider disconnect, conflicting claim scope, and old daemon/relay capabilities; assert safe adoption or explicit failure without a second spawn. After exact exit, assert terminal and tab listings omit the surface, a stale publication cannot restore it, restart cannot resurrect it, and an exact provisional handoff is consumed even when exit wins before the next snapshot.",
+ "invariant": "For every claim-capable execution route, one provider-session identity has at most one live PTY owner and one canonical host surface across concurrent clients, retries, reconnects, and stale publications. A live orphan may be adopted only when the controller proves its exact handle and incarnation, its worktree and host owner match, no competing visual owner exists, and a host topology CAS wins. A viewer may classify completion only from successful host/provider inspection or explicit lifecycle evidence; transport, handle, or provider unavailability remains unknown and breaks any consecutive-idle proof. A physical exit retires that exact incarnation durably so stale client state and host restart cannot recreate it. Mixed-version routes select the unchanged legacy request before any authority side effect or execution-owner-local filesystem access.",
+ "oracle": "Race independent clients and repeated operation IDs, then assert one physical spawn and one canonical PTY/surface; inject exit-before-reply, provider disconnect, conflicting claim scope, old daemon/relay capabilities, reused handles, stale incarnations, owner mismatch, and topology revision conflict; assert safe adoption or explicit failure without a second spawn or wrong-process attachment. Restore legacy split panes and groups beside a newer host-owned tab, preserving output, input, resize, titles, tab/leaf identity, active group, and multi-client convergence. For completion, drive a known running agent through outer transport loss, authoritative provider rejection, reconnect, explicit stop, real exit status, and successful hook completion; assert unavailable evidence never dispatches completion and two fresh authoritative idle samples are required after the gap. After exact exit, assert terminal and tab listings omit the surface, a stale publication cannot restore it, restart cannot resurrect it, and an exact provisional handoff is consumed even when exit wins before the next snapshot.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/shared/claimed-agent-pty-owner.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/providers/ssh-pty-provider-agent-session-create-operation.test.ts src/main/runtime/orca-runtime-agent-session-operation.test.ts src/main/runtime/remote-agent-session-host-authority.integration.test.ts src/main/runtime/orca-runtime-terminal-retirement.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts src/renderer/src/runtime/remote-runtime-session-tabs-inflight.test.ts src/renderer/src/runtime/web-runtime-session.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts tests/e2e/remote-terminal-tab-retirement.unit.test.ts",
- "pnpm test:repro:remote-agent-session"
+ "pnpm test:repro:remote-agent-session",
+ "Manual headed paired-server journey: isolated Orca desktop host + separate paired web client + real Codex process + 20-second WebSocket fault + reconnect + explicit stop",
+ "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/terminal-orphan-owner.test.ts src/main/runtime/terminal-orphan-topology.test.ts src/renderer/src/runtime/web-session-terminal-orphan-recovery.test.ts src/renderer/src/runtime/web-session-terminal-orphan-mixed-version.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts --maxWorkers=1",
+ "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/pty-process-inspection.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/relay/pty-handler.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/runtime/orca-runtime.test.ts tests/e2e/remote-agent-completion-authority.unit.test.ts src/renderer/src/runtime/runtime-terminal-inspection.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts",
+ "pnpm exec vitest run --config config/vitest.config.ts tests/e2e/remote-agent-completion-authority.unit.test.ts src/main/providers/pty-process-inspection.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/relay/pty-handler.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/renderer/src/runtime/runtime-terminal-inspection.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/lib/codex-session-restart.test.ts"
],
"testFiles": [
+ "src/main/providers/pty-process-inspection.test.ts",
+ "src/main/daemon/terminal-host.test.ts",
+ "src/main/daemon/daemon-pty-router.test.ts",
+ "src/main/daemon/degraded-daemon-pty-provider.test.ts",
+ "src/relay/pty-handler.test.ts",
+ "src/main/runtime/orca-runtime.test.ts",
+ "tests/e2e/remote-agent-completion-authority.unit.test.ts",
+ "src/renderer/src/runtime/runtime-terminal-inspection.test.ts",
+ "src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts",
+ "src/renderer/src/components/terminal-pane/pty-connection.test.ts",
+ "src/renderer/src/lib/codex-session-restart.test.ts",
"src/shared/claimed-agent-pty-owner.test.ts",
"src/main/daemon/daemon-pty-adapter.test.ts",
"src/main/providers/ssh-pty-provider-agent-session-create-operation.test.ts",
@@ -1438,9 +1456,70 @@
"src/renderer/src/runtime/remote-runtime-session-tabs-inflight.test.ts",
"src/renderer/src/runtime/web-runtime-session.test.ts",
"src/renderer/src/runtime/web-session-tabs-sync.test.ts",
- "tests/e2e/remote-terminal-tab-retirement.unit.test.ts"
+ "tests/e2e/remote-terminal-tab-retirement.unit.test.ts",
+ "src/main/runtime/orca-runtime.test.ts",
+ "src/main/runtime/terminal-orphan-owner.test.ts",
+ "src/main/runtime/terminal-orphan-topology.test.ts",
+ "src/renderer/src/runtime/web-session-terminal-orphan-recovery.test.ts",
+ "src/renderer/src/runtime/web-session-terminal-orphan-mixed-version.test.ts"
],
"assertionRefs": [
+ {
+ "file": "src/main/runtime/orca-runtime.test.ts",
+ "assertions": [
+ "completion-sensitive process inspection preserves authoritative host/provider failures"
+ ]
+ },
+ {
+ "file": "src/main/providers/pty-process-inspection.test.ts",
+ "assertions": [
+ "dedicated provider inspection preserves failures and rejects missing PTYs instead of returning idle evidence"
+ ]
+ },
+ {
+ "file": "src/main/daemon/daemon-pty-router.test.ts",
+ "assertions": [
+ "completion inspection rejects an unmapped session instead of borrowing the current daemon"
+ ]
+ },
+ {
+ "file": "src/main/daemon/degraded-daemon-pty-provider.test.ts",
+ "assertions": [
+ "completion inspection rejects an unmapped session instead of borrowing the local fallback"
+ ]
+ },
+ {
+ "file": "src/relay/pty-handler.test.ts",
+ "assertions": [
+ "strict relay inspection rejects a missing PTY"
+ ]
+ },
+ {
+ "file": "tests/e2e/remote-agent-completion-authority.unit.test.ts",
+ "assertions": [
+ "transport loss remains unknown through reconnect and cannot dispatch completion",
+ "returned unavailability or a thrown transport failure interrupts consecutive-idle proof and requires two fresh authoritative idle samples",
+ "explicit stop, real exit status, and genuine successful completion remain distinct"
+ ]
+ },
+ {
+ "file": "src/renderer/src/runtime/runtime-terminal-inspection.test.ts",
+ "assertions": [
+ "direct SSH terminals use strict main-process inspection rather than lax split IPC evidence"
+ ]
+ },
+ {
+ "file": "src/renderer/src/components/terminal-pane/pty-connection.test.ts",
+ "assertions": [
+ "completion polling uses the atomic process-inspection boundary without regressing established lifecycle behavior"
+ ]
+ },
+ {
+ "file": "src/renderer/src/lib/codex-session-restart.test.ts",
+ "assertions": [
+ "one unreachable pane cannot suppress restart notices for another authoritatively confirmed Codex pane"
+ ]
+ },
{
"file": "src/shared/claimed-agent-pty-owner.test.ts",
"assertions": [
@@ -1497,9 +1576,52 @@
"one exact exit produces one same-epoch higher-version host publication and one durable persistence flush",
"same-epoch stale publications cannot resurrect the retired surface after reconnect"
]
+ },
+ {
+ "file": "src/main/runtime/orca-runtime.test.ts",
+ "assertions": [
+ "v1.4.150-shaped agent, setup, and shell PTYs adopt as one CAS transaction while stale incarnation and competing clients fail safely",
+ "current-generation restart and disconnect/reconnect preserve output, input, resize, title, tab, leaf, handle, and incarnation identity",
+ "split-pane and multi-group legacy topology merges beside a newer host-owned terminal without replacing it",
+ "equivalent Windows and separator-normalized persisted worktree keys canonicalize without duplicate terminal topology",
+ "connection mismatch, reused handles, SSH ownership mismatch, and stale topology revisions cannot claim a live PTY while WSL ownership succeeds"
+ ]
+ },
+ {
+ "file": "src/renderer/src/runtime/web-session-terminal-orphan-recovery.test.ts",
+ "assertions": [
+ "absence stays pending until an exact live orphan adoption settles",
+ "client pane and group topology is pruned to exact orphan claims and translated to host tab identities",
+ "a missing split leaf remains recoverable when another leaf in the same tab is already host-owned"
+ ]
+ },
+ {
+ "file": "src/renderer/src/runtime/web-session-terminal-orphan-mixed-version.test.ts",
+ "assertions": [
+ "mixed-version inventory without incarnation evidence remains visible but cannot adopt",
+ "a truncated legacy unfiltered inventory cannot hide a candidate whose liveness is unresolved"
+ ]
}
],
"evidenceRuns": [
+ {
+ "date": "2026-07-22",
+ "runner": "manual",
+ "platform": "macos",
+ "command": "Manual headed paired-server journey: isolated Orca desktop host + separate paired web client + real Codex process + 20-second WebSocket fault + reconnect + explicit stop",
+ "result": "passed",
+ "durationSeconds": 549,
+ "summary": "The primary user topology used an isolated headed Orca desktop as the owning server and a separate paired Edge client. Host inspection reported Codex alive before, during, and after a page-scoped WebSocket fault; the client showed no completion toast, reconnected to the same live Codex TUI, and explicit stop restored the shell prompt with no child process."
+ },
+ {
+ "date": "2026-07-23",
+ "runner": "local",
+ "platform": "macos",
+ "command": "pnpm exec vitest run --config config/vitest.config.ts tests/e2e/remote-agent-completion-authority.unit.test.ts src/main/providers/pty-process-inspection.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/relay/pty-handler.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/renderer/src/runtime/runtime-terminal-inspection.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/lib/codex-session-restart.test.ts",
+ "result": "passed",
+ "durationSeconds": 11.96,
+ "summary": "Eleven focused files and 870 tests passed on the current-main candidate. The cross-boundary harness fails with the implementation reverted by dispatching process-exit from unavailable remote evidence. Direct SSH uses strict main-process inspection, daemon and relay inspection reject missing or unmapped sessions, the terminal lifecycle suite uses the atomic inspection boundary, and one stale pane cannot suppress restart notices for a separately confirmed Codex pane."
+ },
{
"date": "2026-07-21",
"runner": "local",
@@ -1510,13 +1632,13 @@
"summary": "Ten focused files and 325 tests passed after the final review fixes, covering claim scope, mixed-version Pi/SSH fallback ordering, operation replay, terminal retirement, causal inventory fencing, exact concurrent handoff confirmation, daemon-generation integration, transport behavior, and remote host integration."
},
{
- "date": "2026-07-21",
+ "date": "2026-07-22",
"runner": "local",
"platform": "macos",
"command": "pnpm test:repro:remote-agent-session",
"result": "passed",
- "durationSeconds": 48.47,
- "summary": "The secondary build-backed headless parity harness passed over encrypted WebSocket pairing with independent clients, proving one spawn, retry adoption, durable exit retirement, stale-publication rejection, and no restart resurrection."
+ "durationSeconds": 48.63,
+ "summary": "The secondary build-backed headless parity harness passed post-rebase on main@72a2d7bc7 over encrypted WebSocket pairing with independent clients, proving one spawn, retry adoption, durable exit retirement, stale-publication rejection, and no restart resurrection."
},
{
"date": "2026-07-22",
@@ -1556,27 +1678,166 @@
},
"redGreenEvidence": {
"status": "partial",
- "evidence": "The exact cross-boundary retirement oracle is red on pre-#9687 commit 2a32c5c9a and PR #9053 head d3a1d3047 because the pinned persisted surface remains in the host publication, and green on main@4fce2de49. Duplicate-resume red evidence remains encoded in lower-layer tests; saved CI artifacts are still needed."
+ "evidence": "Issue #9151 has local red/green evidence: current main dispatches process-exit from a stale remote handle, while the fix preserves unknown liveness and requires fresh consecutive idle evidence after reconnect. The exact retirement oracle is red on pre-#9687 commit 2a32c5c9a and PR #9053 head d3a1d3047 because the pinned persisted surface remains in the host publication, and green on main@4fce2de49. Duplicate-resume red evidence remains encoded in lower-layer tests; saved CI artifacts are still needed."
},
"performanceBudget": {
"required": true,
- "evidence": "Agent-session reconciliation runs only at explicit claim admission, dedupes concurrent provider listing, and adds no polling or renderer output work. Create-operation ledgers are capped globally and per client, expire after 24 hours, and reject rather than evict live replay fences. Capability caches are bounded or connection-scoped, and exact handoffs are consumed by the next authoritative snapshot."
+ "evidence": "Agent-session reconciliation runs only at explicit claim admission, dedupes concurrent provider listing, and adds no polling or renderer output work. Completion inspection reuses the coordinator's per-pane in-flight guard, global concurrency/rate queue, and existing error backoff; the strict daemon path reduces two foreground RPCs to one. Create-operation ledgers are capped globally and per client, expire after 24 hours, and reject rather than evict live replay fences. Capability caches are bounded or connection-scoped, and exact handoffs are consumed by the next authoritative snapshot."
},
"promotionCriteria": [
"Run the focused gate and remote-server repro for at least 100 consecutive passes or 14 days across required CI platforms.",
"Attach saved red/green evidence for duplicate remote resume and exit-before-snapshot retirement.",
- "Add the primary live journey with a headed Orca desktop server and a separate paired client; use a physical host when OS, ConPTY, update, sleep, firewall, or window lifecycle is causal.",
+ "Automate the headed Orca desktop-server journey with a separate paired client; add a physical host only when OS, ConPTY, update, sleep, firewall, or window lifecycle is causal.",
"Add live SSH/WSL provider evidence before claiming full provider coverage; Docker SSH proves only the SSH provider/relay path."
],
"knownGaps": [
- "The primary live topology—a headed Orca desktop server with an isolated profile paired to a separate persistent client—has not been run for this issue. The causal boundary is host membership rather than OS/window/ConPTY/update/sleep behavior, so a physical headed host was not required for the current disposition.",
+ "The primary headed macOS desktop-server journey is collected but not automated in CI; Windows and Linux window, ConPTY, update, sleep/wake, and firewall behavior remain uncollected.",
+ "Mixed-version pairings remain conservative only when the completion-aware client and strict-inspection host changes are both present; older peers retain their legacy classification behavior.",
"The secondary headless parity harness runs on macOS with a local daemon-backed execution owner and independent short-lived encrypted RPC clients; two persistent viewer-store mirrors and reconnect ordering are joined deterministically in the cross-boundary unit test rather than mounted live.",
- "SSH and relay failure ordering is deterministic provider-contract coverage, not a live SSH-host journey or paired-Orca-server proof; WSL has no provider-specific run.",
+ "SSH and relay failure ordering is deterministic provider-contract coverage, not a live SSH-host journey or paired-Orca-server proof; WSL has no provider-specific run, and Linux and Windows runs remain uncollected.",
"Fresh-launch operation replay is memory-backed and intentionally does not survive runtime restart; a durable operation journal is a documented future extension.",
"Automatic sleep checkpoints, verified nested-SSH execution namespaces, and multi-process profile coordination remain outside v1."
],
"demotionRule": "Keep experimental or demote if the focused gate flakes without a product or harness bug, if a retry can physically spawn twice, if a stale exit/publication can replace or resurrect a terminal, or if mixed-version fallback occurs after an authority side effect."
},
+ {
+ "id": "runtime-routing.active-server-preference",
+ "title": "Active Server changes only through its explicit Advanced control",
+ "maturity": "experimental",
+ "protection": "partial",
+ "owner": "runtime-routing",
+ "layer": "main-preload-renderer-persistence-contract",
+ "surfaces": [
+ "Advanced Active Server setting",
+ "saved server Connect and Disconnect",
+ "remote workspace navigation",
+ "terminal reveal and create",
+ "browser and mobile handoff",
+ "app restart"
+ ],
+ "platforms": [
+ "macos",
+ "linux",
+ "windows",
+ "mobile"
+ ],
+ "providers": [
+ "local",
+ "remote-runtime",
+ "ssh",
+ "wsl"
+ ],
+ "coveredPlatforms": [
+ "macos"
+ ],
+ "coveredProviders": [
+ "local",
+ "remote-runtime",
+ "ssh",
+ "wsl"
+ ],
+ "coverageNotes": "Platform-neutral deterministic tests separate the durable Active Server preference from per-client connection, selected-workspace, browser-session, and execution-host routing. The composed regression models Local desktop -> connect/navigate Windows 2 -> reveal a local terminal -> restart. Multi-client browser host overrides, multi-server profile caches, generic settings IPC rejection, local and remote workspace ownership, and restart reset of transient routing are covered. Live desktop UI runs remain uncollected.",
+ "motivatingLinks": [
+ "https://github.com/stablyai/orca/pull/9687"
+ ],
+ "invariant": "Only an explicit user change in Settings > Remote Orca Servers > Advanced > Active Server may mutate activeRuntimeEnvironmentId. Connecting, pairing, disconnecting, selecting or revealing a workspace or terminal, browser/mobile handoff, remote navigation, and reconnect must use transient or target-owner routing and must never rewrite the durable preference. Generic settings mutation cannot bypass the dedicated preference IPC.",
+ "oracle": "Start with Active Server=Local desktop, connect and navigate Windows 2, then reveal a local terminal and assert it succeeds while the persisted preference remains local before and after restart. Repeat with multiple clients and servers, browser host switches, remote-owned and local-owned workspaces, pairing/connect/disconnect, and generic settings writes. Assert only the dedicated validated preference method changes activeRuntimeEnvironmentId and stale host-operation completions cannot overwrite the newly selected transient host.",
+ "commands": [
+ "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/settings.test.ts src/main/ipc/runtime-environments.test.ts src/renderer/src/store/slices/settings.test.ts src/renderer/src/store/slices/browser.test.ts src/renderer/src/components/settings/browser-session-host-selection.test.ts src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts src/renderer/src/components/status-bar/SshStatusSegment.test.ts src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts src/renderer/src/hooks/useIpcEvents.test.ts src/renderer/src/web/web-preload-api.test.ts --maxWorkers=1"
+ ],
+ "testFiles": [
+ "src/main/ipc/settings.test.ts",
+ "src/main/ipc/runtime-environments.test.ts",
+ "src/renderer/src/store/slices/settings.test.ts",
+ "src/renderer/src/store/slices/browser.test.ts",
+ "src/renderer/src/components/settings/browser-session-host-selection.test.ts",
+ "src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts",
+ "src/renderer/src/components/status-bar/SshStatusSegment.test.ts",
+ "src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts",
+ "src/renderer/src/hooks/useIpcEvents.test.ts",
+ "src/renderer/src/web/web-preload-api.test.ts"
+ ],
+ "assertionRefs": [
+ {
+ "file": "src/main/ipc/settings.test.ts",
+ "assertions": [
+ "generic settings IPC strips activeRuntimeEnvironmentId while the dedicated validated IPC persists it",
+ "invalid preference types and unknown server identities cannot mutate the durable preference"
+ ]
+ },
+ {
+ "file": "src/renderer/src/hooks/useIpcEvents.test.ts",
+ "assertions": [
+ "Local desktop remains the durable default after transient Windows 2 navigation and a focused local terminal reveal succeeds",
+ "local and remote terminal create route by target workspace ownership instead of the durable preference"
+ ]
+ },
+ {
+ "file": "src/renderer/src/store/slices/browser.test.ts",
+ "assertions": [
+ "multiple clients select different transient browser hosts without changing Active Server",
+ "restart clears transient browser host override while retaining the durable local preference",
+ "late profile and import results update only their captured host and cannot overwrite a newer selection"
+ ]
+ },
+ {
+ "file": "src/renderer/src/components/settings/browser-session-host-selection.test.ts",
+ "assertions": [
+ "a removed transient server override falls back to an available host instead of leaving browser settings on an invalid option"
+ ]
+ },
+ {
+ "file": "src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts",
+ "assertions": [
+ "connection status and the Advanced default-host selection are distinct concepts"
+ ]
+ },
+ {
+ "file": "src/renderer/src/web/web-preload-api.test.ts",
+ "assertions": [
+ "generic web settings writes cannot mutate Active Server",
+ "the dedicated web preference setter rejects unknown server identities without corrupting the saved choice"
+ ]
+ }
+ ],
+ "evidenceRuns": [
+ {
+ "date": "2026-07-22",
+ "runner": "local",
+ "platform": "macos",
+ "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/settings.test.ts src/main/ipc/runtime-environments.test.ts src/renderer/src/store/slices/settings.test.ts src/renderer/src/store/slices/browser.test.ts src/renderer/src/components/settings/browser-session-host-selection.test.ts src/renderer/src/components/settings/RuntimeEnvironmentsPane.test.ts src/renderer/src/components/status-bar/SshStatusSegment.test.ts src/renderer/src/components/sidebar/use-add-repo-host-selection.test.ts src/renderer/src/hooks/useIpcEvents.test.ts src/renderer/src/web/web-preload-api.test.ts --maxWorkers=1",
+ "result": "passed",
+ "durationSeconds": 6.55,
+ "summary": "Ten files and 282 tests passed, including the composed Local -> Windows 2 navigation -> local reveal -> restart regression, dedicated-only preference persistence, removed transient-host fallback, multi-client browser routing, late host-operation suppression, and web pairing/preference separation."
+ }
+ ],
+ "runtimeBudget": {
+ "p95Seconds": 20,
+ "scope": "focused persistence and routing contract tests"
+ },
+ "flakeHistory": {
+ "status": "unknown",
+ "evidence": "New deterministic gate with no soak history."
+ },
+ "redGreenEvidence": {
+ "status": "partial",
+ "evidence": "The user-observed local terminal reveal failure and unintended Active Server switch are encoded by deterministic routing and persistence tests; a saved intentional-break artifact is not yet attached."
+ },
+ "performanceBudget": {
+ "required": false,
+ "evidence": "Preference writes are explicit user actions; transient routing adds no polling and uses existing host/worktree indexes."
+ },
+ "promotionCriteria": [
+ "Run the focused gate in soak across macOS, Linux, and Windows.",
+ "Attach a live Windows Local -> Windows 2 -> local reveal -> restart artifact.",
+ "Attach saved red/green evidence for generic settings mutation and transient connection routing."
+ ],
+ "knownGaps": [
+ "The exact journey is deterministic contract coverage, not a packaged Windows UI automation run.",
+ "Browser/mobile handoff is covered through transient routing state and preload contracts, not a live phone browser session."
+ ],
+ "demotionRule": "Demote or block release if any non-Advanced path mutates Active Server, if local reveal depends on the durable default instead of workspace ownership, or if transient host state survives restart."
+ },
{
"id": "terminal-geometry.visible-convergence",
"title": "Visible desktop terminals converge across xterm, fit, PTY, shell, and runtime mirror size",
@@ -4608,16 +4869,18 @@
"macos"
],
"coveredProviders": [],
- "coverageNotes": "Local macOS evidence over the runtime-RPC stream budgets on main@1282f5c2d. PR #5824 adds a platform-neutral mobile decision gate proving chat-covered terminal streams pause and resume only after the mounted WebView is ready; live Android restore evidence remains required. The pending stack adds byte-exact 512KB/2MB/256KB/48KB budget assertions; legacy JSON subscribe parity remains undecided.",
+ "coverageNotes": "Local macOS evidence covers runtime-RPC stream budgets plus paired-renderer parse/discard credit. Deferred credit is shared by local and remote transports, batches ACKs at 192 KiB or 4 ms, grows per-stream windows from 512 KiB to 2 MiB and aggregate windows from 2 MiB to 8 MiB, bounds queued output to 256 KiB per stream, and caps each multiplex connection at 32 active or pending streams for an 8 MiB aggregate pending-output ceiling. Deterministic tests cover replay ordering, stale generations, malformed frames, hidden panes, queue eviction, disposal, send/recovery failure, repeated pending-slot replacement, reconnect, and round-robin fairness. The opt-in benchmark covers 1/20/100 ms RTT and 1/4/8 viewers, exact protocol-frame allocations, scheduler CPU, and measured @xterm/headless parser CPU/retained heap. Live Android restore evidence, browser/WebGL parser measurements, and legacy JSON subscribe parity remain required.",
"motivatingLinks": [
"https://github.com/stablyai/orca/pull/6951",
"https://github.com/stablyai/orca/pull/6955",
"https://github.com/stablyai/orca/pull/7009"
],
- "invariant": "Runtime and mobile terminal subscriptions must cap initial snapshots, live output buffered while snapshots load, chunk sizes, and batch sizes; a terminal covered by native chat must have no live output subscription and must restore from fresh scrollback when revealed, while preserving output order, input locks, resize/driver events, and fallback parity or explicit fallback deprecation.",
- "oracle": "The current executable slice asserts mobile initial snapshots downgrade until they fit <=512KB, requested binary snapshots downgrade until they fit <=2MB, binary live output queued while the initial snapshot loads stays <=256KB while preserving the newest tail, large binary output is split into <=48KB frames, output bursts are coalesced before emit, aborts do not register stale listeners, and stale mobile resize re-stream completions are dropped. The mobile native-chat decision test asserts an active stream pauses while covered and resumes only for a ready active terminal. JSON fallback parity and live Android scrollback restoration remain explicit gaps.",
+ "invariant": "Runtime and mobile terminal subscriptions must cap initial snapshots, live output buffered while snapshots load, chunk sizes, batches, and aggregate in-flight credit. ACK means the renderer parsed the bytes or intentionally discarded them; receipt-time ACK is forbidden. Every replay, stale-generation, malformed-frame, hidden-pane, eviction, disposal, error, and reconnect path must settle credit exactly once so streams neither leak memory nor stall. A terminal covered by native chat must restore from fresh scrollback when revealed, while preserving output order, input locks, resize/driver events, fairness, and fallback parity or explicit fallback deprecation.",
+ "oracle": "Assert mobile initial snapshots downgrade until they fit <=512KB, requested binary snapshots downgrade until they fit <=2MB, live output queued while snapshots load stays <=256KB per stream, large output splits into <=48KB frames, and output bursts coalesce. Feed paired output through the xterm parse callback and prove ACK is deferred until parse or intentional discard, then inject stale generation, malformed/transformed frames, replay failure, queue eviction, hidden panes, pane disposal, ACK send failure, recovery serialization failure, and reconnect races; assert ordered replay and exactly-once credit settlement. Fill the aggregate window across bulk and interactive streams, ACK once, and prove round-robin progress. Run the opt-in 64 MiB/viewer RTT matrix and enforce bounded 8 MiB aggregate in-flight memory, >7 MiB/s/viewer at 100 ms RTT, and <200 ms completion spread. JSON fallback parity and live Android scrollback restoration remain explicit gaps.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/main/runtime/rpc/terminal-output-batching.test.ts src/main/runtime/rpc/terminal-multiplex.test.ts",
+ "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/main/runtime/rpc/terminal-output-batching.test.ts src/main/runtime/rpc/terminal-multiplex.test.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts src/renderer/src/components/terminal-pane/terminal-pty-ack-gate.test.ts src/renderer/src/lib/pane-manager/terminal-delivery-credit.test.ts src/renderer/src/runtime/remote-runtime-terminal-parse-backpressure.test.ts src/renderer/src/runtime/runtime-terminal-stream.test.ts --maxWorkers=1",
+ "ORCA_TERMINAL_PERF_BENCH=1 pnpm exec vitest run --config config/vitest.config.ts --disableConsoleIntercept src/main/runtime/rpc/terminal-multiplex-flow-control.bench.test.ts",
"pnpm --dir mobile exec vitest run --root .. mobile/src/session/mobile-native-chat-terminal-stream.test.ts",
"pnpm --dir mobile exec vitest run --root .. mobile/src/session/use-mobile-native-chat-terminal-stream.test.ts"
],
@@ -4625,6 +4888,12 @@
"src/main/runtime/rpc/terminal-subscribe-buffer.test.ts",
"src/main/runtime/rpc/terminal-output-batching.test.ts",
"src/main/runtime/rpc/terminal-multiplex.test.ts",
+ "src/main/runtime/rpc/terminal-multiplex-flow-control.bench.test.ts",
+ "src/renderer/src/components/terminal-pane/pty-connection.test.ts",
+ "src/renderer/src/components/terminal-pane/terminal-pty-ack-gate.test.ts",
+ "src/renderer/src/lib/pane-manager/terminal-delivery-credit.test.ts",
+ "src/renderer/src/runtime/remote-runtime-terminal-parse-backpressure.test.ts",
+ "src/renderer/src/runtime/runtime-terminal-stream.test.ts",
"mobile/src/session/mobile-native-chat-terminal-stream.test.ts",
"mobile/src/session/use-mobile-native-chat-terminal-stream.test.ts"
],
@@ -4649,7 +4918,33 @@
"assertions": [
"requested snapshots fall back smaller when serialized data exceeds the send budget",
"oversized live output frames are bounded for subscribed binary streams",
- "multibyte live output flushes when encoded bytes reach the batch budget"
+ "multibyte live output flushes when encoded bytes reach the batch budget",
+ "adaptive credit grows only after ACK, stays globally bounded, and drains pending streams round-robin",
+ "send and recovery serialization failures detach once instead of leaking credit or retrying forever",
+ "32 active or pending slots cap aggregate queued output and repeated pending-slot subscribe cancels its older waiter"
+ ]
+ },
+ {
+ "file": "src/renderer/src/lib/pane-manager/terminal-delivery-credit.test.ts",
+ "assertions": [
+ "nested synchronous deliveries restore the outer credit owner",
+ "unclaimed intentional discards settle automatically while every claimed scheduler child must settle before the parent credits"
+ ]
+ },
+ {
+ "file": "src/renderer/src/runtime/remote-runtime-terminal-parse-backpressure.test.ts",
+ "assertions": [
+ "paired renderer ACK waits for xterm parse completion or explicit discard",
+ "192 KiB parsed output batches into one ACK while the 4 ms timer releases interactive output",
+ "malformed frames, malformed transformed output, disposal, late parse, renderer delivery failure, and ACK transport failure release credit or close the owning stream without reordering output"
+ ]
+ },
+ {
+ "file": "src/main/runtime/rpc/terminal-multiplex-flow-control.bench.test.ts",
+ "assertions": [
+ "one through eight viewers stay within the 8 MiB aggregate adaptive window",
+ "the 100 ms RTT model sustains more than 7 MiB/s per viewer with less than 200 ms fairness spread",
+ "the opt-in benchmark reports RTT throughput, scheduler CPU time, exact protocol frame allocations, completion spread, and measured @xterm/headless parser CPU and retained heap"
]
},
{
@@ -4686,6 +4981,15 @@
"result": "passed",
"durationSeconds": 0.2,
"summary": "The focused mobile native-chat suite passed with 3 terminal-stream lifecycle assertions in the staged PR #5824 worktree."
+ },
+ {
+ "date": "2026-07-22",
+ "runner": "local",
+ "platform": "macos",
+ "command": "ORCA_TERMINAL_PERF_BENCH=1 pnpm exec vitest run --config config/vitest.config.ts --disableConsoleIntercept src/main/runtime/rpc/terminal-multiplex-flow-control.bench.test.ts",
+ "result": "passed",
+ "durationSeconds": 0.91,
+ "summary": "The 1/20/100 ms RTT x 1/4/8 viewer matrix stayed at or below 8 MiB in flight with zero completion spread. At 100 ms it modeled 18.8 MiB/s per viewer for 1-4 viewers and 9.7 MiB/s for 8 viewers. Measured @xterm/headless parsing was 26.7/63.6/95.3 aggregate MiB/s for 1/4/8 viewers, with 84.4/236.5/336.0 ms CPU and 2893/13409/28991 KiB retained heap for 4 MiB per viewer."
}
],
"runtimeBudget": {
@@ -4702,7 +5006,7 @@
},
"performanceBudget": {
"required": true,
- "evidence": "This gate is the byte and batching budget for runtime/mobile terminal streaming."
+ "evidence": "Parsed/discarded credit uses 192 KiB/4 ms ACK batching, 512 KiB-to-2 MiB adaptive per-stream windows, a 2 MiB-to-8 MiB aggregate window, <=48 KiB output frames, <=256 KiB queued output per stream, and <=32 streams per connection (8 MiB aggregate pending output). The 64 MiB/viewer model gate requires >7 MiB/s/viewer at 100 ms RTT, <200 ms completion spread, and aggregate in-flight bytes <=8 MiB. The 2026-07-22 run modeled 9.7 MiB/s/viewer at 100 ms with eight viewers and measured @xterm/headless at 95.3 aggregate MiB/s, 336.0 ms parser CPU, and 28991 KiB retained heap for eight 4 MiB viewers."
},
"promotionCriteria": [
"Gate binary multiplex first.",
@@ -4711,7 +5015,8 @@
],
"knownGaps": [
"The pure mobile decision gate does not yet prove live Android WebView scrollback restore after a chat toggle.",
- "Legacy JSON subscribe parity is undecided."
+ "Legacy JSON subscribe parity is undecided.",
+ "The parser measurement uses @xterm/headless; browser renderer/WebGL CPU, GPU, and allocation behavior still need packaged-app performance evidence."
],
"demotionRule": "Cannot promote while a supported stream path has uncapped snapshot or live-output buffering."
},
diff --git a/config/scripts/computer-e2e-workflow.test.mjs b/config/scripts/computer-e2e-workflow.test.mjs
index 3ef41583217..560859c5264 100644
--- a/config/scripts/computer-e2e-workflow.test.mjs
+++ b/config/scripts/computer-e2e-workflow.test.mjs
@@ -177,7 +177,7 @@ describe('computer-use e2e workflow', () => {
)
})
- it('builds Electron main output before every computer-use e2e run', () => {
+ it('builds Electron main output and verifies the CLI before every computer-use e2e run', () => {
const workflow = parse(
readFileSync(join(projectDir, '.github/workflows/computer-e2e.yml'), 'utf8')
)
@@ -187,6 +187,7 @@ describe('computer-use e2e workflow', () => {
.map((step) => step.run)
.filter((run) => typeof run === 'string')
const buildIndex = runs.indexOf('pnpm build:electron-vite')
+ const cliVerifyIndex = runs.indexOf('pnpm verify:cli-runtime')
const e2eIndexes = runs
.map((run, index) => (run.includes('test:e2e:computer') ? index : -1))
.filter((index) => index >= 0)
@@ -195,10 +196,17 @@ describe('computer-use e2e workflow', () => {
buildIndex,
`${jobName} should build out/main before computer e2e`
).toBeGreaterThanOrEqual(0)
+ expect(
+ cliVerifyIndex,
+ `${jobName} should verify the CLI after the main build`
+ ).toBeGreaterThan(buildIndex)
for (const e2eIndex of e2eIndexes) {
expect(buildIndex, `${jobName} should build out/main before computer e2e`).toBeLessThan(
e2eIndex
)
+ expect(cliVerifyIndex, `${jobName} should verify the CLI before computer e2e`).toBeLessThan(
+ e2eIndex
+ )
}
}
})
diff --git a/config/scripts/generate-windows-blockmap.mjs b/config/scripts/generate-windows-blockmap.mjs
new file mode 100644
index 00000000000..7444a7b43dc
--- /dev/null
+++ b/config/scripts/generate-windows-blockmap.mjs
@@ -0,0 +1,18 @@
+// Regenerate the electron-updater `.blockmap` for a (re-signed) Windows installer.
+// electron-builder 26 dropped the app-builder-bin Go binary; blockmap generation
+// now lives in app-builder-lib's pure-JS `buildBlockMap`. Mirrors createBlockmap's
+// "gzip" format for the standalone NSIS installer blockmap.
+import { createRequire } from 'node:module'
+
+const require = createRequire(import.meta.url)
+
+const [input, output] = process.argv.slice(2)
+if (!input || !output) {
+ console.error('usage: generate-windows-blockmap.mjs ')
+ process.exit(1)
+}
+
+const { buildBlockMap } = require('app-builder-lib/out/targets/blockmap/blockmap')
+
+const info = await buildBlockMap(input, 'gzip', output)
+console.log(`blockmap written: ${output} (installer sha512=${info.sha512}, size=${info.size})`)
diff --git a/config/scripts/verify-cli-bin.test.mjs b/config/scripts/verify-cli-bin.test.mjs
index da988064d05..d26a603a148 100644
--- a/config/scripts/verify-cli-bin.test.mjs
+++ b/config/scripts/verify-cli-bin.test.mjs
@@ -47,6 +47,14 @@ describe('verifyPackageCliBin', () => {
})
})
+ it('rejects a compiled CLI with a missing runtime dependency', () => {
+ const { projectDir } = makeProjectWithCli(
+ '#!/usr/bin/env node\nrequire("./missing-runtime-dependency")\n'
+ )
+
+ expect(() => verifyPackageCliBin({ projectDir, runHelp: true })).toThrow()
+ })
+
it('rejects an empty package bin target', () => {
const { projectDir } = makeProjectWithCli('')
diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json
index f8978764ccd..53859a16a32 100644
--- a/config/tsconfig.cli.json
+++ b/config/tsconfig.cli.json
@@ -3,6 +3,10 @@
"include": [
"../src/cli/**/*",
"../src/shared/**/*",
+ "../src/main/agent-state-file-reader.ts",
+ "../src/main/agent-hooks/agent-hook-file-comparison.ts",
+ "../src/main/agent-hooks/agent-hook-file-limits.ts",
+ "../src/main/agent-hooks/agent-hook-sftp-text-reader.ts",
"../src/main/agent-hooks/hook-stdin-contract.ts",
"../src/main/agent-hooks/hook-config-write-path.ts",
"../src/main/agent-hooks/hooks-json-read.ts",
@@ -19,11 +23,12 @@
"../src/main/codex/codex-app-server-client.ts",
"../src/main/codex/codex-app-server-grant-bridge.ts",
"../src/main/codex/codex-app-server-grant-envelope.ts",
+ "../src/main/codex/codex-app-server-grant-json.ts",
"../src/main/codex/codex-app-server-session.ts",
"../src/main/codex/codex-config-mirror.ts",
"../src/main/codex/codex-config-path-reference-rewrite.ts",
- "../src/main/codex/codex-config-settings-upsert.ts",
"../src/main/codex/codex-home-paths.ts",
+ "../src/main/codex/codex-host-retry-deadlines.ts",
"../src/main/codex/codex-hook-identity.ts",
"../src/main/codex/codex-hook-trust-grant.ts",
"../src/main/codex/codex-managed-trust-reconciliation.ts",
@@ -35,8 +40,8 @@
"../src/main/codex/codex-user-hook-trust-rebase-client.ts",
"../src/main/codex/codex-user-hook-trust-rebase.ts",
"../src/main/codex/codex-wsl-hook-install-plan.ts",
+ "../src/main/codex/codex-wsl-reconciliation-generations.ts",
"../src/main/codex/config-settings-promotion.ts",
- "../src/main/codex/config-toml-key-path.ts",
"../src/main/codex/config-toml-line-scan.ts",
"../src/main/codex/config-toml-trust.ts",
"../src/main/codex/hook-service.ts",
@@ -44,12 +49,14 @@
"../src/main/codex-accounts/fs-utils.ts",
"../src/main/codex-accounts/wsl-codex-command.ts",
"../src/main/codex-cli/command.ts",
+ "../src/main/codex-cli/nvm-version-directory-discovery.ts",
"../src/main/command-code/command-code-managed-script.ts",
"../src/main/command-code/hook-service.ts",
"../src/main/copilot/hook-service.ts",
"../src/main/cursor/hook-service.ts",
"../src/main/droid/hook-service.ts",
"../src/main/gemini/hook-service.ts",
+ "../src/main/generated-node-bounded-file-reader.ts",
"../src/main/grok/hook-service.ts",
"../src/main/devin/hook-settings.ts",
"../src/main/devin/hook-service.ts",
diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg
index 50ca9cde0e2..790c0df3df0 100644
--- a/docs/assets/readme-downloads.svg
+++ b/docs/assets/readme-downloads.svg
@@ -1,5 +1,5 @@
-
- downloads: 7.6m
+
+ downloads: 8.0m
@@ -15,7 +15,7 @@
downloads
downloads
- 7.6m
- 7.6m
+ 8.0m
+ 8.0m
diff --git a/docs/assets/wechat-qr.jpg b/docs/assets/wechat-qr.jpg
index ed695f996af..7b02a4d72c9 100644
Binary files a/docs/assets/wechat-qr.jpg and b/docs/assets/wechat-qr.jpg differ
diff --git a/docs/readme/README.zh-CN.md b/docs/readme/README.zh-CN.md
index 595ca2461d1..8121a1fe8da 100644
--- a/docs/readme/README.zh-CN.md
+++ b/docs/readme/README.zh-CN.md
@@ -235,7 +235,7 @@ yay -S stably-orca-bin
- **Discord:** 加入 **[Discord](https://discord.gg/fzjDKHxv8Q)** 社区。
- **Twitter / X:** 关注 **[@orca_build](https://x.com/orca_build)** 获取更新和公告。
-- **微信:** 第一、二个微信群均已满,现在可以加入第三个群。
+- **微信:** 其他群已满,现在加入第 5 群。
diff --git a/mobile/app.json b/mobile/app.json
index bd307ea83c3..6f55551e927 100644
--- a/mobile/app.json
+++ b/mobile/app.json
@@ -18,7 +18,7 @@
"bundleIdentifier": "com.stably.orca.mobile",
"buildNumber": "1",
"infoPlist": {
- "NSLocalNetworkUsageDescription": "Orca connects to the desktop app on your local network.",
+ "NSLocalNetworkUsageDescription": "Orca connects to the desktop app on your LAN.",
"NSMicrophoneUsageDescription": "Allow Orca to record voice dictation and transcribe it on your paired desktop.",
"NSPhotoLibraryUsageDescription": "Allow Orca to attach photos from your library to a terminal session on your paired desktop.",
"NSAppTransportSecurity": {
diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx
index 71dde856ff0..618545e3f7e 100644
--- a/mobile/app/h/[hostId]/session/[worktreeId].tsx
+++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx
@@ -86,6 +86,10 @@ import {
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 {
+ LAST_VISITED_WORKTREE_STORAGE_KEY,
+ serializeLastVisitedWorktreeRecord
+} from '../../../../src/worktree/last-visited-worktree-repo'
import { useMobileDictation } from '../../../../src/hooks/use-mobile-dictation'
import {
triggerMediumImpact,
@@ -175,13 +179,14 @@ import {
} from '../../../../src/session/mobile-terminal-tab-agent'
import type { MobileNewTabAgentOption } from '../../../../src/session/mobile-new-tab-agent-options'
import { loadMobileNewTabAgentOptions } from '../../../../src/session/mobile-new-tab-agent-loader'
-import { useMobileImageAttachment } from '../../../../src/session/use-mobile-image-attachment'
+import { useMobileSessionImageAttachments } from '../../../../src/session/use-mobile-session-image-attachments'
import { useMobileAttachmentInputLeaseGate } from '../../../../src/session/use-mobile-attachment-input-lease-gate'
import { useMobileTerminalPaste } from '../../../../src/session/use-mobile-terminal-paste'
import { useTerminalLiveInputModePreference } from '../../../../src/session/use-terminal-live-input-mode-preference'
import { MobileTerminalLiveInputStatus } from '../../../../src/session/MobileTerminalLiveInputStatus'
import { MobileTerminalInputActions } from '../../../../src/session/MobileTerminalInputActions'
import { resolveMobileFileTabDoc } from '../../../../src/files/mobile-file-tab-doc'
+import { captureMobileFileMutationOwnership } from '../../../../src/files/mobile-file-mutation-ownership'
import { openMobileTerminalFileTap } from '../../../../src/session/mobile-terminal-file-tap-open'
import { useLiveWorktreeName } from '../../../../src/session/use-live-worktree-name'
import {
@@ -208,6 +213,7 @@ import { useMobileNativeChatReadability } from '../../../../src/session/use-mobi
import { useMobileNativeChatInputLease } from '../../../../src/session/use-mobile-native-chat-input-lease'
import { getMobileTerminalActionSheetActions } from '../../../../src/session/mobile-terminal-action-sheet-actions'
import * as nativeChatTerminalStream from '../../../../src/session/mobile-native-chat-terminal-stream'
+import { mobileNativeChatScopeKey } from '../../../../src/session/mobile-native-chat-scope-key'
import { useMobileNativeChatTerminalStream } from '../../../../src/session/use-mobile-native-chat-terminal-stream'
import { subscribeMobileTerminalSafely } from '../../../../src/session/mobile-terminal-stream-subscribe'
import { activateMobileSessionTab } from '../../../../src/session/mobile-session-tab-activation'
@@ -225,6 +231,8 @@ import {
TERMINAL_GESTURE_INPUT_REFILL_PER_SECOND,
updateTerminalCwdFromStreamEvent
} from '../../../../src/session/mobile-session-route-helpers'
+import { MobileSessionFileDocLifecycle } from '../../../../src/session/mobile-session-file-doc-lifecycle'
+import { MobileSessionMarkdownDocLifecycle } from '../../../../src/session/mobile-session-markdown-doc-lifecycle'
import { resolveMarkdownFloatingActionsBottom } from '../../../../src/session/markdown-floating-actions-layout'
import { resolveTabStripScrollOffset } from '../../../../src/session/tab-strip-scroll'
import { activateOpenedSourceControlDiffTab } from '../../../../src/session/opened-mobile-session-tab'
@@ -895,7 +903,9 @@ export default function SessionScreen() {
const tabLayoutsRef = useRef>(new Map())
const [markdownDocs, setMarkdownDocs] = useState>(new Map())
const markdownDocsRef = useRef>(new Map())
+ const markdownDocLifecycleRef = useRef(new MobileSessionMarkdownDocLifecycle())
const [fileDocs, setFileDocs] = useState>(new Map())
+ const fileDocLifecycleRef = useRef(new MobileSessionFileDocLifecycle())
const [diffComments, setDiffComments] = useState([])
const diffCommentsRef = useRef([])
const [diffCommentBusy, setDiffCommentBusy] = useState(false)
@@ -1719,6 +1729,8 @@ export default function SessionScreen() {
if (orphanedDraftTabs.length > 0) {
nextTabs = [...orphanedDraftTabs, ...nextTabs]
}
+ markdownDocLifecycleRef.current.reconcile(nextTabs, setMarkdownDocs)
+ fileDocLifecycleRef.current.reconcile(nextTabs, setFileDocs)
sessionTabsRef.current = nextTabs
// Why: subscribe snapshots often repeat identical payloads; skip re-set to avoid a subscription teardown/replay loop.
setSessionTabs((prev) => (mobileSessionTabsEqual(prev, nextTabs) ? prev : nextTabs))
@@ -1836,8 +1848,7 @@ export default function SessionScreen() {
if (!client) {
return
}
- setMarkdownDocs((prev) => new Map(prev).set(tab.id, { status: 'loading' }))
- try {
+ await markdownDocLifecycleRef.current.load(tab, setMarkdownDocs, async () => {
const response = await client.sendRequest('markdown.readTab', {
worktree: `id:${worktreeId}`,
tabId: tab.id
@@ -1850,19 +1861,16 @@ export default function SessionScreen() {
editable?: boolean
readOnlyReason?: string
}
- setMarkdownDocs((prev) =>
- new Map(prev).set(tab.id, {
- status: 'ready',
- content: result.content,
- localContent: result.content,
- baseVersion: result.version,
- isDirty: false,
- editable: result.editable === true,
- stale: result.isDirty,
- readOnlyReason: result.readOnlyReason
- })
- )
- return
+ return {
+ status: 'ready',
+ content: result.content,
+ localContent: result.content,
+ baseVersion: result.version,
+ isDirty: false,
+ editable: result.editable === true,
+ stale: result.isDirty,
+ readOnlyReason: result.readOnlyReason
+ }
}
if (!shouldReadMarkdownFromDiskAfterReadTabFailure(response as RpcFailure)) {
throw new Error((response as RpcFailure).error.message)
@@ -1880,24 +1888,12 @@ export default function SessionScreen() {
truncated: boolean
byteLength: number
}
- setMarkdownDocs((prev) =>
- new Map(prev).set(
- tab.id,
- buildMarkdownDiskFallbackDoc({
- content: fileResult.content,
- truncated: fileResult.truncated,
- tabIsDirty: tab.isDirty
- })
- )
- )
- } catch {
- setMarkdownDocs((prev) =>
- new Map(prev).set(tab.id, {
- status: 'error',
- message: "Couldn't load markdown"
- })
- )
- }
+ return buildMarkdownDiskFallbackDoc({
+ content: fileResult.content,
+ truncated: fileResult.truncated,
+ tabIsDirty: tab.isDirty
+ })
+ })
},
[client, worktreeId]
)
@@ -1907,31 +1903,13 @@ export default function SessionScreen() {
if (!client) {
return
}
- setFileDocs((prev) => new Map(prev).set(tab.id, { status: 'loading' }))
- try {
- const doc = await resolveMobileFileTabDoc(client, {
+ await fileDocLifecycleRef.current.load(tab, setFileDocs, () =>
+ resolveMobileFileTabDoc(client, {
worktreeId,
relativePath: tab.relativePath,
diffSource: tab.diffSource
})
- setFileDocs((prev) => new Map(prev).set(tab.id, doc))
- } catch (err) {
- const message = err instanceof Error ? err.message : ''
- const previewMessage =
- message === 'binary_file'
- ? 'Binary preview unavailable'
- : message === 'file_too_large'
- ? 'File too large for mobile preview'
- : tab.diffSource === 'staged' || tab.diffSource === 'unstaged'
- ? "Couldn't load diff preview"
- : "Couldn't load file preview"
- setFileDocs((prev) =>
- new Map(prev).set(tab.id, {
- status: 'error',
- message: previewMessage
- })
- )
- }
+ )
},
[client, worktreeId]
)
@@ -2246,6 +2224,9 @@ export default function SessionScreen() {
})
} finally {
markdownSaveInFlightRef.current.delete(tab.id)
+ if (markdownSaveSeqRef.current.get(tab.id) === saveSeq) {
+ markdownSaveSeqRef.current.delete(tab.id)
+ }
}
},
[client, markdownDocs, showToast, worktreeId]
@@ -2518,10 +2499,10 @@ export default function SessionScreen() {
useEffect(() => {
if (hostId && worktreeId) {
- void AsyncStorage.setItem(
- 'orca:last-visited-worktree',
- JSON.stringify({ hostId, worktreeId })
- )
+ const serialized = serializeLastVisitedWorktreeRecord({ hostId, worktreeId })
+ if (serialized) {
+ void AsyncStorage.setItem(LAST_VISITED_WORKTREE_STORAGE_KEY, serialized)
+ }
}
}, [hostId, worktreeId])
@@ -2555,6 +2536,10 @@ export default function SessionScreen() {
terminalDiagnosticsRef.current.resetRoute()
appliedSnapshotMarkerRef.current = { epoch: null, version: -1 }
closedTabTombstonesRef.current.clear()
+ markdownDocLifecycleRef.current.reset()
+ fileDocLifecycleRef.current.reset()
+ markdownSaveSeqRef.current.clear()
+ markdownSaveInFlightRef.current.clear()
for (const queued of terminalGestureInputQueuesRef.current.values()) {
if (queued.timer) {
clearTimeout(queued.timer)
@@ -2574,6 +2559,10 @@ export default function SessionScreen() {
return () => {
sessionTabActionSheetRequestSeqRef.current += 1
sessionTabActionSheetKeyboardHideSubRef.current?.remove()
+ markdownDocLifecycleRef.current.reset()
+ fileDocLifecycleRef.current.reset()
+ markdownSaveSeqRef.current.clear()
+ markdownSaveInFlightRef.current.clear()
clearPendingLiveInputCommit()
clearDelayedActionTimers()
}
@@ -3639,14 +3628,20 @@ export default function SessionScreen() {
showToast
})
- const { attachImage, isAttaching } = useMobileImageAttachment({
+ // Terminal input pastes an attached image straight into the visible terminal;
+ // native chat instead holds it as a composer chip and rides it along on submit.
+ const { attachImage, isAttaching, nativeChatImages } = useMobileSessionImageAttachments({
client,
activeHandle,
+ activeHandleRef,
canSend,
connState,
deviceTokenRef,
- beforeTerminalSend: flushPendingLiveInputBeforeAttachmentSend,
+ nativeChatScopeKey: mobileNativeChatScopeKey(hostId, worktreeId, activeSessionTabId),
+ nativeChatInputLeaseReady,
getActiveWorktreeConnectionId,
+ beforeTerminalSend: flushPendingLiveInputBeforeAttachmentSend,
+ nativeChatBaseSend: nativeChatController.handleNativeChatSend,
showToast,
onSuccess: triggerSelection,
onError: triggerError
@@ -3901,11 +3896,12 @@ export default function SessionScreen() {
try {
const worktree = `id:${worktreeId}`
+ const mutationOwnership = await captureMobileFileMutationOwnership(client, worktree)
for (let attempt = 1; attempt <= 100; attempt += 1) {
const relativePath = attempt === 1 ? 'untitled.md' : `untitled-${attempt}.md`
const createResponse = await client.sendRequest(
'files.createFile',
- { worktree, relativePath },
+ { worktree, relativePath, ...mutationOwnership },
{ timeoutMs: 15_000 }
)
if (!createResponse.ok) {
@@ -4100,7 +4096,23 @@ export default function SessionScreen() {
initializedHandlesRef.current.delete(terminalHandle)
clearTerminalLiveInputDefault(terminalHandle)
}
- setSessionTabs((prev) => prev.filter((candidate) => candidate.id !== tab.id))
+ if (tab.type === 'file') {
+ fileDocLifecycleRef.current.close(tab.id, setFileDocs)
+ }
+ if (tab.type === 'markdown') {
+ markdownDocLifecycleRef.current.close(tab.id, (update) => {
+ setMarkdownDocs((current) => {
+ const next = update(current)
+ markdownDocsRef.current = next
+ return next
+ })
+ })
+ markdownSaveSeqRef.current.delete(tab.id)
+ markdownSaveInFlightRef.current.delete(tab.id)
+ }
+ const remainingTabs = sessionTabsRef.current.filter((candidate) => candidate.id !== tab.id)
+ sessionTabsRef.current = remainingTabs
+ setSessionTabs(remainingTabs)
// Why: tombstone the closed tab and rely on the snapshot, not a blind refetch that often re-added the not-yet-closed tab.
closedTabTombstonesRef.current.set(tab.id, Date.now() + 10_000)
if (activeSessionTabId === tab.id) {
@@ -4379,6 +4391,7 @@ export default function SessionScreen() {
hostedChecksSupported: prIsGithubRepo
})
const showHeaderMoreButton = showAgentSessionHistoryAction || showChecksAction
+ const createTabBusy = creating || creatingBrowser || creatingMarkdown
return (
@@ -4581,24 +4594,16 @@ export default function SessionScreen() {
{
setCreateError('')
setShowCreateTabDrawer(true)
}}
>
- {creating || creatingBrowser || creatingMarkdown
- ? 'Creating...'
- : 'Create Tab'}
+ {createTabBusy ? 'Creating...' : 'Create Tab'}
@@ -4715,8 +4720,7 @@ export default function SessionScreen() {
))}
void attachImage('library')}
- isAttaching={isAttaching}
+ images={nativeChatImages}
onMicPress={handleDictationToggle}
micActive={dictation.isRecording}
dictationMode={dictationMode}
diff --git a/mobile/app/h/[hostId]/tasks.tsx b/mobile/app/h/[hostId]/tasks.tsx
index a9d24ae637a..b25a40682ca 100644
--- a/mobile/app/h/[hostId]/tasks.tsx
+++ b/mobile/app/h/[hostId]/tasks.tsx
@@ -137,9 +137,15 @@ import {
clearMobileTaskCopyFeedbackTimer,
scheduleMobileTaskCopyFeedbackReset
} from '../../../src/tasks/mobile-task-copy-feedback-timer'
+import {
+ createMobileItemPrFileContentScope,
+ createMobileProjectPrFileContentScope,
+ useMobilePrFileContentCache
+} from '../../../src/tasks/use-mobile-pr-file-content-cache'
import type {
BaseRefSearchResult,
GitHubOwnerRepo,
+ GitHubPRFileContents,
PersistedTrustedOrcaHooks,
SparsePreset,
TuiAgent
@@ -358,13 +364,6 @@ type GitHubDetailCheck = {
url?: string | null
}
-type GitHubPRFileContents = {
- original: string
- modified: string
- originalIsBinary: boolean
- modifiedIsBinary: boolean
-}
-
type DetailPayload =
| {
provider: 'github'
@@ -2263,8 +2262,6 @@ export default function MobileTasksScreen() {
const [itemReviewersDraft, setItemReviewersDraft] = useState('')
const [itemReplyDrafts, setItemReplyDrafts] = useState>({})
const [expandedPrFilePath, setExpandedPrFilePath] = useState(null)
- const [prFileContents, setPrFileContents] = useState>({})
- const [prFileLoadingPath, setPrFileLoadingPath] = useState(null)
const [prFileCommentDrafts, setPrFileCommentDrafts] = useState>({})
const [copiedLinkKey, setCopiedLinkKey] = useState(null)
const copiedLinkResetTimerRef = useRef | null>(null)
@@ -2602,6 +2599,23 @@ export default function MobileTasksScreen() {
() => (projectRowItem ? findProjectRowRepo(projectRowItem) : null),
[findProjectRowRepo, projectRowItem]
)
+ const itemPrFileContentScope = createMobileItemPrFileContentScope(actionItem, detailPayload)
+ const projectPrFileContentScope = createMobileProjectPrFileContentScope(
+ projectRowItem,
+ projectRowHostedRepo,
+ projectRowDetail,
+ projectRowItem ? projectRowGitHubRepository(projectRowItem, activeGitHubProjectHost) : null
+ )
+ const activePrFileContentScope = projectRowItem
+ ? projectPrFileContentScope
+ : itemPrFileContentScope
+ const {
+ clear: clearPrFileContents,
+ contents: prFileContents,
+ load: loadPrFileContent,
+ loadingPath: prFileLoadingPath
+ } = useMobilePrFileContentCache(activePrFileContentScope)
+
const itemReviewerCandidates = useMemo(() => {
if (!actionItem || actionItem.provider !== 'github' || actionItem.source.type !== 'pr') {
return []
@@ -4141,8 +4155,7 @@ export default function MobileTasksScreen() {
setItemReviewersDraft('')
setItemReplyDrafts({})
setExpandedPrFilePath(null)
- setPrFileContents({})
- setPrFileLoadingPath(null)
+ clearPrFileContents()
setPrFileCommentDrafts({})
setExpandedResolvedCommentGroups(new Set())
return
@@ -4157,11 +4170,10 @@ export default function MobileTasksScreen() {
setItemReviewersDraft('')
setItemReplyDrafts({})
setExpandedPrFilePath(null)
- setPrFileContents({})
- setPrFileLoadingPath(null)
+ clearPrFileContents()
setPrFileCommentDrafts({})
setExpandedResolvedCommentGroups(new Set())
- }, [actionItem])
+ }, [actionItem, clearPrFileContents])
useEffect(() => {
if (!detailPayload) {
@@ -4458,8 +4470,7 @@ export default function MobileTasksScreen() {
setProjectEditingCommentDraft('')
setProjectReviewersDraft('')
setExpandedPrFilePath(null)
- setPrFileContents({})
- setPrFileLoadingPath(null)
+ clearPrFileContents()
setPrFileCommentDrafts({})
setProjectFieldDrafts({})
return
@@ -4474,8 +4485,7 @@ export default function MobileTasksScreen() {
setProjectEditingCommentDraft('')
setProjectReviewersDraft('')
setExpandedPrFilePath(null)
- setPrFileContents({})
- setPrFileLoadingPath(null)
+ clearPrFileContents()
setPrFileCommentDrafts({})
setProjectFieldDrafts(
Object.fromEntries(
@@ -4579,6 +4589,7 @@ export default function MobileTasksScreen() {
}
}, [
activeGitHubProjectHost,
+ clearPrFileContents,
client,
githubProjectTable,
projectRowDetailRefreshSeq,
@@ -6549,9 +6560,6 @@ export default function MobileTasksScreen() {
return
}
setExpandedPrFilePath(file.path)
- if (prFileContents[file.path]) {
- return
- }
const repo = findProjectRowRepo(row)
if (
!client ||
@@ -6560,49 +6568,45 @@ export default function MobileTasksScreen() {
!row.content.number ||
projectRowDetail?.provider !== 'github' ||
!projectRowDetail.headSha ||
- !projectRowDetail.baseSha
+ !projectRowDetail.baseSha ||
+ !projectPrFileContentScope
) {
setProjectRowDetailError('Unable to load file contents for this pull request.')
return
}
- setPrFileLoadingPath(file.path)
- setProjectRowDetailError('')
- try {
- const response = await client.sendRequest(
- 'github.prFileContents',
- {
- repo: `id:${repo.id}`,
- prNumber: row.content.number,
- prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost),
- path: file.path,
- oldPath: file.oldPath,
- status: file.status ?? 'modified',
- headSha: projectRowDetail.headSha,
- baseSha: projectRowDetail.baseSha
- },
- { timeoutMs: 30_000 }
- )
- if (!isSuccess(response)) {
- throw new Error(response.error.message)
- }
- setPrFileContents((current) => ({
- ...current,
- [file.path]: response.result as GitHubPRFileContents
- }))
- } catch (err) {
- setProjectRowDetailError(
- err instanceof Error ? err.message : 'Failed to load file contents'
- )
- } finally {
- setPrFileLoadingPath(null)
- }
+ await loadPrFileContent(
+ projectPrFileContentScope,
+ file,
+ async () => {
+ const response = await client.sendRequest(
+ 'github.prFileContents',
+ {
+ repo: `id:${repo.id}`,
+ prNumber: row.content.number,
+ prRepo: projectRowGitHubRepository(row, activeGitHubProjectHost),
+ path: file.path,
+ oldPath: file.oldPath,
+ status: file.status ?? 'modified',
+ headSha: projectRowDetail.headSha,
+ baseSha: projectRowDetail.baseSha
+ },
+ { timeoutMs: 30_000 }
+ )
+ if (!isSuccess(response)) {
+ throw new Error(response.error.message)
+ }
+ return response.result
+ },
+ setProjectRowDetailError
+ )
},
[
activeGitHubProjectHost,
client,
expandedPrFilePath,
findProjectRowRepo,
- prFileContents,
+ loadPrFileContent,
+ projectPrFileContentScope,
projectRowDetail
]
)
@@ -7526,49 +7530,43 @@ export default function MobileTasksScreen() {
return
}
setExpandedPrFilePath(file.path)
- if (prFileContents[file.path]) {
- return
- }
if (
!client ||
item.source.type !== 'pr' ||
detailPayload?.provider !== 'github' ||
!detailPayload.headSha ||
- !detailPayload.baseSha
+ !detailPayload.baseSha ||
+ !itemPrFileContentScope
) {
setError('Unable to load file contents for this pull request.')
return
}
- setPrFileLoadingPath(file.path)
- setError('')
- try {
- const response = await client.sendRequest(
- 'github.prFileContents',
- {
- repo: `id:${item.source.repoId}`,
- prNumber: item.source.number,
- path: file.path,
- oldPath: file.oldPath,
- status: file.status ?? 'modified',
- headSha: detailPayload.headSha,
- baseSha: detailPayload.baseSha
- },
- { timeoutMs: 30_000 }
- )
- if (!isSuccess(response)) {
- throw new Error(response.error.message)
- }
- setPrFileContents((current) => ({
- ...current,
- [file.path]: response.result as GitHubPRFileContents
- }))
- } catch (err) {
- setError(err instanceof Error ? err.message : 'Failed to load file contents')
- } finally {
- setPrFileLoadingPath(null)
- }
+ await loadPrFileContent(
+ itemPrFileContentScope,
+ file,
+ async () => {
+ const response = await client.sendRequest(
+ 'github.prFileContents',
+ {
+ repo: `id:${item.source.repoId}`,
+ prNumber: item.source.number,
+ path: file.path,
+ oldPath: file.oldPath,
+ status: file.status ?? 'modified',
+ headSha: detailPayload.headSha,
+ baseSha: detailPayload.baseSha
+ },
+ { timeoutMs: 30_000 }
+ )
+ if (!isSuccess(response)) {
+ throw new Error(response.error.message)
+ }
+ return response.result
+ },
+ setError
+ )
},
- [client, detailPayload, expandedPrFilePath, prFileContents]
+ [client, detailPayload, expandedPrFilePath, itemPrFileContentScope, loadPrFileContent]
)
const addGitHubFileReviewComment = useCallback(
@@ -12322,7 +12320,7 @@ export default function MobileTasksScreen() {
) : prFileContents[file.path] ? (
@@ -13268,7 +13266,7 @@ export default function MobileTasksScreen() {
) : prFileContents[file.path] ? (
diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx
index b7132849845..6110a5a3c09 100644
--- a/mobile/app/index.tsx
+++ b/mobile/app/index.tsx
@@ -27,6 +27,10 @@ import AsyncStorage from '@react-native-async-storage/async-storage'
import { loadHosts } from '../src/transport/host-store'
import { removeHostAndCloseClient } from '../src/transport/host-removal-lifecycle'
import { pickResumeWorktree } from '../src/worktree/resume-worktree'
+import {
+ LAST_VISITED_WORKTREE_STORAGE_KEY,
+ readLastVisitedWorktreeRecord
+} from '../src/worktree/last-visited-worktree-repo'
import type { RpcClient } from '../src/transport/rpc-client'
import { sendSingleFlightRequest } from '../src/transport/request-single-flight'
import {
@@ -394,13 +398,14 @@ export default function HomeScreen() {
router.replace(mobileOnboardingDestination(onboardingSteps))
}
})
- void AsyncStorage.getItem('orca:last-visited-worktree').then((raw) => {
+ void AsyncStorage.getItem(LAST_VISITED_WORKTREE_STORAGE_KEY).then((raw) => {
if (stale || !raw) {
return
}
- try {
- setLastVisited(JSON.parse(raw))
- } catch {}
+ const record = readLastVisitedWorktreeRecord(raw)
+ if (record) {
+ setLastVisited(record)
+ }
})
for (const entry of allClientsRef.current) {
if (entry.client.getState() === 'connected') {
diff --git a/mobile/fastlane/Fastfile b/mobile/fastlane/Fastfile
index a8ccf9b8c49..3ceb91c0d97 100644
--- a/mobile/fastlane/Fastfile
+++ b/mobile/fastlane/Fastfile
@@ -16,6 +16,7 @@
require "base64"
require "json"
+require_relative "ios_release_version"
default_platform(:ios)
@@ -66,25 +67,6 @@ def current_mobile_version(config)
config.fetch("expo").fetch("version")
end
-def truthy_option?(value)
- %w[1 true yes on].include?(value.to_s.strip.downcase)
-end
-
-def bump_patch_version(version)
- match = version.match(/\A(\d+)\.(\d+)\.(\d+)\z/)
- UI.user_error!("Cannot bump non-semver mobile version '#{version}'") unless match
-
- "#{match[1]}.#{match[2]}.#{match[3].to_i + 1}"
-end
-
-def resolve_requested_version(options, config)
- requested = options[:version].to_s.strip
- return requested unless requested.empty?
-
- current_version = current_mobile_version(config)
- truthy_option?(options[:bump_patch]) ? bump_patch_version(current_version) : current_version
-end
-
# Returns true when `version`'s App Store train is closed to new build uploads.
# `app` is a Spaceship::ConnectAPI::App the caller looks up (nil if lookup
# failed). On a nil app or any API error, degrade to "open": we then proceed as
@@ -113,13 +95,7 @@ platform :ios do
lane :prepare_release_version do |options|
api_key = app_store_connect_api_key_from_env
config = load_mobile_app_config
- version = resolve_requested_version(options, config)
- # Fail fast (seconds) if the resolved version's App Store train is already
- # closed: Apple would otherwise reject the upload ~20 min later with 90186.
- # Bumping the version is left to the human (the bump_patch input or a
- # "Prepare mobile X" commit) so the marketing version stays a deliberate,
- # release-notes-bearing decision rather than something CI invents.
app =
begin
Spaceship::ConnectAPI::App.find(BUNDLE_ID)
@@ -127,11 +103,31 @@ platform :ios do
UI.error("Could not look up App Store app #{BUNDLE_ID} (#{error.message}); skipping closed-train check.")
nil
end
+ version =
+ begin
+ IosReleaseVersion.resolve(
+ requested: options[:version],
+ bump_patch: options[:bump_patch],
+ current_version: current_mobile_version(config),
+ train_closed: ->(candidate) { version_train_closed?(app, candidate) },
+ )
+ rescue ArgumentError => error
+ UI.user_error!(error.message)
+ end
+
+ # Explicit and checked-in versions still fail fast when closed. Patch bumps
+ # skip closed trains above because workflow-only releases can outpace Git.
if version_train_closed?(app, version)
+ retry_guidance =
+ if IosReleaseVersion.truthy?(options[:bump_patch])
+ "Use a higher release_version or land a \"Prepare mobile \" commit."
+ else
+ "Re-dispatch with bump_patch_version: true (or a higher release_version), " \
+ "or land a \"Prepare mobile \" commit."
+ end
UI.user_error!(
"iOS version #{version} is already submitted/released on the App Store and cannot accept " \
- "new builds. Re-dispatch with bump_patch_version: true (or a higher release_version), " \
- "or land a \"Prepare mobile \" commit.",
+ "new builds. #{retry_guidance}",
)
end
diff --git a/mobile/fastlane/ios_release_version.rb b/mobile/fastlane/ios_release_version.rb
new file mode 100644
index 00000000000..2b4fda40e06
--- /dev/null
+++ b/mobile/fastlane/ios_release_version.rb
@@ -0,0 +1,24 @@
+module IosReleaseVersion
+ module_function
+
+ def truthy?(value)
+ %w[1 true yes on].include?(value.to_s.strip.downcase)
+ end
+
+ def bump_patch(version)
+ match = version.match(/\A(\d+)\.(\d+)\.(\d+)\z/)
+ raise ArgumentError, "Cannot bump non-semver mobile version '#{version}'" unless match
+
+ "#{match[1]}.#{match[2]}.#{match[3].to_i + 1}"
+ end
+
+ def resolve(requested:, bump_patch:, current_version:, train_closed:)
+ exact_version = requested.to_s.strip
+ return exact_version unless exact_version.empty?
+ return current_version unless truthy?(bump_patch)
+
+ candidate = bump_patch(current_version)
+ candidate = bump_patch(candidate) while train_closed.call(candidate)
+ candidate
+ end
+end
diff --git a/mobile/fastlane/ios_release_version_test.rb b/mobile/fastlane/ios_release_version_test.rb
new file mode 100644
index 00000000000..795e6748275
--- /dev/null
+++ b/mobile/fastlane/ios_release_version_test.rb
@@ -0,0 +1,48 @@
+require "minitest/autorun"
+require_relative "ios_release_version"
+
+class IosReleaseVersionTest < Minitest::Test
+ def test_exact_version_wins_without_checking_trains
+ checked_versions = []
+
+ version = IosReleaseVersion.resolve(
+ requested: " 0.0.40 ",
+ bump_patch: true,
+ current_version: "0.0.32",
+ train_closed: ->(candidate) { checked_versions << candidate },
+ )
+
+ assert_equal("0.0.40", version)
+ assert_empty(checked_versions)
+ end
+
+ def test_uses_current_version_without_a_patch_bump
+ version = IosReleaseVersion.resolve(
+ requested: "",
+ bump_patch: false,
+ current_version: "0.0.32",
+ train_closed: ->(_) { flunk("should not check trains") },
+ )
+
+ assert_equal("0.0.32", version)
+ end
+
+ def test_skips_closed_patch_versions_from_a_stale_repo_version
+ closed_versions = %w[0.0.33 0.0.34]
+
+ version = IosReleaseVersion.resolve(
+ requested: "",
+ bump_patch: true,
+ current_version: "0.0.32",
+ train_closed: ->(candidate) { closed_versions.include?(candidate) },
+ )
+
+ assert_equal("0.0.35", version)
+ end
+
+ def test_rejects_non_semver_versions
+ error = assert_raises(ArgumentError) { IosReleaseVersion.bump_patch("0.0") }
+
+ assert_equal("Cannot bump non-semver mobile version '0.0'", error.message)
+ end
+end
diff --git a/mobile/scripts/bounded-process-line-reader.mjs b/mobile/scripts/bounded-process-line-reader.mjs
new file mode 100644
index 00000000000..a209f03eb0a
--- /dev/null
+++ b/mobile/scripts/bounded-process-line-reader.mjs
@@ -0,0 +1,103 @@
+import { Buffer } from 'node:buffer'
+
+export const PROCESS_LINE_MAX_BYTES = 64 * 1024
+export const PROCESS_OUTPUT_TAIL_MAX_CODE_UNITS = 64 * 1024
+const TRUNCATED_LINE_SUFFIX = '… [line truncated]'
+
+export function appendProcessOutputTail(
+ current,
+ line,
+ maxCodeUnits = PROCESS_OUTPUT_TAIL_MAX_CODE_UNITS
+) {
+ if (!Number.isSafeInteger(maxCodeUnits) || maxCodeUnits < 0) {
+ throw new RangeError('Process output tail limit must be a non-negative safe integer')
+ }
+ if (maxCodeUnits === 0) {
+ return ''
+ }
+ const appended = `${current}${line}\n`
+ return appended.length <= maxCodeUnits ? appended : appended.slice(-maxCodeUnits)
+}
+
+export function attachBoundedProcessLineReader(
+ stream,
+ onLine,
+ maxLineBytes = PROCESS_LINE_MAX_BYTES
+) {
+ if (!Number.isSafeInteger(maxLineBytes) || maxLineBytes < 0) {
+ throw new RangeError('Process line limit must be a non-negative safe integer')
+ }
+
+ const retained = Buffer.allocUnsafe(maxLineBytes)
+ let retainedBytes = 0
+ let truncated = false
+ let swallowLineFeed = false
+ let closed = false
+
+ const append = (bytes, start, end) => {
+ if (start >= end) {
+ return
+ }
+ const available = maxLineBytes - retainedBytes
+ const copied = Math.min(available, end - start)
+ if (copied > 0) {
+ bytes.copy(retained, retainedBytes, start, start + copied)
+ retainedBytes += copied
+ }
+ truncated ||= copied < end - start
+ }
+
+ const emit = () => {
+ const line = retained.subarray(0, retainedBytes).toString('utf8')
+ onLine(truncated ? `${line}${TRUNCATED_LINE_SUFFIX}` : line)
+ retainedBytes = 0
+ truncated = false
+ }
+
+ const onData = (chunk) => {
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
+ let start = 0
+ if (swallowLineFeed) {
+ swallowLineFeed = false
+ if (bytes[0] === 0x0a) {
+ start = 1
+ }
+ }
+
+ for (let index = start; index < bytes.length; index += 1) {
+ const value = bytes[index]
+ if (value !== 0x0a && value !== 0x0d) {
+ continue
+ }
+ append(bytes, start, index)
+ emit()
+ if (value === 0x0d && index + 1 < bytes.length && bytes[index + 1] === 0x0a) {
+ index += 1
+ } else if (value === 0x0d && index + 1 === bytes.length) {
+ swallowLineFeed = true
+ }
+ start = index + 1
+ }
+ append(bytes, start, bytes.length)
+ }
+
+ const detach = () => {
+ if (closed) {
+ return
+ }
+ closed = true
+ stream.off('data', onData)
+ stream.off('end', onEnd)
+ }
+
+ const onEnd = () => {
+ if (retainedBytes > 0 || truncated) {
+ emit()
+ }
+ detach()
+ }
+
+ stream.on('data', onData)
+ stream.on('end', onEnd)
+ return detach
+}
diff --git a/mobile/scripts/bounded-response-body.mjs b/mobile/scripts/bounded-response-body.mjs
new file mode 100644
index 00000000000..cef6099d2bb
--- /dev/null
+++ b/mobile/scripts/bounded-response-body.mjs
@@ -0,0 +1,36 @@
+export async function responseBodyIncludesWithinLimit(response, needle, maxBytes) {
+ const declaredLength = response.headers.get('content-length')
+ if (declaredLength && /^\d+$/.test(declaredLength) && Number(declaredLength) > maxBytes) {
+ await response.body?.cancel().catch(() => undefined)
+ return false
+ }
+ if (!response.body) {
+ return false
+ }
+
+ const reader = response.body.getReader()
+ const decoder = new TextDecoder()
+ let observedBytes = 0
+ let suffix = ''
+ try {
+ while (true) {
+ const { done, value } = await reader.read()
+ if (done) {
+ return `${suffix}${decoder.decode()}`.includes(needle)
+ }
+ observedBytes += value.byteLength
+ if (observedBytes > maxBytes) {
+ await reader.cancel().catch(() => undefined)
+ return false
+ }
+ const candidate = suffix + decoder.decode(value, { stream: true })
+ if (candidate.includes(needle)) {
+ await reader.cancel().catch(() => undefined)
+ return true
+ }
+ suffix = candidate.slice(-Math.max(0, needle.length - 1))
+ }
+ } finally {
+ reader.releaseLock()
+ }
+}
diff --git a/mobile/scripts/start-emulator-pairing-runtime.mjs b/mobile/scripts/start-emulator-pairing-runtime.mjs
index 02078825db4..3e23c54093b 100644
--- a/mobile/scripts/start-emulator-pairing-runtime.mjs
+++ b/mobile/scripts/start-emulator-pairing-runtime.mjs
@@ -3,7 +3,10 @@ import { mkdirSync, mkdtempSync } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import process from 'node:process'
-import readline from 'node:readline'
+import {
+ appendProcessOutputTail,
+ attachBoundedProcessLineReader
+} from './bounded-process-line-reader.mjs'
function primaryLanIp(lanIpCandidates) {
return lanIpCandidates()[0] || '127.0.0.1'
@@ -66,15 +69,15 @@ async function waitForPairingRuntime({ child, userData, pairingAddress, logSucce
let stderr = ''
let resolved = false
let exited = false
- let rl = null
- let rlErr = null
+ let closeStdout = () => {}
+ let closeStderr = () => {}
const stop = () => {
if (!exited) {
child.kill('SIGTERM')
}
- rl?.close()
- rlErr?.close()
+ closeStdout()
+ closeStderr()
child.stdout?.destroy()
child.stderr?.destroy()
}
@@ -119,15 +122,17 @@ async function waitForPairingRuntime({ child, userData, pairingAddress, logSucce
reject(error)
}
- rl = readline.createInterface({ input: child.stdout })
- rl.on('line', (line) => {
- output += line + '\n'
+ closeStdout = attachBoundedProcessLineReader(child.stdout, (line) => {
+ if (!resolved) {
+ output = appendProcessOutputTail(output, line)
+ }
handleRuntimeLine(line, finishResolve)
})
- rlErr = readline.createInterface({ input: child.stderr })
- rlErr.on('line', (line) => {
- stderr += line + '\n'
+ closeStderr = attachBoundedProcessLineReader(child.stderr, (line) => {
+ if (!resolved) {
+ stderr = appendProcessOutputTail(stderr, line)
+ }
})
child.on('error', (error) => {
diff --git a/mobile/scripts/start-emulator.mjs b/mobile/scripts/start-emulator.mjs
index d6489b530ee..8c5e11f944c 100755
--- a/mobile/scripts/start-emulator.mjs
+++ b/mobile/scripts/start-emulator.mjs
@@ -22,16 +22,21 @@ import os from 'node:os'
import { promisify } from 'node:util'
import path from 'node:path'
import process from 'node:process'
-import readline from 'node:readline'
import {
registerWorktreeForPairingRuntime,
startHeadlessPairingRuntime
} from './start-emulator-pairing-runtime.mjs'
+import {
+ appendProcessOutputTail,
+ attachBoundedProcessLineReader
+} from './bounded-process-line-reader.mjs'
+import { responseBodyIncludesWithinLimit } from './bounded-response-body.mjs'
import { ensureMobileExpoCli, getMobileExpoExecutablePath } from './mobile-expo-cli.mjs'
const execFileAsync = promisify(execFile)
const DEFAULT_METRO_PORT = 8081
const METRO_PORT_SEARCH_LIMIT = 100
+const METRO_STATUS_MAX_BYTES = 64 * 1024
// Parse CLI arguments
const args = process.argv.slice(2)
@@ -113,6 +118,22 @@ function logInfo(message) {
log(`[info] ${message}`, 'yellow')
}
+function createBackpressuredLineWriter(source, target, color) {
+ let waitingForDrain = false
+ return (line) => {
+ const accepted = target.write(color + line + colors.reset + '\n')
+ if (accepted || waitingForDrain) {
+ return
+ }
+ waitingForDrain = true
+ source.pause()
+ target.once('drain', () => {
+ waitingForDrain = false
+ source.resume()
+ })
+ }
+}
+
function assertIosSimulatorPlatform() {
if (process.platform !== 'darwin') {
throw new Error('iOS Simulator automation requires macOS and Xcode.')
@@ -382,8 +403,10 @@ async function startMetro(worktree) {
let url = null
let resolved = false
let exited = false
- let rl = null
- let rlErr = null
+ let closeStdout = () => {}
+ let closeStderr = () => {}
+ const writeStdoutLine = createBackpressuredLineWriter(metro.stdout, process.stdout, colors.dim)
+ const writeStderrLine = createBackpressuredLineWriter(metro.stderr, process.stderr, colors.red)
const metroResult = () => ({
process: metro,
@@ -391,8 +414,8 @@ async function startMetro(worktree) {
output,
isExited: () => exited,
closeOutput: () => {
- rl?.close()
- rlErr?.close()
+ closeStdout()
+ closeStderr()
metro.stdin?.destroy()
metro.stdout?.destroy()
metro.stderr?.destroy()
@@ -400,10 +423,11 @@ async function startMetro(worktree) {
})
// Parse Metro output for the development URL
- rl = readline.createInterface({ input: metro.stdout })
- rl.on('line', (line) => {
- output += line + '\n'
- process.stdout.write(colors.dim + line + colors.reset + '\n')
+ closeStdout = attachBoundedProcessLineReader(metro.stdout, (line) => {
+ if (!resolved) {
+ output = appendProcessOutputTail(output, line)
+ }
+ writeStdoutLine(line)
// Look for "Waiting on" message from Metro
// When Metro says "Waiting on http://localhost:8081", we need to construct the dev-client URL
@@ -446,10 +470,11 @@ async function startMetro(worktree) {
})
// Also check stderr
- rlErr = readline.createInterface({ input: metro.stderr })
- rlErr.on('line', (line) => {
- output += line + '\n'
- process.stderr.write(colors.red + line + colors.reset + '\n')
+ closeStderr = attachBoundedProcessLineReader(metro.stderr, (line) => {
+ if (!resolved) {
+ output = appendProcessOutputTail(output, line)
+ }
+ writeStderrLine(line)
})
metro.on('error', (error) => {
@@ -547,7 +572,11 @@ async function verifyMetro(url) {
try {
const response = await fetch(statusUrl, { signal: controller.signal })
- return (await response.text()).includes('packager-status:running')
+ return await responseBodyIncludesWithinLimit(
+ response,
+ 'packager-status:running',
+ METRO_STATUS_MAX_BYTES
+ )
} catch {
return false
} finally {
diff --git a/mobile/src/browser/MobileBrowserPane.tsx b/mobile/src/browser/MobileBrowserPane.tsx
index 683b972bfa8..bc516a4787a 100644
--- a/mobile/src/browser/MobileBrowserPane.tsx
+++ b/mobile/src/browser/MobileBrowserPane.tsx
@@ -1,7 +1,4 @@
/* oxlint-disable react-doctor/no-adjust-state-on-prop-change -- Why: mobile browser state mirrors a remote desktop screencast session and CDP dialogs, which are external systems that cannot be derived during render. */
-// 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 {
ActivityIndicator,
@@ -42,6 +39,10 @@ import {
getInitialMobileBrowserViewMode,
saveMobileBrowserViewMode
} from './mobile-browser-view-mode-state'
+import {
+ createMobileBrowserFrameDataUri,
+ MobileBrowserFrameCache
+} from './mobile-browser-frame-cache'
import {
clampBrowserZoomState,
computeBrowserFrameGeometry,
@@ -108,14 +109,7 @@ const TOUCH_CLICK_RADIUS_DIP = 14
const MIN_ZOOM = 1
const MAX_ZOOM = 3.5
const DEFAULT_ZOOM: BrowserZoomState = { scale: 1, offsetX: 0, offsetY: 0 }
-const BROWSER_FRAME_CACHE_LIMIT = 4
-
-type BrowserFrameCacheEntry = {
- uri: string
- metadata: BrowserScreencastFrameMetadata
-}
-
-const browserFrameCache = new Map()
+const browserFrameCache = new MobileBrowserFrameCache()
type BrowserPageParams = {
worktree: string
@@ -143,7 +137,7 @@ export function MobileBrowserPane({
getInitialMobileBrowserViewMode(worktreeId, tab.browserPageId)
)
const cacheKey = makeBrowserFrameCacheKey(worktreeId, tab.browserPageId, browserViewMode)
- const cachedInitialFrame = peekCachedBrowserFrame(cacheKey)
+ const cachedInitialFrame = browserFrameCache.peek(cacheKey)
const [addressValue, setAddressValue] = useState(displayBrowserUrl(tab.url))
const [addressFocused, setAddressFocused] = useState(false)
const [addressSyncState, setAddressSyncState] = useState({
@@ -229,7 +223,7 @@ export function MobileBrowserPane({
const subscription = AppState.addEventListener('change', (nextState) => {
const active = nextState === 'active'
if (!active) {
- clearCachedBrowserFramesForWorktree(worktreeId)
+ browserFrameCache.clearWorktree(worktreeId)
}
setAppActive(active)
})
@@ -280,8 +274,14 @@ export function MobileBrowserPane({
frameMetadataRef.current = frame.metadata
setFrameMetadata(frame.metadata)
}
- const nextFrameUri = createBrowserFrameDataUri(frame)
- cacheBrowserFrame(frameCacheKey, { uri: nextFrameUri, metadata: frame.metadata })
+ const nextFrameUri = createMobileBrowserFrameDataUri(frame)
+ if (!nextFrameUri) {
+ busyRef.current = false
+ setBusy(false)
+ setError('Browser frame is too large to display safely.')
+ return
+ }
+ browserFrameCache.set(frameCacheKey, { uri: nextFrameUri, metadata: frame.metadata })
if (!frameMountedRef.current) {
frameUriRef.current = nextFrameUri
frameMountedRef.current = true
@@ -389,7 +389,7 @@ export function MobileBrowserPane({
const sameStream = Boolean(cacheKey) && lastStreamCacheKeyRef.current === cacheKey
lastStreamCacheKeyRef.current = cacheKey
if (!sameStream || !frameUriRef.current) {
- const cachedFrame = getCachedBrowserFrame(cacheKey)
+ const cachedFrame = browserFrameCache.get(cacheKey)
if (cachedFrame) {
frameUriRef.current = cachedFrame.uri
frameMountedRef.current = true
@@ -1303,10 +1303,6 @@ function buttonColor(enabled: boolean): string {
return enabled ? colors.textSecondary : colors.textMuted
}
-function createBrowserFrameDataUri(frame: BrowserScreencastFrame): string {
- return `data:image/${frame.format};base64,${Buffer.from(frame.image).toString('base64')}`
-}
-
function makeBrowserFrameCacheKey(
worktreeId: string,
browserPageId: string | null,
@@ -1315,47 +1311,6 @@ function makeBrowserFrameCacheKey(
return browserPageId ? `${worktreeId}:${browserPageId}:${viewMode}` : null
}
-function clearCachedBrowserFramesForWorktree(worktreeId: string): void {
- const prefix = `${worktreeId}:`
- for (const key of browserFrameCache.keys()) {
- if (key.startsWith(prefix)) {
- browserFrameCache.delete(key)
- }
- }
-}
-
-function getCachedBrowserFrame(cacheKey: string | null): BrowserFrameCacheEntry | null {
- if (!cacheKey) {
- return null
- }
- const cached = browserFrameCache.get(cacheKey)
- if (!cached) {
- return null
- }
- browserFrameCache.delete(cacheKey)
- browserFrameCache.set(cacheKey, cached)
- return cached
-}
-
-function peekCachedBrowserFrame(cacheKey: string | null): BrowserFrameCacheEntry | null {
- return cacheKey ? (browserFrameCache.get(cacheKey) ?? null) : null
-}
-
-function cacheBrowserFrame(cacheKey: string | null, entry: BrowserFrameCacheEntry): void {
- if (!cacheKey) {
- return
- }
- browserFrameCache.delete(cacheKey)
- browserFrameCache.set(cacheKey, entry)
- while (browserFrameCache.size > BROWSER_FRAME_CACHE_LIMIT) {
- const oldestKey = browserFrameCache.keys().next().value
- if (typeof oldestKey !== 'string') {
- break
- }
- browserFrameCache.delete(oldestKey)
- }
-}
-
function updateBrowserLayerVisibility(
layers: [View | null, View | null],
visible: FrameLayer
diff --git a/mobile/src/browser/mobile-browser-frame-cache.test.ts b/mobile/src/browser/mobile-browser-frame-cache.test.ts
new file mode 100644
index 00000000000..79280dd1055
--- /dev/null
+++ b/mobile/src/browser/mobile-browser-frame-cache.test.ts
@@ -0,0 +1,68 @@
+import { describe, expect, it } from 'vitest'
+import {
+ BrowserScreencastOpcode,
+ type BrowserScreencastFrame
+} from '../transport/browser-screencast-protocol'
+import {
+ createMobileBrowserFrameDataUri,
+ MOBILE_BROWSER_FRAME_MAX_IMAGE_BYTES,
+ MobileBrowserFrameCache
+} from './mobile-browser-frame-cache'
+
+function entry(uri: string) {
+ return { uri, metadata: {} }
+}
+
+function frame(imageBytes: number): BrowserScreencastFrame {
+ return {
+ opcode: BrowserScreencastOpcode.Frame,
+ seq: 1,
+ format: 'jpeg',
+ metadata: {},
+ image: new Uint8Array(imageBytes)
+ }
+}
+
+describe('MobileBrowserFrameCache', () => {
+ it('preserves LRU behavior below the count and retained-character limits', () => {
+ const cache = new MobileBrowserFrameCache(2, 100)
+ cache.set('first', entry('frame-1'))
+ cache.set('second', entry('frame-2'))
+
+ expect(cache.get('first')).toEqual(entry('frame-1'))
+ cache.set('third', entry('frame-3'))
+
+ expect(cache.peek('second')).toBeNull()
+ expect(cache.evidence().keysOldestFirst).toEqual(['first', 'third'])
+ })
+
+ it('accepts the exact aggregate budget and evicts oldest at one character over', () => {
+ const cache = new MobileBrowserFrameCache(4, 20)
+ expect(cache.set('a', entry('x'.repeat(9)))).toBe(true)
+ expect(cache.set('b', entry('x'.repeat(9)))).toBe(true)
+ expect(cache.evidence()).toMatchObject({ entryCount: 2, retainedCharacters: 20 })
+
+ expect(cache.set('c', entry(''))).toBe(true)
+ expect(cache.peek('a')).toBeNull()
+ expect(cache.evidence()).toMatchObject({ entryCount: 2, retainedCharacters: 11 })
+ })
+
+ it('does not retain one entry larger than the full budget', () => {
+ const cache = new MobileBrowserFrameCache(4, 10)
+
+ expect(cache.set('key', entry('x'.repeat(8)))).toBe(false)
+ expect(cache.evidence()).toEqual({
+ entryCount: 0,
+ retainedCharacters: 0,
+ keysOldestFirst: []
+ })
+ })
+})
+
+describe('createMobileBrowserFrameDataUri', () => {
+ it('accepts the exact image-byte limit and rejects one byte over before base64 expansion', () => {
+ expect(MOBILE_BROWSER_FRAME_MAX_IMAGE_BYTES).toBe(8 * 1024 * 1024)
+ expect(createMobileBrowserFrameDataUri(frame(4), 4)).toBe('data:image/jpeg;base64,AAAAAA==')
+ expect(createMobileBrowserFrameDataUri(frame(5), 4)).toBeNull()
+ })
+})
diff --git a/mobile/src/browser/mobile-browser-frame-cache.ts b/mobile/src/browser/mobile-browser-frame-cache.ts
new file mode 100644
index 00000000000..0d856c546b3
--- /dev/null
+++ b/mobile/src/browser/mobile-browser-frame-cache.ts
@@ -0,0 +1,134 @@
+import { Buffer } from 'buffer'
+import type {
+ BrowserScreencastFrame,
+ BrowserScreencastFrameMetadata
+} from '../transport/browser-screencast-protocol'
+
+export const MOBILE_BROWSER_FRAME_MAX_IMAGE_BYTES = 8 * 1024 * 1024
+export const MOBILE_BROWSER_FRAME_CACHE_MAX_ENTRIES = 4
+export const MOBILE_BROWSER_FRAME_CACHE_MAX_RETAINED_CHARACTERS = 16 * 1024 * 1024
+
+export type MobileBrowserFrameCacheEntry = {
+ uri: string
+ metadata: BrowserScreencastFrameMetadata
+}
+
+export type MobileBrowserFrameCacheEvidence = {
+ entryCount: number
+ retainedCharacters: number
+ keysOldestFirst: string[]
+}
+
+type RetainedFrame = {
+ entry: MobileBrowserFrameCacheEntry
+ retainedCharacters: number
+}
+
+export class MobileBrowserFrameCache {
+ private readonly entries = new Map()
+ private retainedCharacters = 0
+
+ constructor(
+ private readonly maxEntries = MOBILE_BROWSER_FRAME_CACHE_MAX_ENTRIES,
+ private readonly maxRetainedCharacters = MOBILE_BROWSER_FRAME_CACHE_MAX_RETAINED_CHARACTERS
+ ) {
+ if (
+ !Number.isInteger(maxEntries) ||
+ maxEntries < 1 ||
+ !Number.isSafeInteger(maxRetainedCharacters) ||
+ maxRetainedCharacters < 1
+ ) {
+ throw new Error('Mobile browser frame cache limits must be positive integers')
+ }
+ }
+
+ get(key: string | null): MobileBrowserFrameCacheEntry | null {
+ if (!key) {
+ return null
+ }
+ const retained = this.entries.get(key)
+ if (!retained) {
+ return null
+ }
+ this.entries.delete(key)
+ this.entries.set(key, retained)
+ return retained.entry
+ }
+
+ peek(key: string | null): MobileBrowserFrameCacheEntry | null {
+ return key ? (this.entries.get(key)?.entry ?? null) : null
+ }
+
+ set(key: string | null, entry: MobileBrowserFrameCacheEntry): boolean {
+ if (!key) {
+ return false
+ }
+ const retainedCharacters = key.length + entry.uri.length
+ const previous = this.entries.get(key)
+ if (previous) {
+ this.retainedCharacters -= previous.retainedCharacters
+ this.entries.delete(key)
+ }
+ if (retainedCharacters > this.maxRetainedCharacters) {
+ return false
+ }
+ this.entries.set(key, { entry, retainedCharacters })
+ this.retainedCharacters += retainedCharacters
+ this.evictOverflow()
+ return this.entries.has(key)
+ }
+
+ clearWorktree(worktreeId: string): void {
+ const prefix = `${worktreeId}:`
+ for (const key of this.entries.keys()) {
+ if (key.startsWith(prefix)) {
+ this.delete(key)
+ }
+ }
+ }
+
+ clear(): void {
+ this.entries.clear()
+ this.retainedCharacters = 0
+ }
+
+ evidence(): MobileBrowserFrameCacheEvidence {
+ return {
+ entryCount: this.entries.size,
+ retainedCharacters: this.retainedCharacters,
+ keysOldestFirst: [...this.entries.keys()]
+ }
+ }
+
+ private delete(key: string): void {
+ const retained = this.entries.get(key)
+ if (!retained) {
+ return
+ }
+ this.retainedCharacters -= retained.retainedCharacters
+ this.entries.delete(key)
+ }
+
+ private evictOverflow(): void {
+ while (
+ this.entries.size > this.maxEntries ||
+ this.retainedCharacters > this.maxRetainedCharacters
+ ) {
+ const oldestKey = this.entries.keys().next().value
+ if (typeof oldestKey !== 'string') {
+ return
+ }
+ this.delete(oldestKey)
+ }
+ }
+}
+
+export function createMobileBrowserFrameDataUri(
+ frame: BrowserScreencastFrame,
+ maxImageBytes = MOBILE_BROWSER_FRAME_MAX_IMAGE_BYTES
+): string | null {
+ if (frame.image.byteLength > maxImageBytes) {
+ return null
+ }
+ return `data:image/${frame.format};base64,${Buffer.from(frame.image).toString('base64')}`
+}
diff --git a/mobile/src/browser/mobile-browser-view-mode-state.test.ts b/mobile/src/browser/mobile-browser-view-mode-state.test.ts
index 20ba4844de5..448412fff5e 100644
--- a/mobile/src/browser/mobile-browser-view-mode-state.test.ts
+++ b/mobile/src/browser/mobile-browser-view-mode-state.test.ts
@@ -1,5 +1,7 @@
import { beforeEach, describe, expect, it } from 'vitest'
import {
+ BROWSER_VIEW_MODE_PAGE_KEY_MAX_CHARACTERS,
+ BROWSER_VIEW_MODE_STATE_LIMIT,
clearMobileBrowserViewModeState,
getInitialMobileBrowserViewMode,
saveMobileBrowserViewMode
@@ -22,4 +24,28 @@ describe('mobile browser view mode state', () => {
expect(getInitialMobileBrowserViewMode('worktree-1', 'page-2')).toBe('web')
expect(getInitialMobileBrowserViewMode('worktree-2', 'page-1')).toBe('web')
})
+
+ it('retains the exact LRU count and evicts the oldest page at one over', () => {
+ for (let index = 0; index < BROWSER_VIEW_MODE_STATE_LIMIT; index += 1) {
+ saveMobileBrowserViewMode('worktree', `page-${index}`, 'mobile')
+ }
+ expect(getInitialMobileBrowserViewMode('worktree', 'page-0')).toBe('mobile')
+
+ saveMobileBrowserViewMode('worktree', 'one-over', 'mobile')
+
+ expect(getInitialMobileBrowserViewMode('worktree', 'page-0')).toBe('web')
+ expect(getInitialMobileBrowserViewMode('worktree', 'one-over')).toBe('mobile')
+ })
+
+ it('accepts the exact page-key character limit and rejects one over', () => {
+ const worktreeId = 'w'
+ const exactPageId = 'p'.repeat(BROWSER_VIEW_MODE_PAGE_KEY_MAX_CHARACTERS - 2)
+ const oversizedPageId = `${exactPageId}p`
+
+ saveMobileBrowserViewMode(worktreeId, exactPageId, 'mobile')
+ saveMobileBrowserViewMode(worktreeId, oversizedPageId, 'mobile')
+
+ expect(getInitialMobileBrowserViewMode(worktreeId, exactPageId)).toBe('mobile')
+ expect(getInitialMobileBrowserViewMode(worktreeId, oversizedPageId)).toBe('web')
+ })
})
diff --git a/mobile/src/browser/mobile-browser-view-mode-state.ts b/mobile/src/browser/mobile-browser-view-mode-state.ts
index 35923d61a81..da9a906f5e2 100644
--- a/mobile/src/browser/mobile-browser-view-mode-state.ts
+++ b/mobile/src/browser/mobile-browser-view-mode-state.ts
@@ -1,6 +1,7 @@
import type { MobileBrowserViewMode } from './browser-screencast-request'
-const BROWSER_VIEW_MODE_STATE_LIMIT = 40
+export const BROWSER_VIEW_MODE_STATE_LIMIT = 40
+export const BROWSER_VIEW_MODE_PAGE_KEY_MAX_CHARACTERS = 4_096
const browserViewModeByPageKey = new Map()
export function getInitialMobileBrowserViewMode(
@@ -42,5 +43,9 @@ function makeBrowserViewModePageKey(
worktreeId: string,
browserPageId: string | null
): string | null {
- return browserPageId ? `${worktreeId}:${browserPageId}` : null
+ if (!browserPageId) {
+ return null
+ }
+ const key = `${worktreeId}:${browserPageId}`
+ return key.length <= BROWSER_VIEW_MODE_PAGE_KEY_MAX_CHARACTERS ? key : null
}
diff --git a/mobile/src/cache/home-snapshot-cache.test.ts b/mobile/src/cache/home-snapshot-cache.test.ts
new file mode 100644
index 00000000000..4c2485b741b
--- /dev/null
+++ b/mobile/src/cache/home-snapshot-cache.test.ts
@@ -0,0 +1,80 @@
+import AsyncStorage from '@react-native-async-storage/async-storage'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import {
+ HOME_SNAPSHOT_MAX_SERIALIZED_BYTES,
+ loadHomeSnapshot,
+ resetHomeSnapshotCacheForTests,
+ saveHomeSnapshot,
+ type HomeSnapshot
+} from './home-snapshot-cache'
+
+vi.mock('@react-native-async-storage/async-storage', () => ({
+ default: {
+ getItem: vi.fn(),
+ setItem: vi.fn()
+ }
+}))
+
+function snapshotWithSerializedBytes(serializedBytes: number): HomeSnapshot {
+ const base = {
+ worktreeInfo: {},
+ accountsByHost: {},
+ savedAt: 1,
+ padding: ''
+ }
+ const baseBytes = JSON.stringify(base).length
+ return {
+ ...base,
+ padding: 'x'.repeat(serializedBytes - baseBytes)
+ } as unknown as HomeSnapshot
+}
+
+describe('home snapshot cache', () => {
+ beforeEach(() => {
+ vi.useFakeTimers()
+ resetHomeSnapshotCacheForTests()
+ vi.mocked(AsyncStorage.getItem).mockReset()
+ vi.mocked(AsyncStorage.setItem).mockReset().mockResolvedValue(undefined)
+ })
+
+ afterEach(() => {
+ resetHomeSnapshotCacheForTests()
+ vi.useRealTimers()
+ })
+
+ it('persists and reloads normal snapshots unchanged', async () => {
+ const snapshot = snapshotWithSerializedBytes(128)
+ saveHomeSnapshot(snapshot)
+ await vi.advanceTimersByTimeAsync(250)
+
+ const raw = vi.mocked(AsyncStorage.setItem).mock.calls[0]?.[1]
+ expect(raw).toBe(JSON.stringify(snapshot))
+
+ resetHomeSnapshotCacheForTests()
+ vi.mocked(AsyncStorage.getItem).mockResolvedValue(raw ?? null)
+ await expect(loadHomeSnapshot()).resolves.toEqual(snapshot)
+ })
+
+ it('accepts the exact serialized-byte cap and rejects one byte over', async () => {
+ const exact = snapshotWithSerializedBytes(HOME_SNAPSHOT_MAX_SERIALIZED_BYTES)
+ saveHomeSnapshot(exact)
+ await vi.advanceTimersByTimeAsync(250)
+ expect(AsyncStorage.setItem).toHaveBeenCalledOnce()
+ expect(vi.mocked(AsyncStorage.setItem).mock.calls[0]?.[1]).toHaveLength(
+ HOME_SNAPSHOT_MAX_SERIALIZED_BYTES
+ )
+
+ vi.mocked(AsyncStorage.setItem).mockClear()
+ saveHomeSnapshot(snapshotWithSerializedBytes(HOME_SNAPSHOT_MAX_SERIALIZED_BYTES + 1))
+ await vi.advanceTimersByTimeAsync(250)
+ expect(AsyncStorage.setItem).not.toHaveBeenCalled()
+ })
+
+ it('rejects an oversized durable payload before JSON parsing', async () => {
+ vi.mocked(AsyncStorage.getItem).mockResolvedValue(
+ `"${'x'.repeat(HOME_SNAPSHOT_MAX_SERIALIZED_BYTES)}"`
+ )
+
+ await expect(loadHomeSnapshot()).resolves.toBeNull()
+ })
+})
diff --git a/mobile/src/cache/home-snapshot-cache.ts b/mobile/src/cache/home-snapshot-cache.ts
index a591bb01d70..4d906887c04 100644
--- a/mobile/src/cache/home-snapshot-cache.ts
+++ b/mobile/src/cache/home-snapshot-cache.ts
@@ -5,8 +5,10 @@
// WebSocket reconnects and the first responses come back.
import AsyncStorage from '@react-native-async-storage/async-storage'
import type { AccountsSnapshot } from '../components/AccountUsage'
+import { stringifyMobileOutboundJson } from '../transport/mobile-outbound-json'
const STORAGE_KEY = 'orca:home-snapshot:v1'
+export const HOME_SNAPSHOT_MAX_SERIALIZED_BYTES = 2 * 1024 * 1024
type WorktreeSummary = {
worktreeId: string
@@ -42,6 +44,12 @@ export async function loadHomeSnapshot(): Promise {
if (!raw) {
return null
}
+ if (
+ raw.length > HOME_SNAPSHOT_MAX_SERIALIZED_BYTES ||
+ utf8ByteLengthExceeds(raw, HOME_SNAPSHOT_MAX_SERIALIZED_BYTES)
+ ) {
+ return null
+ }
const parsed = JSON.parse(raw) as HomeSnapshot
if (
typeof parsed !== 'object' ||
@@ -61,12 +69,53 @@ export async function loadHomeSnapshot(): Promise {
// Why: throttle writes so a flurry of streamed account-snapshot updates
// (one per provider fetch finishing) doesn't hammer AsyncStorage.
export function saveHomeSnapshot(snapshot: HomeSnapshot): void {
+ let serialized: string
+ try {
+ serialized = stringifyMobileOutboundJson(snapshot, HOME_SNAPSHOT_MAX_SERIALIZED_BYTES)
+ } catch {
+ return
+ }
memoryCache = snapshot
if (writeTimer) {
clearTimeout(writeTimer)
}
writeTimer = setTimeout(() => {
writeTimer = null
- void AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot)).catch(() => {})
+ void AsyncStorage.setItem(STORAGE_KEY, serialized).catch(() => {})
}, 250)
}
+
+function utf8ByteLengthExceeds(value: string, limit: number): boolean {
+ let bytes = 0
+ for (let index = 0; index < value.length; index += 1) {
+ const code = value.charCodeAt(index)
+ if (code <= 0x7f) {
+ bytes += 1
+ } else if (code <= 0x7ff) {
+ bytes += 2
+ } else if (code >= 0xd800 && code <= 0xdbff) {
+ const next = value.charCodeAt(index + 1)
+ if (next >= 0xdc00 && next <= 0xdfff) {
+ bytes += 4
+ index += 1
+ } else {
+ bytes += 3
+ }
+ } else {
+ bytes += 3
+ }
+ if (bytes > limit) {
+ return true
+ }
+ }
+ return false
+}
+
+/** Test-only: clear the process-lifetime snapshot and delayed write. */
+export function resetHomeSnapshotCacheForTests(): void {
+ memoryCache = null
+ if (writeTimer) {
+ clearTimeout(writeTimer)
+ writeTimer = null
+ }
+}
diff --git a/mobile/src/cache/mobile-rpc-list-cache.test.ts b/mobile/src/cache/mobile-rpc-list-cache.test.ts
new file mode 100644
index 00000000000..50eeccf027e
--- /dev/null
+++ b/mobile/src/cache/mobile-rpc-list-cache.test.ts
@@ -0,0 +1,50 @@
+import { describe, expect, it } from 'vitest'
+import { MobileRpcListCache } from './mobile-rpc-list-cache'
+
+describe('MobileRpcListCache', () => {
+ it('preserves values below the limits and expires them at the existing age boundary', () => {
+ const cache = new MobileRpcListCache(100, 2, 3, 100)
+ const values = [{ id: 'repo' }]
+
+ expect(cache.set('host', values, 1_000)).toBe(true)
+ expect(cache.get('host', 1_100)).toBe(values)
+ expect(cache.get('host', 1_101)).toBeNull()
+ })
+
+ it('accepts the exact item cap and rejects one over', () => {
+ const cache = new MobileRpcListCache(100, 2, 3, 1_000)
+ const exact = [1, 2, 3]
+
+ expect(cache.set('host', exact)).toBe(true)
+ expect(cache.get('host')).toBe(exact)
+ expect(cache.set('host', [...exact, 4])).toBe(false)
+ expect(cache.get('host')).toBeNull()
+ })
+
+ it('accepts the exact aggregate byte budget and evicts oldest at one over', () => {
+ const probe = new MobileRpcListCache(100, 3, 3, 1_000)
+ probe.set('first', ['a'])
+ const firstBytes = probe.evidence().retainedBytes
+ probe.clear()
+
+ const cache = new MobileRpcListCache(100, 3, 3, firstBytes * 2)
+ expect(cache.set('first', ['a'])).toBe(true)
+ expect(cache.set('other', ['a'])).toBe(true)
+ expect(cache.evidence().retainedBytes).toBe(firstBytes * 2)
+
+ expect(cache.set('x', [])).toBe(true)
+ expect(cache.get('first')).toBeNull()
+ expect(cache.evidence().retainedBytes).toBeLessThanOrEqual(firstBytes * 2)
+ })
+
+ it('rejects a single payload larger than the full byte budget', () => {
+ const cache = new MobileRpcListCache(100, 2, 3, 20)
+
+ expect(cache.set('host', ['a'.repeat(100)])).toBe(false)
+ expect(cache.evidence()).toEqual({
+ entryCount: 0,
+ retainedBytes: 0,
+ keysOldestFirst: []
+ })
+ })
+})
diff --git a/mobile/src/cache/mobile-rpc-list-cache.ts b/mobile/src/cache/mobile-rpc-list-cache.ts
new file mode 100644
index 00000000000..33619a0fed6
--- /dev/null
+++ b/mobile/src/cache/mobile-rpc-list-cache.ts
@@ -0,0 +1,126 @@
+import { stringifyMobileOutboundJson } from '../transport/mobile-outbound-json'
+
+export type MobileRpcListCacheEvidence = {
+ entryCount: number
+ retainedBytes: number
+ keysOldestFirst: string[]
+}
+
+type CacheEntry = {
+ values: unknown[]
+ at: number
+ retainedBytes: number
+}
+
+export class MobileRpcListCache {
+ private readonly entries = new Map()
+ private retainedBytes = 0
+
+ constructor(
+ private readonly maxAgeMs: number,
+ private readonly maxEntries: number,
+ private readonly maxItemsPerEntry: number,
+ private readonly maxRetainedBytes: number
+ ) {
+ if (
+ !Number.isFinite(maxAgeMs) ||
+ maxAgeMs < 0 ||
+ !Number.isInteger(maxEntries) ||
+ maxEntries < 1 ||
+ !Number.isInteger(maxItemsPerEntry) ||
+ maxItemsPerEntry < 1 ||
+ !Number.isSafeInteger(maxRetainedBytes) ||
+ maxRetainedBytes < 1
+ ) {
+ throw new Error('Mobile RPC list cache limits must be positive')
+ }
+ }
+
+ set(key: string, values: unknown[], now = Date.now()): boolean {
+ this.delete(key)
+ if (values.length > this.maxItemsPerEntry) {
+ return false
+ }
+ let serialized: string
+ try {
+ serialized = stringifyMobileOutboundJson({ key, values }, this.maxRetainedBytes)
+ } catch {
+ return false
+ }
+ const retainedBytes = utf8ByteLength(serialized)
+ if (retainedBytes > this.maxRetainedBytes) {
+ return false
+ }
+ this.entries.set(key, { values, at: now, retainedBytes })
+ this.retainedBytes += retainedBytes
+ this.evictOverflow()
+ return this.entries.has(key)
+ }
+
+ get(key: string, now = Date.now()): unknown[] | null {
+ const entry = this.entries.get(key)
+ if (!entry) {
+ return null
+ }
+ if (now - entry.at > this.maxAgeMs) {
+ this.delete(key)
+ return null
+ }
+ return entry.values
+ }
+
+ clear(): void {
+ this.entries.clear()
+ this.retainedBytes = 0
+ }
+
+ evidence(): MobileRpcListCacheEvidence {
+ return {
+ entryCount: this.entries.size,
+ retainedBytes: this.retainedBytes,
+ keysOldestFirst: [...this.entries.keys()]
+ }
+ }
+
+ private delete(key: string): void {
+ const entry = this.entries.get(key)
+ if (!entry) {
+ return
+ }
+ this.retainedBytes -= entry.retainedBytes
+ this.entries.delete(key)
+ }
+
+ private evictOverflow(): void {
+ while (this.entries.size > this.maxEntries || this.retainedBytes > this.maxRetainedBytes) {
+ const oldestKey = this.entries.keys().next().value
+ if (typeof oldestKey !== 'string') {
+ return
+ }
+ this.delete(oldestKey)
+ }
+ }
+}
+
+function utf8ByteLength(value: string): number {
+ let bytes = 0
+ for (let index = 0; index < value.length; index += 1) {
+ const code = value.charCodeAt(index)
+ if (code <= 0x7f) {
+ bytes += 1
+ } else if (code <= 0x7ff) {
+ bytes += 2
+ } else if (code >= 0xd800 && code <= 0xdbff) {
+ const next = value.charCodeAt(index + 1)
+ if (next >= 0xdc00 && next <= 0xdfff) {
+ bytes += 4
+ index += 1
+ } else {
+ bytes += 3
+ }
+ } else {
+ bytes += 3
+ }
+ }
+ return bytes
+}
diff --git a/mobile/src/cache/repo-cache.test.ts b/mobile/src/cache/repo-cache.test.ts
index 156fa507619..adef8d02fcc 100644
--- a/mobile/src/cache/repo-cache.test.ts
+++ b/mobile/src/cache/repo-cache.test.ts
@@ -1,8 +1,17 @@
-import { describe, expect, it, vi } from 'vitest'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
-import { getCachedRepos, setCachedRepos } from './repo-cache'
+import {
+ getCachedRepos,
+ MOBILE_REPO_CACHE_MAX_ITEMS_PER_HOST,
+ resetRepoCacheForTests,
+ setCachedRepos
+} from './repo-cache'
describe('repo cache', () => {
+ beforeEach(() => {
+ resetRepoCacheForTests()
+ })
+
it('returns recent host-scoped repos', () => {
const repos = [{ id: 'repo-1' }]
@@ -23,4 +32,13 @@ describe('repo cache', () => {
vi.useRealTimers()
}
})
+
+ it('retains the exact per-host item cap and rejects one over', () => {
+ const exact = Array.from({ length: MOBILE_REPO_CACHE_MAX_ITEMS_PER_HOST }, () => null)
+ setCachedRepos('host', exact)
+ expect(getCachedRepos('host')).toBe(exact)
+
+ setCachedRepos('host', [...exact, null])
+ expect(getCachedRepos('host')).toBeNull()
+ })
})
diff --git a/mobile/src/cache/repo-cache.ts b/mobile/src/cache/repo-cache.ts
index ecb485c4d67..e5e407aee06 100644
--- a/mobile/src/cache/repo-cache.ts
+++ b/mobile/src/cache/repo-cache.ts
@@ -2,35 +2,29 @@
// host-scoped cache lets workspace creation open from the last known list while
// a fresh repo.list refresh happens in the background.
-type CachedRepos = {
- repos: unknown[]
- at: number
-}
-
-const cache = new Map()
+import { MobileRpcListCache } from './mobile-rpc-list-cache'
const MAX_AGE_MS = 60_000
-const MAX_ENTRIES = 20
+export const MOBILE_REPO_CACHE_MAX_ENTRIES = 20
+export const MOBILE_REPO_CACHE_MAX_ITEMS_PER_HOST = 10_000
+export const MOBILE_REPO_CACHE_MAX_RETAINED_BYTES = 16 * 1024 * 1024
+
+const cache = new MobileRpcListCache(
+ MAX_AGE_MS,
+ MOBILE_REPO_CACHE_MAX_ENTRIES,
+ MOBILE_REPO_CACHE_MAX_ITEMS_PER_HOST,
+ MOBILE_REPO_CACHE_MAX_RETAINED_BYTES
+)
export function setCachedRepos(hostId: string, repos: unknown[]): void {
- cache.delete(hostId)
- cache.set(hostId, { repos, at: Date.now() })
- if (cache.size > MAX_ENTRIES) {
- const oldest = cache.keys().next().value
- if (oldest) {
- cache.delete(oldest)
- }
- }
+ cache.set(hostId, repos)
}
export function getCachedRepos(hostId: string): unknown[] | null {
- const entry = cache.get(hostId)
- if (!entry) {
- return null
- }
- if (Date.now() - entry.at > MAX_AGE_MS) {
- cache.delete(hostId)
- return null
- }
- return entry.repos
+ return cache.get(hostId)
+}
+
+/** Test-only: clear process-lifetime cache state between cases. */
+export function resetRepoCacheForTests(): void {
+ cache.clear()
}
diff --git a/mobile/src/cache/worktree-cache.test.ts b/mobile/src/cache/worktree-cache.test.ts
index f167c6c547d..5412627516c 100644
--- a/mobile/src/cache/worktree-cache.test.ts
+++ b/mobile/src/cache/worktree-cache.test.ts
@@ -1,10 +1,19 @@
-import { describe, expect, it } from 'vitest'
-import { setCachedWorktrees, getCachedWorktrees } from './worktree-cache'
+import { beforeEach, describe, expect, it } from 'vitest'
+import {
+ getCachedWorktrees,
+ MOBILE_WORKTREE_CACHE_MAX_ITEMS_PER_HOST,
+ resetWorktreeCacheForTests,
+ setCachedWorktrees
+} from './worktree-cache'
// Why: AC #8498 guarantees a reconnect refetch writes through the
// same cache path the host detail screen seeds from, so a reconnect can't
// serve a stale snapshot. This unit pins the write-through contract.
describe('worktree-cache write-through', () => {
+ beforeEach(() => {
+ resetWorktreeCacheForTests()
+ })
+
it('returns the most-recently written snapshot, not a stale one', () => {
const hostId = 'host-write-through'
const stale = [{ worktreeId: 'a', name: 'stale' }]
@@ -40,4 +49,13 @@ describe('worktree-cache write-through', () => {
// A fresh screen mount reads the cache — must see the connected set.
expect(getCachedWorktrees(hostId)).toEqual(reconnected)
})
+
+ it('retains the exact per-host item cap and rejects one over', () => {
+ const exact = Array.from({ length: MOBILE_WORKTREE_CACHE_MAX_ITEMS_PER_HOST }, () => null)
+ setCachedWorktrees('host', exact)
+ expect(getCachedWorktrees('host')).toBe(exact)
+
+ setCachedWorktrees('host', [...exact, null])
+ expect(getCachedWorktrees('host')).toBeNull()
+ })
})
diff --git a/mobile/src/cache/worktree-cache.ts b/mobile/src/cache/worktree-cache.ts
index 0b05f78b260..b95d97f8549 100644
--- a/mobile/src/cache/worktree-cache.ts
+++ b/mobile/src/cache/worktree-cache.ts
@@ -2,38 +2,29 @@
// so the host detail page can render instantly on navigation instead of
// waiting for a fresh RPC connection + fetch cycle.
-type CachedWorktrees = {
- worktrees: unknown[]
- at: number
-}
-
-const cache = new Map()
+import { MobileRpcListCache } from './mobile-rpc-list-cache'
const MAX_AGE_MS = 30_000
-const MAX_ENTRIES = 20
+export const MOBILE_WORKTREE_CACHE_MAX_ENTRIES = 20
+export const MOBILE_WORKTREE_CACHE_MAX_ITEMS_PER_HOST = 10_000
+export const MOBILE_WORKTREE_CACHE_MAX_RETAINED_BYTES = 16 * 1024 * 1024
+
+const cache = new MobileRpcListCache(
+ MAX_AGE_MS,
+ MOBILE_WORKTREE_CACHE_MAX_ENTRIES,
+ MOBILE_WORKTREE_CACHE_MAX_ITEMS_PER_HOST,
+ MOBILE_WORKTREE_CACHE_MAX_RETAINED_BYTES
+)
export function setCachedWorktrees(hostId: string, worktrees: unknown[]): void {
- // Why: Map.set on an existing key does not move it to the end of iteration
- // order. Delete first so the re-inserted key becomes the newest entry,
- // giving us true LRU eviction when the cap is hit.
- cache.delete(hostId)
- cache.set(hostId, { worktrees, at: Date.now() })
- if (cache.size > MAX_ENTRIES) {
- const oldest = cache.keys().next().value
- if (oldest) {
- cache.delete(oldest)
- }
- }
+ cache.set(hostId, worktrees)
}
export function getCachedWorktrees(hostId: string): unknown[] | null {
- const entry = cache.get(hostId)
- if (!entry) {
- return null
- }
- if (Date.now() - entry.at > MAX_AGE_MS) {
- cache.delete(hostId)
- return null
- }
- return entry.worktrees
+ return cache.get(hostId)
+}
+
+/** Test-only: clear process-lifetime cache state between cases. */
+export function resetWorktreeCacheForTests(): void {
+ cache.clear()
}
diff --git a/mobile/src/components/BottomDrawer.tsx b/mobile/src/components/BottomDrawer.tsx
index 138e39ddc0e..030e38bcd3f 100644
--- a/mobile/src/components/BottomDrawer.tsx
+++ b/mobile/src/components/BottomDrawer.tsx
@@ -1,41 +1,6 @@
-import { type ReactNode, useCallback, useEffect, useState } from 'react'
-import {
- View,
- Pressable,
- StyleSheet,
- Platform,
- useWindowDimensions,
- ScrollView,
- Keyboard,
- BackHandler,
- Modal
-} from 'react-native'
-import { useSafeAreaInsets } from 'react-native-safe-area-context'
-import { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler'
-import Animated, {
- useSharedValue,
- useAnimatedStyle,
- useAnimatedScrollHandler,
- withSpring,
- withTiming,
- runOnJS,
- interpolate,
- Extrapolation
-} from 'react-native-reanimated'
-import { colors, spacing } from '../theme/mobile-theme'
+import { type ReactNode, useState } from 'react'
import { resolveBottomDrawerMounted } from './bottom-drawer-mount-state'
-import { useInsideBottomDrawerModalHost } from './bottom-drawer-modal-host'
-import { useResponsiveLayout } from '../layout/responsive-layout'
-
-const DISMISS_THRESHOLD = 80
-const SPRING_CONFIG = { damping: 28, stiffness: 400 }
-// Why: negative translateY (pulling up) is damped with a rubber-band factor
-// so the drawer resists upward dragging — a subtle polish touch that signals
-// the drawer cannot expand further.
-const RUBBER_BAND_FACTOR = 0.25
-const SHOW_DURATION = 180
-export const BOTTOM_DRAWER_HIDE_DURATION_MS = 150
-const TOP_SCROLL_EPSILON = 1
+import { MountedBottomDrawer } from './mounted-bottom-drawer'
type Props = {
visible: boolean
@@ -44,6 +9,13 @@ type Props = {
children: ReactNode
dragContentToDismiss?: boolean
contentScrollable?: boolean
+ // Why: smart-source (and similar) need a stable outer frame so a docked
+ // TextInput can sit above the keyboard while results reflow in flex space
+ // above it — content-sized sheets make that field ride every list change.
+ fillAvailable?: boolean
+ // Why: pin an outer content-sized sheet under an inner fill picker without
+ // letting it take touches, draw a second backdrop, or keyboard-lift.
+ interactive?: boolean
zIndex?: number
}
@@ -54,6 +26,8 @@ export function BottomDrawer({
children,
dragContentToDismiss = true,
contentScrollable = true,
+ fillAvailable = false,
+ interactive = true,
zIndex
}: Props) {
const [mounted, setMounted] = useState(visible)
@@ -81,365 +55,11 @@ export function BottomDrawer({
}}
dragContentToDismiss={dragContentToDismiss}
contentScrollable={contentScrollable}
+ fillAvailable={fillAvailable}
+ interactive={interactive}
zIndex={zIndex}
>
{children}
)
}
-
-type MountedBottomDrawerProps = Props & {
- onHidden: () => void
-}
-
-function MountedBottomDrawer({
- visible,
- onClose,
- onHidden,
- children,
- dragContentToDismiss = true,
- contentScrollable = true,
- zIndex = 1000
-}: MountedBottomDrawerProps) {
- const translateY = useSharedValue(0)
- const progress = useSharedValue(0)
- const keyboardOffset = useSharedValue(0)
- const scrollOffsetY = useSharedValue(0)
- const contentDragStartY = useSharedValue(0)
- const contentDragCanDismiss = useSharedValue(false)
- const { height: screenHeight } = useWindowDimensions()
- const insets = useSafeAreaInsets()
- // Why: on wide/tablet canvases a full-width sheet looks stretched; cap it and
- // center it horizontally. Vertical bottom-anchoring (and all the drag/keyboard
- // transforms below) is unchanged, so phone behavior stays identical.
- const { isWideLayout, modalMaxWidth } = useResponsiveLayout()
- const insideModalHost = useInsideBottomDrawerModalHost()
-
- useEffect(() => {
- if (visible) {
- translateY.value = 0
- scrollOffsetY.value = 0
- progress.value = withTiming(1, { duration: SHOW_DURATION })
- } else {
- Keyboard.dismiss()
- progress.value = withTiming(0, { duration: BOTTOM_DRAWER_HIDE_DURATION_MS }, (finished) => {
- if (finished) {
- runOnJS(onHidden)()
- }
- })
- }
- }, [onHidden, visible])
-
- // Why: KeyboardAvoidingView and useAnimatedKeyboard are both unreliable
- // inside Modal (iOS ignores KAV; Android needs adjustNothing for
- // useAnimatedKeyboard). Keyboard event listeners work on both platforms
- // and give us the exact height to shift the drawer by.
- useEffect(() => {
- if (!visible) {
- return
- }
-
- const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'
- const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide'
-
- const onShow = Keyboard.addListener(showEvent, (e) => {
- const height = e.endCoordinates.height - insets.bottom
- keyboardOffset.value = withTiming(Math.max(height, 0), { duration: e.duration || 250 })
- })
- const onHide = Keyboard.addListener(hideEvent, (e) => {
- keyboardOffset.value = withTiming(0, { duration: e.duration || 250 })
- })
-
- return () => {
- onShow.remove()
- onHide.remove()
- keyboardOffset.value = 0
- }
- }, [visible, insets.bottom])
-
- const dismiss = useCallback(() => {
- Keyboard.dismiss()
- progress.value = withTiming(0, { duration: BOTTOM_DRAWER_HIDE_DURATION_MS }, (finished) => {
- if (finished) {
- runOnJS(onClose)()
- }
- })
- }, [onClose, progress])
-
- useEffect(() => {
- if (!visible) {
- return
- }
-
- const sub = BackHandler.addEventListener('hardwareBackPress', () => {
- dismiss()
- return true
- })
- return () => sub.remove()
- }, [visible, dismiss])
-
- const scrollHandler = useAnimatedScrollHandler((event) => {
- scrollOffsetY.value = Math.max(event.contentOffset.y, 0)
- })
-
- const scrollGesture = Gesture.Native()
- const handlePanGesture = Gesture.Pan()
- .activeOffsetY([-8, 8])
- .simultaneousWithExternalGesture(scrollGesture)
- .onUpdate((e) => {
- if (e.translationY > 0) {
- translateY.value = e.translationY
- } else {
- translateY.value = e.translationY * RUBBER_BAND_FACTOR
- }
- })
- .onEnd((e) => {
- if (e.translationY > DISMISS_THRESHOLD || e.velocityY > 500) {
- const velocity = Math.max(e.velocityY, 800)
- const remaining = screenHeight - e.translationY
- const duration = Math.min(Math.max((remaining / velocity) * 1000, 120), 300)
- translateY.value = withTiming(screenHeight, { duration })
- progress.value = withTiming(0, { duration }, () => {
- runOnJS(onClose)()
- })
- } else {
- translateY.value = withSpring(0, SPRING_CONFIG)
- }
- })
- const contentPanGesture = Gesture.Pan()
- .activeOffsetY([-8, 8])
- .simultaneousWithExternalGesture(scrollGesture)
- .onBegin(() => {
- contentDragStartY.value = 0
- contentDragCanDismiss.value = scrollOffsetY.value <= TOP_SCROLL_EPSILON
- })
- .onUpdate((e) => {
- // Why: action-sheet content can be taller than the drawer; downward drags
- // should scroll back to the top before they start dismissing the sheet.
- if (scrollOffsetY.value > TOP_SCROLL_EPSILON) {
- contentDragCanDismiss.value = false
- contentDragStartY.value = 0
- if (translateY.value !== 0) {
- translateY.value = withSpring(0, SPRING_CONFIG)
- }
- return
- }
-
- if (!contentDragCanDismiss.value) {
- contentDragCanDismiss.value = true
- contentDragStartY.value = e.translationY
- }
-
- const translationY = e.translationY - contentDragStartY.value
- if (translationY > 0) {
- translateY.value = translationY
- } else {
- translateY.value = translationY * RUBBER_BAND_FACTOR
- }
- })
- .onEnd((e) => {
- if (!contentDragCanDismiss.value || scrollOffsetY.value > TOP_SCROLL_EPSILON) {
- return
- }
-
- const translationY = e.translationY - contentDragStartY.value
- if (translationY > DISMISS_THRESHOLD || e.velocityY > 500) {
- const velocity = Math.max(e.velocityY, 800)
- const remaining = screenHeight - translationY
- const duration = Math.min(Math.max((remaining / velocity) * 1000, 120), 300)
- translateY.value = withTiming(screenHeight, { duration })
- progress.value = withTiming(0, { duration }, () => {
- runOnJS(onClose)()
- })
- } else {
- translateY.value = withSpring(0, SPRING_CONFIG)
- }
- })
-
- const drawerStyle = useAnimatedStyle(() => ({
- transform: [
- {
- translateY:
- interpolate(progress.value, [0, 1], [screenHeight, 0], Extrapolation.CLAMP) +
- translateY.value -
- keyboardOffset.value
- }
- ]
- }))
-
- const backdropStyle = useAnimatedStyle(() => {
- const dragFade = interpolate(translateY.value, [0, 300], [1, 0], Extrapolation.CLAMP)
- return { opacity: progress.value * dragFade }
- })
-
- // Why: the sheet renders through a full-screen native window (its own Modal
- // below, or the shared BottomDrawerModalHost) so it always covers the viewport
- // — even when mounted deep inside a ScrollView, where a plain absolute overlay
- // anchors to the scrolled content and clips the sheet. Show/hide is driven by
- // `progress` (animationType "none") so the reanimated exit animation runs before
- // the parent unmounts us.
- const overlay = (
-
-
-
-
-
-
-
-
- {!contentScrollable ? (
- <>
-
-
-
-
-
- {children}
- >
- ) : dragContentToDismiss ? (
- <>
-
-
-
-
-
-
-
-
-
- {children}
-
-
-
-
- >
- ) : (
- <>
-
-
-
-
-
-
- {children}
-
- >
- )}
-
-
-
-
-
- )
-
- // Why: inside a BottomDrawerModalHost the host owns the single native Modal;
- // rendering our own would stack modals and reintroduce the iOS present/dismiss
- // race the host exists to avoid. The host handles the Android back button.
- if (insideModalHost) {
- return overlay
- }
-
- return (
-
- {overlay}
-
- )
-}
-
-const styles = StyleSheet.create({
- overlay: {
- ...StyleSheet.absoluteFillObject,
- zIndex: 1000
- },
- root: {
- flex: 1
- },
- backdrop: {
- ...StyleSheet.absoluteFillObject,
- backgroundColor: 'rgba(0,0,0,0.5)'
- },
- anchor: {
- flex: 1,
- justifyContent: 'flex-end'
- },
- anchorWide: {
- alignItems: 'center'
- },
- drawer: {
- backgroundColor: colors.bgBase,
- borderTopLeftRadius: 16,
- borderTopRightRadius: 16,
- paddingHorizontal: spacing.md,
- ...Platform.select({
- ios: {
- shadowColor: '#000',
- shadowOffset: { width: 0, height: -2 },
- shadowOpacity: 0.2,
- shadowRadius: 10
- },
- android: { elevation: 8 }
- })
- },
- handle: {
- alignSelf: 'center',
- width: 36,
- height: 4,
- borderRadius: 2,
- backgroundColor: colors.textMuted,
- opacity: 0.4
- },
- handleHitArea: {
- alignItems: 'center',
- paddingTop: spacing.sm,
- paddingBottom: spacing.md
- },
- staticContent: {
- minHeight: 0
- },
- bottomExtension: {
- position: 'absolute',
- bottom: -500,
- left: 0,
- right: 0,
- height: 500,
- backgroundColor: colors.bgBase
- }
-})
diff --git a/mobile/src/components/CustomKeyModal.tsx b/mobile/src/components/CustomKeyModal.tsx
index 2777e310dc8..6f81da63880 100644
--- a/mobile/src/components/CustomKeyModal.tsx
+++ b/mobile/src/components/CustomKeyModal.tsx
@@ -1,7 +1,6 @@
import { useCallback, useMemo, useState } from 'react'
import { View, Text, Pressable, TextInput, StyleSheet, Switch } from 'react-native'
import { ChevronLeft } from 'lucide-react-native'
-import AsyncStorage from '@react-native-async-storage/async-storage'
import { colors, spacing, radii, typography } from '../theme/mobile-theme'
import { BottomDrawer } from './BottomDrawer'
import {
@@ -11,15 +10,19 @@ import {
type TerminalShortcutModifier,
type TerminalShortcutSpecialKey
} from '../terminal/terminal-accessory-keys'
+import {
+ CUSTOM_ACCESSORY_KEY_MAX_BYTES_CHARACTERS,
+ CUSTOM_ACCESSORY_KEY_MAX_LABEL_CHARACTERS,
+ loadCustomKeys,
+ saveCustomKeys,
+ type CustomKey
+} from '../terminal/custom-accessory-key-store'
-const CUSTOM_ACCESSORY_KEYS_STORAGE_KEY = 'orca:custom-accessory-keys'
-
-export type CustomKey = {
- id: string
- label: string
- bytes: string
- enter: boolean
-}
+export {
+ loadCustomKeys,
+ saveCustomKeys,
+ type CustomKey
+} from '../terminal/custom-accessory-key-store'
type Step = 'choose-type' | 'shortcut-combo' | 'special-keys' | 'text-macro'
@@ -64,19 +67,6 @@ type Props = {
onManageShortcuts?: () => void
}
-export async function loadCustomKeys(): Promise {
- try {
- const raw = await AsyncStorage.getItem(CUSTOM_ACCESSORY_KEYS_STORAGE_KEY)
- return raw ? (JSON.parse(raw) as CustomKey[]) : []
- } catch {
- return []
- }
-}
-
-export async function saveCustomKeys(keys: CustomKey[]): Promise {
- await AsyncStorage.setItem(CUSTOM_ACCESSORY_KEYS_STORAGE_KEY, JSON.stringify(keys))
-}
-
export function CustomKeyModal({ visible, onClose, onKeysChanged, onManageShortcuts }: Props) {
const [step, setStep] = useState('choose-type')
const [shortcutKey, setShortcutKey] = useState('c')
@@ -104,8 +94,7 @@ export function CustomKeyModal({ visible, onClose, onKeysChanged, onManageShortc
async (key: Omit) => {
const existing = await loadCustomKeys()
const newKey: CustomKey = { ...key, id: `custom-${Date.now()}` }
- const updated = [...existing, newKey]
- await saveCustomKeys(updated)
+ const updated = await saveCustomKeys([...existing, newKey])
onKeysChanged(updated)
onClose()
},
@@ -372,6 +361,7 @@ export function CustomKeyModal({ visible, onClose, onKeysChanged, onManageShortc
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={false}
+ maxLength={CUSTOM_ACCESSORY_KEY_MAX_LABEL_CHARACTERS}
/>
Command
Press Enter
diff --git a/mobile/src/components/NewWorktreeModal.tsx b/mobile/src/components/NewWorktreeModal.tsx
index 61c9b2e711b..60dffab0d2a 100644
--- a/mobile/src/components/NewWorktreeModal.tsx
+++ b/mobile/src/components/NewWorktreeModal.tsx
@@ -14,8 +14,9 @@ import { ChevronDown, ChevronUp } from 'lucide-react-native'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcResponse, RpcSuccess } from '../transport/types'
import { colors, spacing, radii, typography } from '../theme/mobile-theme'
-import { BottomDrawer, BOTTOM_DRAWER_HIDE_DURATION_MS } from './BottomDrawer'
+import { BottomDrawer } from './BottomDrawer'
import { BottomDrawerModalHost } from './bottom-drawer-modal-host'
+import { useNewWorktreeDrawerNavigation } from './use-new-worktree-drawer-navigation'
import { PickerListDrawer } from './PickerListDrawer'
import { MobileAgentIcon } from './MobileAgentIcon'
import { getSuggestedCreatureName } from './worktree-name-suggestion'
@@ -111,12 +112,6 @@ type CreateOptions = {
approvedSetupContentHash?: string
}
-type NewWorktreeDrawerView = 'form' | 'transition' | 'source' | 'repo' | 'agent' | 'trust'
-
-// Why: iOS cannot reliably present a second native modal until the first drawer's
-// exit commits; one extra frame keeps transitions sequential on slower devices.
-const NEW_WORKTREE_DRAWER_TRANSITION_MS = BOTTOM_DRAWER_HIDE_DURATION_MS + 16
-
function repoColor(name: string): string {
const palette = ['#f97316', '#8b5cf6', '#06b6d4', '#ec4899', '#84cc16', '#f59e0b', '#6366f1']
let hash = 0
@@ -196,8 +191,8 @@ function NewWorktreeModalContent({
const [initialRepos] = useState(() => (hostId ? (getCachedRepos(hostId) as Repo[] | null) : null))
const [repos, setRepos] = useState(initialRepos ?? [])
const [selectedRepo, setSelectedRepo] = useState(null)
- const [drawerView, setDrawerView] = useState('form')
- const drawerTransitionTimerRef = useRef | null>(null)
+ const { drawerView, formSheetVisible, formSheetInteractive, transitionDrawer, openSourceDrawer } =
+ useNewWorktreeDrawerNavigation(visible)
const createInFlightRef = useRef(false)
const setupTrustActionInFlightRef = useRef(false)
const [selectedAgentState, setSelectedAgent] = useState(AGENT_OPTIONS[0]!)
@@ -232,29 +227,6 @@ function NewWorktreeModalContent({
[existingWorktrees, selectedRepo]
)
- useEffect(() => {
- return () => {
- if (drawerTransitionTimerRef.current) {
- clearTimeout(drawerTransitionTimerRef.current)
- }
- }
- }, [])
-
- function transitionDrawer(nextView: Exclude): void {
- if (drawerTransitionTimerRef.current) {
- clearTimeout(drawerTransitionTimerRef.current)
- }
- setDrawerView('transition')
- drawerTransitionTimerRef.current = setTimeout(() => {
- drawerTransitionTimerRef.current = null
- setDrawerView(nextView)
- }, NEW_WORKTREE_DRAWER_TRANSITION_MS)
- }
-
- // The Smart source picker owns the workspace name AND the linked-source
- // selection: typing names the workspace and drives source search, and picking
- // a source resolves the base/branch/push metadata (matching desktop). The
- // creature-name fallback is only computed lazily at submit for a blank name.
const composer = useMobileComposerSource({
client,
selectedRepoId: selectedRepo?.id ?? null,
@@ -830,7 +802,7 @@ function NewWorktreeModalContent({
}
}}
>
-
+
Create Workspace
@@ -876,8 +848,9 @@ function NewWorktreeModalContent({
composer={composer}
label={selectedRepoIsGit ? "Name or 'Create From'" : 'Workspace name'}
disabled={sshGate.requiresConnection}
+ interactive={formSheetInteractive}
onBeforeOpen={() => setError('')}
- onOpenDrawer={() => transitionDrawer('source')}
+ onOpenDrawer={openSourceDrawer}
/>
{composer.forkPushWarning ? (
diff --git a/mobile/src/components/PickerListDrawer.tsx b/mobile/src/components/PickerListDrawer.tsx
index 018832cdf98..e908c98f449 100644
--- a/mobile/src/components/PickerListDrawer.tsx
+++ b/mobile/src/components/PickerListDrawer.tsx
@@ -3,7 +3,8 @@ import { FlatList, Pressable, StyleSheet, Text, View } from 'react-native'
import { Check } from 'lucide-react-native'
import { colors, spacing, typography } from '../theme/mobile-theme'
-import { BottomDrawer, BOTTOM_DRAWER_HIDE_DURATION_MS } from './BottomDrawer'
+import { BottomDrawer } from './BottomDrawer'
+import { BOTTOM_DRAWER_HIDE_DURATION_MS } from './bottom-drawer-constants'
type Props = {
visible: boolean
diff --git a/mobile/src/components/SmartWorkspaceSourceDrawer.tsx b/mobile/src/components/SmartWorkspaceSourceDrawer.tsx
index c5f1a6c47db..df12d5aae62 100644
--- a/mobile/src/components/SmartWorkspaceSourceDrawer.tsx
+++ b/mobile/src/components/SmartWorkspaceSourceDrawer.tsx
@@ -2,8 +2,8 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import {
ActivityIndicator,
FlatList,
+ InteractionManager,
Pressable,
- StyleSheet,
Text,
TextInput,
View
@@ -25,11 +25,16 @@ import {
} from '../tasks/smart-source-paste-intent'
import { useSmartWorkspaceSource } from '../tasks/use-smart-workspace-source'
import type { MobileComposerSource } from '../tasks/use-mobile-composer-source'
-import { colors, radii, spacing, typography } from '../theme/mobile-theme'
-import { BottomDrawer, BOTTOM_DRAWER_HIDE_DURATION_MS } from './BottomDrawer'
+import { colors } from '../theme/mobile-theme'
+import { BottomDrawer } from './BottomDrawer'
+import { smartWorkspaceSourceDrawerStyles as styles } from './smart-workspace-source-drawer-styles'
import { SmartSourceModeIcon } from './SmartSourceModeIcon'
import { SmartWorkspaceSourceRow } from './SmartWorkspaceSourceRow'
+// Why: match MobileSearchField — native autoFocus alone often fails to raise
+// the soft keyboard when the drawer is mid-present animation.
+const SOURCE_INPUT_FOCUS_DELAY_MS = 120
+
type Props = {
visible: boolean
client: RpcClient | null
@@ -58,6 +63,7 @@ export function SmartWorkspaceSourceDrawer({
const availableModes = useMemo(() => resolveAvailableSmartModes(availability), [availability])
const [mode, setMode] = useState(() => resolveDefaultSmartMode(availability))
const [mrStateFilter, setMrStateFilter] = useState('opened')
+ const inputRef = useRef(null)
// Why: read latest availability inside the open effect without making it a
// reactive dep (the object is recreated each render), so re-seeding happens
// only on open, not on every availability recompute.
@@ -71,6 +77,26 @@ export function SmartWorkspaceSourceDrawer({
}
}, [visible])
+ // Why: focus after open interactions settle so the keyboard appears and the
+ // caret lands in the docked field (same value as the form via composer.name).
+ useEffect(() => {
+ if (!visible) {
+ return
+ }
+ let timeout: ReturnType | undefined
+ const task = InteractionManager.runAfterInteractions(() => {
+ timeout = setTimeout(() => {
+ inputRef.current?.focus()
+ }, SOURCE_INPUT_FOCUS_DELAY_MS)
+ })
+ return () => {
+ task.cancel()
+ if (timeout) {
+ clearTimeout(timeout)
+ }
+ }
+ }, [visible])
+
// Snap the chosen mode back into the available set if availability changes.
const effectiveMode = availableModes.includes(mode) ? mode : (availableModes[0] ?? 'text')
@@ -100,10 +126,6 @@ export function SmartWorkspaceSourceDrawer({
repos
})
- function closeSoon(): void {
- setTimeout(onClose, BOTTOM_DRAWER_HIDE_DURATION_MS)
- }
-
function handleSelectRow(row: SourceRow): void {
switch (row.kind) {
case 'use-name':
@@ -154,272 +176,146 @@ export function SmartWorkspaceSourceDrawer({
const showEmpty =
!loading && !error && !needsGitHubRemote && effectiveMode !== 'text' && rows.length === 0
+ const modeTabs = SMART_MODE_OPTIONS.filter((option: SmartModeOption) =>
+ availableModes.includes(option.id)
+ )
+
return (
-
- Name or 'Create From'
-
- Done
-
-
-
-
-
-
- {SMART_MODE_OPTIONS.filter((option: SmartModeOption) =>
- availableModes.includes(option.id)
- ).map((option) => {
- const selected = option.id === effectiveMode
- const tint = selected ? colors.textPrimary : colors.textSecondary
- return (
- setMode(option.id)}
- >
-
-
- {option.label}
-
-
- )
- })}
-
-
- {effectiveMode === 'gitlab' ? (
-
- {MR_STATE_FILTER_OPTIONS.map((option) => {
- const selected = option.id === mrStateFilter
- return (
- setMrStateFilter(option.id)}
- >
-
- {option.label}
-
-
- )
- })}
+ {/* Why: column with results flex:1 + dock flex-shrink:0 at the end.
+ Fill sheet height + marginBottom place this column on the keyboard
+ top; dock must stay a non-flex sibling so FlatList cannot clip it. */}
+
+
+ Name or 'Create From'
+
+ Done
+
- ) : null}
- {crossRepoPrompt ? (
-
-
- This item lives in {crossRepoPrompt.link.slug.owner}/{crossRepoPrompt.link.slug.repo}.
-
-
-
- Cancel
-
- void handleAcceptCrossRepo()}>
-
- Switch to {crossRepoPrompt.matchingRepo.displayName}
+
+ {crossRepoPrompt ? (
+
+
+ This item lives in {crossRepoPrompt.link.slug.owner}/
+ {crossRepoPrompt.link.slug.repo}.
-
-
-
- ) : null}
-
- {!sshReady && effectiveMode !== 'text' && effectiveMode !== 'linear' ? (
- Connect the repository to search sources.
- ) : needsGitHubRemote ? (
-
- This SSH repo needs a GitHub remote to list issues and PRs.
-
- ) : error ? (
- {error}
- ) : null}
-
- row.value}
- style={styles.list}
- keyboardShouldPersistTaps="handled"
- nestedScrollEnabled
- ListFooterComponent={
- loading ? (
-
-
+
+
+ Cancel
+
+ void handleAcceptCrossRepo()}
+ >
+
+ Switch to {crossRepoPrompt.matchingRepo.displayName}
+
+
+
- ) : showEmpty ? (
- {emptyHint || 'No results found.'}
- ) : null
- }
- renderItem={({ item }) => (
- handleSelectRow(item)} />
- )}
- />
+ ) : null}
+
+ {!sshReady && effectiveMode !== 'text' && effectiveMode !== 'linear' ? (
+ Connect the repository to search sources.
+ ) : needsGitHubRemote ? (
+
+ This SSH repo needs a GitHub remote to list issues and PRs.
+
+ ) : error ? (
+ {error}
+ ) : null}
+
+ row.value}
+ style={styles.list}
+ contentContainerStyle={styles.listContent}
+ keyboardShouldPersistTaps="handled"
+ keyboardDismissMode="none"
+ nestedScrollEnabled
+ ListFooterComponent={
+ loading ? (
+
+
+
+ ) : showEmpty ? (
+ {emptyHint || 'No results found.'}
+ ) : rows.length === 0 && effectiveMode === 'text' ? (
+ Type a workspace name in the field below.
+ ) : null
+ }
+ renderItem={({ item }) => (
+ handleSelectRow(item)} />
+ )}
+ />
+
+
+
+ {effectiveMode === 'gitlab' ? (
+
+ {MR_STATE_FILTER_OPTIONS.map((option) => {
+ const selected = option.id === mrStateFilter
+ return (
+ setMrStateFilter(option.id)}
+ >
+
+ {option.label}
+
+
+ )
+ })}
+
+ ) : null}
+
+
+ {modeTabs.map((option) => {
+ const selected = option.id === effectiveMode
+ const tint = selected ? colors.textPrimary : colors.textSecondary
+ return (
+ setMode(option.id)}
+ >
+
+
+ {option.label}
+
+
+ )
+ })}
+
+
+
+
+
)
}
-
-const styles = StyleSheet.create({
- header: {
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'space-between',
- paddingHorizontal: spacing.xs,
- paddingBottom: spacing.sm
- },
- title: {
- fontSize: 15,
- fontWeight: '600',
- color: colors.textPrimary
- },
- done: {
- fontSize: typography.bodySize,
- fontWeight: '600',
- color: colors.accentBlue
- },
- search: {
- backgroundColor: colors.bgRaised,
- color: colors.textPrimary,
- borderRadius: radii.input,
- paddingHorizontal: spacing.md,
- paddingVertical: spacing.sm,
- fontSize: typography.bodySize,
- borderWidth: 1,
- borderColor: colors.borderSubtle,
- marginBottom: spacing.sm
- },
- tabRow: {
- flexDirection: 'row',
- flexWrap: 'wrap',
- gap: spacing.xs,
- marginBottom: spacing.sm
- },
- tab: {
- flexDirection: 'row',
- alignItems: 'center',
- gap: spacing.xs,
- paddingHorizontal: spacing.sm + 2,
- paddingVertical: spacing.xs + 2,
- borderRadius: radii.button,
- borderWidth: 1,
- borderColor: colors.borderSubtle
- },
- tabSelected: {
- backgroundColor: colors.bgPanel,
- borderColor: colors.textSecondary
- },
- tabText: {
- fontSize: 13,
- color: colors.textSecondary
- },
- tabTextSelected: {
- color: colors.textPrimary,
- fontWeight: '600'
- },
- chipRow: {
- flexDirection: 'row',
- gap: spacing.xs,
- marginBottom: spacing.sm
- },
- chip: {
- paddingHorizontal: spacing.md,
- paddingVertical: spacing.xs,
- borderRadius: radii.button,
- borderWidth: 1,
- borderColor: colors.borderSubtle
- },
- chipSelected: {
- backgroundColor: colors.bgPanel,
- borderColor: colors.textSecondary
- },
- chipText: {
- fontSize: 12,
- color: colors.textSecondary
- },
- chipTextSelected: {
- color: colors.textPrimary,
- fontWeight: '600'
- },
- crossRepo: {
- backgroundColor: colors.bgRaised,
- borderRadius: radii.input,
- borderWidth: 1,
- borderColor: colors.borderSubtle,
- padding: spacing.md,
- marginBottom: spacing.sm,
- gap: spacing.sm
- },
- crossRepoText: {
- fontSize: 13,
- color: colors.textSecondary
- },
- crossRepoActions: {
- flexDirection: 'row',
- justifyContent: 'flex-end',
- gap: spacing.sm
- },
- crossRepoDismiss: {
- paddingHorizontal: spacing.md,
- paddingVertical: spacing.xs + 2,
- borderRadius: radii.button,
- borderWidth: 1,
- borderColor: colors.borderSubtle
- },
- crossRepoDismissText: {
- fontSize: 13,
- color: colors.textSecondary
- },
- crossRepoSwitch: {
- paddingHorizontal: spacing.md,
- paddingVertical: spacing.xs + 2,
- borderRadius: radii.button,
- backgroundColor: colors.bgPanel,
- borderWidth: 1,
- borderColor: colors.textSecondary
- },
- crossRepoSwitchText: {
- fontSize: 13,
- fontWeight: '600',
- color: colors.textPrimary
- },
- notice: {
- fontSize: 12,
- color: colors.textMuted,
- paddingHorizontal: spacing.xs,
- paddingBottom: spacing.sm
- },
- errorNotice: {
- fontSize: 12,
- color: colors.statusRed,
- paddingHorizontal: spacing.xs,
- paddingBottom: spacing.sm
- },
- list: {
- backgroundColor: colors.bgPanel,
- borderRadius: radii.card,
- overflow: 'hidden',
- maxHeight: 420,
- flexGrow: 0
- },
- loading: {
- paddingVertical: spacing.lg,
- alignItems: 'center'
- },
- empty: {
- paddingVertical: spacing.lg,
- textAlign: 'center',
- color: colors.textMuted,
- fontSize: 13
- }
-})
diff --git a/mobile/src/components/SmartWorkspaceSourceField.tsx b/mobile/src/components/SmartWorkspaceSourceField.tsx
index eb08ddca66a..9b8972b16be 100644
--- a/mobile/src/components/SmartWorkspaceSourceField.tsx
+++ b/mobile/src/components/SmartWorkspaceSourceField.tsx
@@ -1,4 +1,4 @@
-import { Linking, Pressable, StyleSheet, Text, View } from 'react-native'
+import { Linking, Pressable, StyleSheet, Text, TextInput, View } from 'react-native'
import {
CircleDot,
ExternalLink,
@@ -16,6 +16,10 @@ type Props = {
composer: MobileComposerSource
label: string
disabled?: boolean
+ // Why: only the active form view may focus this field. While the source drawer
+ // is open/closing this stays non-focusable so the drawer's dismiss (which
+ // restores native focus back here) can't re-fire onFocus and reopen the drawer.
+ interactive: boolean
onBeforeOpen?: () => void
onOpenDrawer: () => void
}
@@ -40,6 +44,7 @@ export function SmartWorkspaceSourceField({
composer,
label,
disabled,
+ interactive,
onBeforeOpen,
onOpenDrawer
}: Props) {
@@ -77,18 +82,24 @@ export function SmartWorkspaceSourceField({
) : (
-
-
- {composer.name || 'Type a name or search a source'}
-
-
+ value={composer.name}
+ onChangeText={composer.setName}
+ onFocus={openDrawer}
+ editable={!disabled && interactive}
+ placeholder="Type a name or search a source"
+ placeholderTextColor={colors.textMuted}
+ autoCapitalize="none"
+ autoCorrect={false}
+ // Why: form field is a portal into the picker; return should not
+ // submit the create form while the drawer is about to open.
+ blurOnSubmit={false}
+ showSoftInputOnFocus={false}
+ />
)}
)
@@ -114,17 +125,12 @@ const styles = StyleSheet.create({
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm + 2,
borderWidth: 1,
- borderColor: colors.borderSubtle
- },
- disabled: {
- opacity: 0.55
- },
- inputText: {
+ borderColor: colors.borderSubtle,
fontSize: typography.bodySize,
color: colors.textPrimary
},
- inputPlaceholder: {
- color: colors.textMuted
+ disabled: {
+ opacity: 0.55
},
pill: {
flexDirection: 'row',
diff --git a/mobile/src/components/TerminalShortcutSettings.tsx b/mobile/src/components/TerminalShortcutSettings.tsx
index 18d882aa3fb..66bf28046c5 100644
--- a/mobile/src/components/TerminalShortcutSettings.tsx
+++ b/mobile/src/components/TerminalShortcutSettings.tsx
@@ -117,7 +117,7 @@ export function TerminalShortcutSettings({
pendingCustomKeysWritesRef.current += 1
customKeysWriteChainRef.current = customKeysWriteChainRef.current
.catch(() => {})
- .then(() => saveCustomKeys(next))
+ .then(() => saveCustomKeys(next).then(() => undefined))
.catch(() => {})
.finally(() => {
pendingCustomKeysWritesRef.current -= 1
diff --git a/mobile/src/components/bottom-drawer-constants.ts b/mobile/src/components/bottom-drawer-constants.ts
new file mode 100644
index 00000000000..d021d3d4b78
--- /dev/null
+++ b/mobile/src/components/bottom-drawer-constants.ts
@@ -0,0 +1 @@
+export const BOTTOM_DRAWER_HIDE_DURATION_MS = 150
diff --git a/mobile/src/components/bottom-drawer-fill-height.test.ts b/mobile/src/components/bottom-drawer-fill-height.test.ts
new file mode 100644
index 00000000000..84a0498d768
--- /dev/null
+++ b/mobile/src/components/bottom-drawer-fill-height.test.ts
@@ -0,0 +1,67 @@
+import { describe, expect, it } from 'vitest'
+import { resolveBottomDrawerFillHeight } from './bottom-drawer-fill-height'
+
+describe('resolveBottomDrawerFillHeight', () => {
+ it('fills the space under the safe top when the keyboard is closed', () => {
+ expect(
+ resolveBottomDrawerFillHeight({
+ screenHeight: 844,
+ topInset: 54,
+ keyboardInset: 0,
+ topGap: 16
+ })
+ ).toBe(844 - 54 - 16)
+ })
+
+ it('shrinks by the keyboard inset so the sheet top stays under the status bar', () => {
+ expect(
+ resolveBottomDrawerFillHeight({
+ screenHeight: 844,
+ topInset: 54,
+ keyboardInset: 292,
+ topGap: 16
+ })
+ ).toBe(844 - 54 - 16 - 292)
+ })
+
+ it('never expands past the space above the keyboard on tiny viewports', () => {
+ expect(
+ resolveBottomDrawerFillHeight({
+ screenHeight: 400,
+ topInset: 50,
+ keyboardInset: 300,
+ topGap: 16
+ })
+ ).toBe(34)
+ })
+
+ it('pairs with marginBottom=keyboardInset so the sheet sits on the keyboard top', () => {
+ // screen 844, top 54, gap 16, keyboard 292 → height 482; marginBottom 292
+ // bottom edge at 844-292=552; top edge at 552-482=70 (= 54+16)
+ const keyboardInset = 292
+ const height = resolveBottomDrawerFillHeight({
+ screenHeight: 844,
+ topInset: 54,
+ keyboardInset,
+ topGap: 16
+ })
+ const topEdge = 844 - keyboardInset - height
+ expect(height).toBe(482)
+ expect(topEdge).toBe(54 + 16)
+ })
+
+ it('keeps the top edge under the status bar when the keyboard is large', () => {
+ const screenHeight = 400
+ const topInset = 50
+ const topGap = 16
+ const keyboardInset = 300
+ const height = resolveBottomDrawerFillHeight({
+ screenHeight,
+ topInset,
+ keyboardInset,
+ topGap
+ })
+ const topEdge = screenHeight - keyboardInset - height
+ expect(topEdge).toBe(topInset + topGap)
+ })
+})
diff --git a/mobile/src/components/bottom-drawer-fill-height.ts b/mobile/src/components/bottom-drawer-fill-height.ts
new file mode 100644
index 00000000000..0627b33fa97
--- /dev/null
+++ b/mobile/src/components/bottom-drawer-fill-height.ts
@@ -0,0 +1,19 @@
+// Why: fill-mode sheets need a stable outer height so docked chrome (e.g. the
+// smart-source TextInput) does not ride result-list reflow. Height shrinks by
+// the keyboard inset; the sheet is also lifted with marginBottom equal to that
+// inset so the bottom edge sits on the keyboard top (height shrink alone still
+// leaves the dock in the keyboard footprint).
+
+export function resolveBottomDrawerFillHeight(input: {
+ screenHeight: number
+ topInset: number
+ keyboardInset: number
+ topGap?: number
+}): number {
+ const topGap = input.topGap ?? 16
+ const keyboardInset = Math.max(0, input.keyboardInset)
+ // Never exceed the space under the status-bar gap and above the keyboard —
+ // a hard minHeight here would grow the sheet upward under the status bar
+ // while marginBottom still equals the full keyboard inset.
+ return Math.max(0, input.screenHeight - input.topInset - topGap - keyboardInset)
+}
diff --git a/mobile/src/components/bottom-drawer-keyboard-inset.test.ts b/mobile/src/components/bottom-drawer-keyboard-inset.test.ts
new file mode 100644
index 00000000000..2b8ba36d972
--- /dev/null
+++ b/mobile/src/components/bottom-drawer-keyboard-inset.test.ts
@@ -0,0 +1,57 @@
+import { describe, expect, it } from 'vitest'
+import { resolveBottomDrawerKeyboardInset } from './bottom-drawer-keyboard-inset'
+
+describe('resolveBottomDrawerKeyboardInset', () => {
+ it('uses the full keyboard frame for fill sheets on iOS and Android', () => {
+ expect(
+ resolveBottomDrawerKeyboardInset({
+ keyboardHeight: 336,
+ bottomInset: 34,
+ fillAvailable: true,
+ platform: 'ios'
+ })
+ ).toBe(336)
+ expect(
+ resolveBottomDrawerKeyboardInset({
+ keyboardHeight: 300,
+ bottomInset: 48,
+ fillAvailable: true,
+ platform: 'android'
+ })
+ ).toBe(300)
+ })
+
+ it('subtracts the home-indicator inset only for iOS content-sized sheets', () => {
+ expect(
+ resolveBottomDrawerKeyboardInset({
+ keyboardHeight: 336,
+ bottomInset: 34,
+ fillAvailable: false,
+ platform: 'ios'
+ })
+ ).toBe(302)
+ })
+
+ it('uses the full IME height for Android content-sized sheets', () => {
+ // Why: Android keyboard height does not include the nav bar (session terminal lift).
+ expect(
+ resolveBottomDrawerKeyboardInset({
+ keyboardHeight: 300,
+ bottomInset: 48,
+ fillAvailable: false,
+ platform: 'android'
+ })
+ ).toBe(300)
+ })
+
+ it('never returns a negative inset', () => {
+ expect(
+ resolveBottomDrawerKeyboardInset({
+ keyboardHeight: 20,
+ bottomInset: 34,
+ fillAvailable: false,
+ platform: 'ios'
+ })
+ ).toBe(0)
+ })
+})
diff --git a/mobile/src/components/bottom-drawer-keyboard-inset.ts b/mobile/src/components/bottom-drawer-keyboard-inset.ts
new file mode 100644
index 00000000000..618d07bd196
--- /dev/null
+++ b/mobile/src/components/bottom-drawer-keyboard-inset.ts
@@ -0,0 +1,26 @@
+// Why: iOS keyboard frame height includes the home-indicator region; Android
+// IME height does not include the system nav bar. That split is already used
+// by the session terminal keyboard lift — keep fill/content-sized drawers on
+// the same contract so OEM/Android and iPhone behave consistently.
+//
+// Fill sheets dock chrome to the *true keyboard top* via marginBottom + height
+// shrink. They always use the raw frame height (subtracting safe-bottom on iOS
+// parks the TextInput under the keys). Content-sized sheets keep the legacy
+// translate path: iOS subtracts safe-bottom (padding already covers it),
+// Android uses the full IME height.
+
+export function resolveBottomDrawerKeyboardInset(input: {
+ keyboardHeight: number
+ bottomInset: number
+ fillAvailable: boolean
+ platform: 'ios' | 'android' | 'windows' | 'macos' | 'web'
+}): number {
+ const keyboardHeight = Math.max(0, input.keyboardHeight)
+ if (input.fillAvailable) {
+ return keyboardHeight
+ }
+ if (input.platform === 'ios') {
+ return Math.max(0, keyboardHeight - Math.max(0, input.bottomInset))
+ }
+ return keyboardHeight
+}
diff --git a/mobile/src/components/bottom-drawer-styles.ts b/mobile/src/components/bottom-drawer-styles.ts
new file mode 100644
index 00000000000..ce74ac5a50e
--- /dev/null
+++ b/mobile/src/components/bottom-drawer-styles.ts
@@ -0,0 +1,74 @@
+import { Platform, StyleSheet } from 'react-native'
+import { colors, spacing } from '../theme/mobile-theme'
+
+export const bottomDrawerStyles = StyleSheet.create({
+ overlay: {
+ ...StyleSheet.absoluteFillObject,
+ zIndex: 1000
+ },
+ root: {
+ flex: 1
+ },
+ backdrop: {
+ ...StyleSheet.absoluteFillObject,
+ backgroundColor: 'rgba(0,0,0,0.5)'
+ },
+ backdropPressable: {
+ ...StyleSheet.absoluteFillObject
+ },
+ anchor: {
+ flex: 1,
+ justifyContent: 'flex-end'
+ },
+ anchorWide: {
+ alignItems: 'center'
+ },
+ drawer: {
+ backgroundColor: colors.bgBase,
+ borderTopLeftRadius: 16,
+ borderTopRightRadius: 16,
+ paddingHorizontal: spacing.md,
+ ...Platform.select({
+ ios: {
+ shadowColor: '#000',
+ shadowOffset: { width: 0, height: -2 },
+ shadowOpacity: 0.2,
+ shadowRadius: 10
+ },
+ android: { elevation: 8 }
+ })
+ },
+ drawerFill: {
+ // Why: flex children (results + dock) need a column height budget; without
+ // this, fill height alone still leaves staticContent height content-sized.
+ overflow: 'hidden',
+ flexDirection: 'column'
+ },
+ handle: {
+ alignSelf: 'center',
+ width: 36,
+ height: 4,
+ borderRadius: 2,
+ backgroundColor: colors.textMuted,
+ opacity: 0.4
+ },
+ handleHitArea: {
+ alignItems: 'center',
+ paddingTop: spacing.sm,
+ paddingBottom: spacing.md
+ },
+ staticContent: {
+ minHeight: 0
+ },
+ staticContentFill: {
+ flex: 1
+ },
+ bottomExtension: {
+ position: 'absolute',
+ bottom: -500,
+ left: 0,
+ right: 0,
+ height: 500,
+ backgroundColor: colors.bgBase
+ }
+})
diff --git a/mobile/src/components/mounted-bottom-drawer.tsx b/mobile/src/components/mounted-bottom-drawer.tsx
new file mode 100644
index 00000000000..3331d7298d8
--- /dev/null
+++ b/mobile/src/components/mounted-bottom-drawer.tsx
@@ -0,0 +1,400 @@
+import { type ReactNode, useCallback, useEffect, useState } from 'react'
+import {
+ View,
+ Pressable,
+ useWindowDimensions,
+ ScrollView,
+ Keyboard,
+ BackHandler,
+ Modal,
+ Platform
+} from 'react-native'
+import { useSafeAreaInsets } from 'react-native-safe-area-context'
+import { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler'
+import Animated, {
+ useSharedValue,
+ useAnimatedStyle,
+ useAnimatedScrollHandler,
+ withSpring,
+ withTiming,
+ runOnJS,
+ interpolate,
+ Extrapolation
+} from 'react-native-reanimated'
+import { spacing } from '../theme/mobile-theme'
+import { resolveBottomDrawerFillHeight } from './bottom-drawer-fill-height'
+import { resolveBottomDrawerKeyboardInset } from './bottom-drawer-keyboard-inset'
+import { BOTTOM_DRAWER_HIDE_DURATION_MS } from './bottom-drawer-constants'
+import { bottomDrawerStyles as styles } from './bottom-drawer-styles'
+import { useInsideBottomDrawerModalHost } from './bottom-drawer-modal-host'
+import { useResponsiveLayout } from '../layout/responsive-layout'
+
+const DISMISS_THRESHOLD = 80
+const SPRING_CONFIG = { damping: 28, stiffness: 400 }
+// Why: negative translateY (pulling up) is damped with a rubber-band factor
+// so the drawer resists upward dragging — a subtle polish touch that signals
+// the drawer cannot expand further.
+const RUBBER_BAND_FACTOR = 0.25
+const SHOW_DURATION = 180
+const TOP_SCROLL_EPSILON = 1
+
+export type MountedBottomDrawerProps = {
+ visible: boolean
+ onClose: () => void
+ onHidden: () => void
+ children: ReactNode
+ dragContentToDismiss?: boolean
+ contentScrollable?: boolean
+ fillAvailable?: boolean
+ // Why: outer sheets pinned under an inner fill picker stay laid out (size
+ // preserved) but must not take touches, stack backdrops, or keyboard-lift.
+ interactive?: boolean
+ zIndex?: number
+}
+
+export function MountedBottomDrawer({
+ visible,
+ onClose,
+ onHidden,
+ children,
+ dragContentToDismiss = true,
+ contentScrollable = true,
+ fillAvailable = false,
+ interactive = true,
+ zIndex = 1000
+}: MountedBottomDrawerProps) {
+ const translateY = useSharedValue(0)
+ const progress = useSharedValue(0)
+ const keyboardOffset = useSharedValue(0)
+ const scrollOffsetY = useSharedValue(0)
+ const contentDragStartY = useSharedValue(0)
+ const contentDragCanDismiss = useSharedValue(false)
+ // Why: fill mode needs the keyboard inset in React layout (not only the
+ // reanimated translate) so height shrinks as the sheet lifts and the top
+ // edge stays under the status bar.
+ const [keyboardInset, setKeyboardInset] = useState(0)
+ const { height: screenHeight } = useWindowDimensions()
+ const insets = useSafeAreaInsets()
+ // Why: on wide/tablet canvases a full-width sheet looks stretched; cap it and
+ // center it horizontally. Vertical bottom-anchoring (and all the drag/keyboard
+ // transforms below) is unchanged, so phone behavior stays identical.
+ const { isWideLayout, modalMaxWidth } = useResponsiveLayout()
+ const insideModalHost = useInsideBottomDrawerModalHost()
+ const fillHeight = fillAvailable
+ ? resolveBottomDrawerFillHeight({
+ screenHeight,
+ topInset: insets.top,
+ keyboardInset,
+ topGap: spacing.lg
+ })
+ : undefined
+
+ useEffect(() => {
+ if (visible) {
+ translateY.value = 0
+ scrollOffsetY.value = 0
+ progress.value = withTiming(1, { duration: SHOW_DURATION })
+ } else {
+ Keyboard.dismiss()
+ setKeyboardInset(0)
+ progress.value = withTiming(0, { duration: BOTTOM_DRAWER_HIDE_DURATION_MS }, (finished) => {
+ if (finished) {
+ runOnJS(onHidden)()
+ }
+ })
+ }
+ }, [onHidden, visible])
+
+ // Why: KeyboardAvoidingView and useAnimatedKeyboard are both unreliable
+ // inside Modal (iOS ignores KAV; Android needs adjustNothing for
+ // useAnimatedKeyboard). Keyboard event listeners work on both platforms
+ // and give us the exact height to shift the drawer by.
+ useEffect(() => {
+ // Pinned-under sheets stay visible for size but must not ride the keyboard —
+ // only the top interactive sheet owns inset/lift.
+ if (!visible || !interactive) {
+ keyboardOffset.value = 0
+ setKeyboardInset(0)
+ return
+ }
+
+ function applyKeyboardHeight(keyboardHeight: number, duration = 0): void {
+ const inset = resolveBottomDrawerKeyboardInset({
+ keyboardHeight,
+ bottomInset: insets.bottom,
+ fillAvailable,
+ platform: Platform.OS
+ })
+ setKeyboardInset(inset)
+ if (duration > 0) {
+ keyboardOffset.value = withTiming(inset, { duration })
+ } else {
+ keyboardOffset.value = inset
+ }
+ }
+
+ // Why: fill sheets dock to the true keyboard top; autoFocus can raise the
+ // keyboard before listeners attach. Seed only in fill mode so content-sized
+ // outer sheets do not inherit a stale metrics height after an inner dismiss.
+ if (fillAvailable) {
+ const existing = Keyboard.metrics()
+ if (existing != null && existing.height > 0) {
+ applyKeyboardHeight(existing.height)
+ }
+ }
+
+ const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'
+ const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide'
+
+ const onShow = Keyboard.addListener(showEvent, (e) => {
+ applyKeyboardHeight(e.endCoordinates.height, e.duration || 250)
+ })
+ const onHide = Keyboard.addListener(hideEvent, (e) => {
+ setKeyboardInset(0)
+ keyboardOffset.value = withTiming(0, { duration: e.duration || 250 })
+ })
+
+ return () => {
+ onShow.remove()
+ onHide.remove()
+ keyboardOffset.value = 0
+ setKeyboardInset(0)
+ }
+ }, [visible, interactive, insets.bottom, fillAvailable])
+
+ const dismiss = useCallback(() => {
+ Keyboard.dismiss()
+ progress.value = withTiming(0, { duration: BOTTOM_DRAWER_HIDE_DURATION_MS }, (finished) => {
+ if (finished) {
+ runOnJS(onClose)()
+ }
+ })
+ }, [onClose, progress])
+
+ useEffect(() => {
+ if (!visible || !interactive) {
+ return
+ }
+
+ const sub = BackHandler.addEventListener('hardwareBackPress', () => {
+ dismiss()
+ return true
+ })
+ return () => sub.remove()
+ }, [visible, interactive, dismiss])
+
+ const scrollHandler = useAnimatedScrollHandler((event) => {
+ scrollOffsetY.value = Math.max(event.contentOffset.y, 0)
+ })
+
+ const scrollGesture = Gesture.Native()
+ const handlePanGesture = Gesture.Pan()
+ .activeOffsetY([-8, 8])
+ .simultaneousWithExternalGesture(scrollGesture)
+ .onUpdate((e) => {
+ if (e.translationY > 0) {
+ translateY.value = e.translationY
+ } else {
+ translateY.value = e.translationY * RUBBER_BAND_FACTOR
+ }
+ })
+ .onEnd((e) => {
+ if (e.translationY > DISMISS_THRESHOLD || e.velocityY > 500) {
+ const velocity = Math.max(e.velocityY, 800)
+ const remaining = screenHeight - e.translationY
+ const duration = Math.min(Math.max((remaining / velocity) * 1000, 120), 300)
+ translateY.value = withTiming(screenHeight, { duration })
+ progress.value = withTiming(0, { duration }, () => {
+ runOnJS(onClose)()
+ })
+ } else {
+ translateY.value = withSpring(0, SPRING_CONFIG)
+ }
+ })
+ const contentPanGesture = Gesture.Pan()
+ .activeOffsetY([-8, 8])
+ .simultaneousWithExternalGesture(scrollGesture)
+ .onBegin(() => {
+ contentDragStartY.value = 0
+ contentDragCanDismiss.value = scrollOffsetY.value <= TOP_SCROLL_EPSILON
+ })
+ .onUpdate((e) => {
+ // Why: action-sheet content can be taller than the drawer; downward drags
+ // should scroll back to the top before they start dismissing the sheet.
+ if (scrollOffsetY.value > TOP_SCROLL_EPSILON) {
+ contentDragCanDismiss.value = false
+ contentDragStartY.value = 0
+ if (translateY.value !== 0) {
+ translateY.value = withSpring(0, SPRING_CONFIG)
+ }
+ return
+ }
+
+ if (!contentDragCanDismiss.value) {
+ contentDragCanDismiss.value = true
+ contentDragStartY.value = e.translationY
+ }
+
+ const translationY = e.translationY - contentDragStartY.value
+ if (translationY > 0) {
+ translateY.value = translationY
+ } else {
+ translateY.value = translationY * RUBBER_BAND_FACTOR
+ }
+ })
+ .onEnd((e) => {
+ if (!contentDragCanDismiss.value || scrollOffsetY.value > TOP_SCROLL_EPSILON) {
+ return
+ }
+
+ const translationY = e.translationY - contentDragStartY.value
+ if (translationY > DISMISS_THRESHOLD || e.velocityY > 500) {
+ const velocity = Math.max(e.velocityY, 800)
+ const remaining = screenHeight - translationY
+ const duration = Math.min(Math.max((remaining / velocity) * 1000, 120), 300)
+ translateY.value = withTiming(screenHeight, { duration })
+ progress.value = withTiming(0, { duration }, () => {
+ runOnJS(onClose)()
+ })
+ } else {
+ translateY.value = withSpring(0, SPRING_CONFIG)
+ }
+ })
+
+ const drawerStyle = useAnimatedStyle(() => {
+ // Why: fill mode already shrinks height by the keyboard inset and lifts via
+ // marginBottom (layout). Also subtracting keyboardOffset here would double-
+ // count and park the dock under the keys (input hidden).
+ const keyboardShift = fillAvailable ? 0 : keyboardOffset.value
+ return {
+ transform: [
+ {
+ translateY:
+ interpolate(progress.value, [0, 1], [screenHeight, 0], Extrapolation.CLAMP) +
+ translateY.value -
+ keyboardShift
+ }
+ ]
+ }
+ })
+
+ const backdropStyle = useAnimatedStyle(() => {
+ const dragFade = interpolate(translateY.value, [0, 300], [1, 0], Extrapolation.CLAMP)
+ return { opacity: progress.value * dragFade }
+ })
+
+ // Why: the sheet renders through a full-screen native window (its own Modal
+ // below, or the shared BottomDrawerModalHost) so it always covers the viewport
+ // — even when mounted deep inside a ScrollView, where a plain absolute overlay
+ // anchors to the scrolled content and clips the sheet. Show/hide is driven by
+ // `progress` (animationType "none") so the reanimated exit animation runs before
+ // the parent unmounts us.
+ const handle = (
+
+
+
+
+
+ )
+
+ const body = !contentScrollable ? (
+ <>
+ {handle}
+
+ {children}
+
+ >
+ ) : dragContentToDismiss ? (
+ <>
+ {handle}
+
+
+
+
+ {children}
+
+
+
+
+ >
+ ) : (
+ <>
+ {handle}
+
+ {children}
+
+ >
+ )
+
+ const overlay = (
+
+
+
+ {interactive ? : null}
+
+
+
+ 0 ? spacing.sm : insets.bottom + spacing.lg
+ },
+ drawerStyle
+ ]}
+ >
+ {body}
+
+
+
+
+
+ )
+
+ // Why: inside a BottomDrawerModalHost the host owns the single native Modal;
+ // rendering our own would stack modals and reintroduce the iOS present/dismiss
+ // race the host exists to avoid. The host handles the Android back button.
+ if (insideModalHost) {
+ return overlay
+ }
+
+ return (
+
+ {overlay}
+
+ )
+}
diff --git a/mobile/src/components/new-worktree-form-sheet-visibility.test.ts b/mobile/src/components/new-worktree-form-sheet-visibility.test.ts
new file mode 100644
index 00000000000..450097b74b0
--- /dev/null
+++ b/mobile/src/components/new-worktree-form-sheet-visibility.test.ts
@@ -0,0 +1,38 @@
+import { describe, expect, it } from 'vitest'
+import { resolveNewWorktreeFormSheetVisible } from './new-worktree-form-sheet-visibility'
+
+describe('resolveNewWorktreeFormSheetVisible', () => {
+ it('keeps the form under the source picker and its close transition', () => {
+ expect(
+ resolveNewWorktreeFormSheetVisible({
+ modalVisible: true,
+ drawerView: 'source',
+ formPinnedUnderSource: true
+ })
+ ).toBe(true)
+ expect(
+ resolveNewWorktreeFormSheetVisible({
+ modalVisible: true,
+ drawerView: 'transition',
+ formPinnedUnderSource: true
+ })
+ ).toBe(true)
+ })
+
+ it('hides the form for sequential repo/agent transitions', () => {
+ expect(
+ resolveNewWorktreeFormSheetVisible({
+ modalVisible: true,
+ drawerView: 'transition',
+ formPinnedUnderSource: false
+ })
+ ).toBe(false)
+ expect(
+ resolveNewWorktreeFormSheetVisible({
+ modalVisible: true,
+ drawerView: 'repo',
+ formPinnedUnderSource: false
+ })
+ ).toBe(false)
+ })
+})
diff --git a/mobile/src/components/new-worktree-form-sheet-visibility.ts b/mobile/src/components/new-worktree-form-sheet-visibility.ts
new file mode 100644
index 00000000000..57ad63c0ef6
--- /dev/null
+++ b/mobile/src/components/new-worktree-form-sheet-visibility.ts
@@ -0,0 +1,16 @@
+// Why: pin the create form under the fill-height name picker (and during that
+// picker's close transition) so dismiss reveals the original content height.
+
+export function resolveNewWorktreeFormSheetVisible(input: {
+ modalVisible: boolean
+ drawerView: string
+ formPinnedUnderSource: boolean
+}): boolean {
+ if (!input.modalVisible) {
+ return false
+ }
+ if (input.drawerView === 'form' || input.drawerView === 'source') {
+ return true
+ }
+ return input.drawerView === 'transition' && input.formPinnedUnderSource
+}
diff --git a/mobile/src/components/pr-sidebar/CommentMarkdown.tsx b/mobile/src/components/pr-sidebar/CommentMarkdown.tsx
index 0fcc49008c2..236f653f81b 100644
--- a/mobile/src/components/pr-sidebar/CommentMarkdown.tsx
+++ b/mobile/src/components/pr-sidebar/CommentMarkdown.tsx
@@ -167,7 +167,10 @@ function TableBlock({
block: Extract
base: number
}) {
- const columnCount = Math.max(block.headers.length, ...block.rows.map((r) => r.length), 1)
+ let columnCount = Math.max(block.headers.length, 1)
+ for (const row of block.rows) {
+ columnCount = Math.max(columnCount, row.length)
+ }
const columns = Array.from({ length: columnCount }, (_, c) => c)
return (
{
it('classifies headings, fenced code, quotes, lists, hr, and paragraphs', () => {
@@ -100,6 +100,42 @@ describe('parseMarkdownBlocks tables', () => {
])
})
+ it('accepts the exact table column limit and falls back one column later', () => {
+ const header = Array.from({ length: COMMENT_MARKDOWN_LIMITS.tableColumns }, (_, index) =>
+ String(index)
+ ).join('|')
+ const delimiter = Array.from(
+ { length: COMMENT_MARKDOWN_LIMITS.tableColumns },
+ () => '---'
+ ).join('|')
+
+ expect(parseMarkdownBlocks(`${header}\n${delimiter}`)[0]).toMatchObject({
+ kind: 'table',
+ headers: { length: COMMENT_MARKDOWN_LIMITS.tableColumns }
+ })
+ expect(parseMarkdownBlocks(`${header}|extra\n${delimiter}|---`)[0]).toMatchObject({
+ kind: 'paragraph'
+ })
+ })
+
+ it('falls back to a bounded preview before splitting oversized input', () => {
+ const exact = 'x'.repeat(COMMENT_MARKDOWN_LIMITS.sourceCodeUnits)
+ const content = 'x'.repeat(COMMENT_MARKDOWN_LIMITS.sourceCodeUnits + 1)
+ const [block] = parseMarkdownBlocks(content)
+
+ expect(parseMarkdownBlocks(exact)).toEqual([{ kind: 'paragraph', text: exact }])
+ expect(block).toMatchObject({ kind: 'paragraph' })
+ expect(block?.kind === 'paragraph' ? block.text.length : 0).toBeLessThan(
+ COMMENT_MARKDOWN_LIMITS.fallbackCodeUnits + 100
+ )
+ })
+
+ it('checks normalized break tags before allocating line arrays', () => {
+ const content = ' '.repeat(COMMENT_MARKDOWN_LIMITS.lines + 1)
+
+ expect(parseMarkdownBlocks(content)[0]).toMatchObject({ kind: 'paragraph' })
+ })
+
it('reads per-column alignment from the delimiter row', () => {
const md = ['| L | C | R |', '| :--- | :---: | ---: |', '| a | b | c |'].join('\n')
const block = parseMarkdownBlocks(md)[0]
diff --git a/mobile/src/components/pr-sidebar/markdown-blocks.ts b/mobile/src/components/pr-sidebar/markdown-blocks.ts
index d5866585b68..dd597383914 100644
--- a/mobile/src/components/pr-sidebar/markdown-blocks.ts
+++ b/mobile/src/components/pr-sidebar/markdown-blocks.ts
@@ -40,6 +40,20 @@ const ORDERED = /^\s*\d+[.)]\s+(.*)$/
const HTML_BLOCK = /<(details|blockquote)\b[^>]*>([\s\S]*?)<\/\1>/i
const SUMMARY = /]*>([\s\S]*?)<\/summary>/i
+export const COMMENT_MARKDOWN_LIMITS = {
+ sourceCodeUnits: 1024 * 1024,
+ lines: 10_000,
+ detailsDepth: 64,
+ tableColumns: 256,
+ tableRows: 2000,
+ tableCells: 50_000,
+ fallbackCodeUnits: 64 * 1024
+} as const
+
+const LIMIT_FALLBACK_SUFFIX = '\n\n[Comment preview truncated for safety]'
+
+class CommentMarkdownLimitError extends Error {}
+
// Removes residual HTML tags from rendered text so stray // etc. don't
// show literally. Conservative: only matches `` / ` ` shapes, so a bare
// "a < b" in prose is left alone.
@@ -48,9 +62,56 @@ export function stripHtmlTags(text: string): string {
}
export function parseMarkdownBlocks(content: string): MarkdownBlock[] {
+ if (!isCommentMarkdownWithinPreparseLimits(content)) {
+ return commentMarkdownLimitFallback(content)
+ }
// Drop HTML comments and normalize before block parsing.
const cleaned = content.replace(//g, '').replace(/ /gi, '\n')
- return parseSegment(cleaned)
+ if (!isCommentMarkdownWithinPreparseLimits(cleaned)) {
+ return commentMarkdownLimitFallback(content)
+ }
+ try {
+ return parseSegment(cleaned)
+ } catch (error) {
+ if (error instanceof CommentMarkdownLimitError) {
+ return commentMarkdownLimitFallback(content)
+ }
+ throw error
+ }
+}
+
+function isCommentMarkdownWithinPreparseLimits(content: string): boolean {
+ if (content.length > COMMENT_MARKDOWN_LIMITS.sourceCodeUnits) {
+ return false
+ }
+ let lines = 1
+ let detailsDepth = 0
+ const detailsPattern = /<\/?details\b/gi
+ for (let index = 0; index < content.length; index += 1) {
+ if (content.charCodeAt(index) !== 10) {
+ continue
+ }
+ lines += 1
+ if (lines > COMMENT_MARKDOWN_LIMITS.lines) {
+ return false
+ }
+ }
+ for (const match of content.matchAll(detailsPattern)) {
+ if (match[0][1] === '/') {
+ detailsDepth = Math.max(0, detailsDepth - 1)
+ continue
+ }
+ detailsDepth += 1
+ if (detailsDepth > COMMENT_MARKDOWN_LIMITS.detailsDepth) {
+ return false
+ }
+ }
+ return true
+}
+
+function commentMarkdownLimitFallback(content: string): MarkdownBlock[] {
+ const preview = content.slice(0, COMMENT_MARKDOWN_LIMITS.fallbackCodeUnits)
+ return [{ kind: 'paragraph', text: `${preview}${LIMIT_FALLBACK_SUFFIX}` }]
}
// Splits a segment at top-level / regions (preserving order),
@@ -120,8 +181,17 @@ function parseLines(content: string): MarkdownBlock[] {
const align = parseAlignRow(lines[i + 1])
i += 2
const rows: string[][] = []
+ let tableCells = headers.length
while (i < lines.length && lines[i].includes('|') && lines[i].trim() !== '') {
- rows.push(splitTableRow(lines[i]))
+ if (rows.length >= COMMENT_MARKDOWN_LIMITS.tableRows) {
+ throw new CommentMarkdownLimitError('Table row limit exceeded')
+ }
+ const row = splitTableRow(lines[i])
+ tableCells += row.length
+ if (tableCells > COMMENT_MARKDOWN_LIMITS.tableCells) {
+ throw new CommentMarkdownLimitError('Table cell limit exceeded')
+ }
+ rows.push(row)
i += 1
}
blocks.push({ kind: 'table', headers, rows, align })
@@ -207,12 +277,18 @@ function splitTableRow(line: string): string[] {
continue
}
if (ch === '|') {
+ if (cells.length >= COMMENT_MARKDOWN_LIMITS.tableColumns) {
+ throw new CommentMarkdownLimitError('Table column limit exceeded')
+ }
cells.push(cell.trim())
cell = ''
continue
}
cell += ch
}
+ if (cells.length >= COMMENT_MARKDOWN_LIMITS.tableColumns) {
+ throw new CommentMarkdownLimitError('Table column limit exceeded')
+ }
cells.push(cell.trim())
return cells
}
diff --git a/mobile/src/components/smart-workspace-source-drawer-styles.ts b/mobile/src/components/smart-workspace-source-drawer-styles.ts
new file mode 100644
index 00000000000..3adc4aab8cb
--- /dev/null
+++ b/mobile/src/components/smart-workspace-source-drawer-styles.ts
@@ -0,0 +1,179 @@
+import { StyleSheet } from 'react-native'
+import { colors, radii, spacing, typography } from '../theme/mobile-theme'
+
+export const smartWorkspaceSourceDrawerStyles = StyleSheet.create({
+ root: {
+ flex: 1,
+ minHeight: 0
+ },
+ header: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ paddingHorizontal: spacing.xs,
+ paddingBottom: spacing.sm,
+ flexShrink: 0
+ },
+ title: {
+ fontSize: 15,
+ fontWeight: '600',
+ color: colors.textPrimary
+ },
+ done: {
+ fontSize: typography.bodySize,
+ fontWeight: '600',
+ color: colors.accentBlue
+ },
+ results: {
+ flex: 1,
+ minHeight: 0
+ },
+ list: {
+ flex: 1,
+ backgroundColor: colors.bgPanel,
+ borderTopLeftRadius: radii.card,
+ borderTopRightRadius: radii.card,
+ overflow: 'hidden'
+ },
+ listContent: {
+ flexGrow: 1,
+ paddingBottom: spacing.sm
+ },
+ // Why: pin the dock to the sheet bottom so a flex-greedy FlatList cannot
+ // push the TextInput out of the fill frame (and under the keyboard).
+ dock: {
+ flexShrink: 0,
+ borderTopWidth: StyleSheet.hairlineWidth,
+ borderTopColor: colors.borderSubtle,
+ backgroundColor: colors.bgBase,
+ paddingTop: spacing.sm,
+ paddingBottom: spacing.sm,
+ gap: spacing.sm,
+ zIndex: 2
+ },
+ search: {
+ backgroundColor: colors.bgRaised,
+ color: colors.textPrimary,
+ borderRadius: radii.input,
+ paddingHorizontal: spacing.md,
+ paddingVertical: spacing.sm + 2,
+ fontSize: typography.bodySize,
+ borderWidth: 1,
+ borderColor: colors.borderSubtle
+ },
+ tabRow: {
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ gap: spacing.xs
+ },
+ tab: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: spacing.xs,
+ paddingHorizontal: spacing.sm + 2,
+ paddingVertical: spacing.xs + 2,
+ borderRadius: radii.button,
+ borderWidth: 1,
+ borderColor: colors.borderSubtle
+ },
+ tabSelected: {
+ backgroundColor: colors.bgPanel,
+ borderColor: colors.textSecondary
+ },
+ tabText: {
+ fontSize: 13,
+ color: colors.textSecondary
+ },
+ tabTextSelected: {
+ color: colors.textPrimary,
+ fontWeight: '600'
+ },
+ chipRow: {
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ gap: spacing.xs
+ },
+ chip: {
+ paddingHorizontal: spacing.md,
+ paddingVertical: spacing.xs,
+ borderRadius: radii.button,
+ borderWidth: 1,
+ borderColor: colors.borderSubtle
+ },
+ chipSelected: {
+ backgroundColor: colors.bgPanel,
+ borderColor: colors.textSecondary
+ },
+ chipText: {
+ fontSize: 12,
+ color: colors.textSecondary
+ },
+ chipTextSelected: {
+ color: colors.textPrimary,
+ fontWeight: '600'
+ },
+ crossRepo: {
+ backgroundColor: colors.bgRaised,
+ borderRadius: radii.input,
+ borderWidth: 1,
+ borderColor: colors.borderSubtle,
+ padding: spacing.md,
+ marginBottom: spacing.sm,
+ gap: spacing.sm
+ },
+ crossRepoText: {
+ fontSize: 13,
+ color: colors.textSecondary
+ },
+ crossRepoActions: {
+ flexDirection: 'row',
+ justifyContent: 'flex-end',
+ gap: spacing.sm
+ },
+ crossRepoDismiss: {
+ paddingHorizontal: spacing.md,
+ paddingVertical: spacing.xs + 2,
+ borderRadius: radii.button,
+ borderWidth: 1,
+ borderColor: colors.borderSubtle
+ },
+ crossRepoDismissText: {
+ fontSize: 13,
+ color: colors.textSecondary
+ },
+ crossRepoSwitch: {
+ paddingHorizontal: spacing.md,
+ paddingVertical: spacing.xs + 2,
+ borderRadius: radii.button,
+ backgroundColor: colors.bgPanel,
+ borderWidth: 1,
+ borderColor: colors.textSecondary
+ },
+ crossRepoSwitchText: {
+ fontSize: 13,
+ fontWeight: '600',
+ color: colors.textPrimary
+ },
+ notice: {
+ fontSize: 12,
+ color: colors.textMuted,
+ paddingHorizontal: spacing.xs,
+ paddingBottom: spacing.sm
+ },
+ errorNotice: {
+ fontSize: 12,
+ color: colors.statusRed,
+ paddingHorizontal: spacing.xs,
+ paddingBottom: spacing.sm
+ },
+ loading: {
+ paddingVertical: spacing.lg,
+ alignItems: 'center'
+ },
+ empty: {
+ paddingVertical: spacing.lg,
+ textAlign: 'center',
+ color: colors.textMuted,
+ fontSize: 13
+ }
+})
diff --git a/mobile/src/components/use-new-worktree-drawer-navigation.ts b/mobile/src/components/use-new-worktree-drawer-navigation.ts
new file mode 100644
index 00000000000..c21a6dee4f1
--- /dev/null
+++ b/mobile/src/components/use-new-worktree-drawer-navigation.ts
@@ -0,0 +1,80 @@
+import { useEffect, useRef, useState } from 'react'
+import { BOTTOM_DRAWER_HIDE_DURATION_MS } from './bottom-drawer-constants'
+import { resolveNewWorktreeFormSheetVisible } from './new-worktree-form-sheet-visibility'
+
+export type NewWorktreeDrawerView = 'form' | 'transition' | 'source' | 'repo' | 'agent' | 'trust'
+
+// Why: iOS cannot reliably present a second native modal until the first drawer's
+// exit commits; one extra frame keeps transitions sequential on slower devices.
+const NEW_WORKTREE_DRAWER_TRANSITION_MS = BOTTOM_DRAWER_HIDE_DURATION_MS + 16
+
+export function useNewWorktreeDrawerNavigation(modalVisible: boolean): {
+ drawerView: NewWorktreeDrawerView
+ formSheetVisible: boolean
+ formSheetInteractive: boolean
+ transitionDrawer: (nextView: Exclude) => void
+ openSourceDrawer: () => void
+} {
+ const [drawerView, setDrawerView] = useState('form')
+ const formPinnedUnderSourceRef = useRef(false)
+ const drawerTransitionTimerRef = useRef | null>(null)
+
+ // Why: cancel any queued transition and reset when the modal closes, so a
+ // timer can't land after close and leave a stale drawer/pin for the next open.
+ useEffect(() => {
+ if (modalVisible) {
+ return
+ }
+ if (drawerTransitionTimerRef.current) {
+ clearTimeout(drawerTransitionTimerRef.current)
+ drawerTransitionTimerRef.current = null
+ }
+ formPinnedUnderSourceRef.current = false
+ setDrawerView('form')
+ }, [modalVisible])
+
+ useEffect(() => {
+ return () => {
+ if (drawerTransitionTimerRef.current) {
+ clearTimeout(drawerTransitionTimerRef.current)
+ }
+ }
+ }, [])
+
+ function transitionDrawer(nextView: Exclude): void {
+ if (drawerTransitionTimerRef.current) {
+ clearTimeout(drawerTransitionTimerRef.current)
+ }
+ setDrawerView('transition')
+ drawerTransitionTimerRef.current = setTimeout(() => {
+ drawerTransitionTimerRef.current = null
+ if (nextView === 'form') {
+ formPinnedUnderSourceRef.current = false
+ }
+ setDrawerView(nextView)
+ }, NEW_WORKTREE_DRAWER_TRANSITION_MS)
+ }
+
+ function openSourceDrawer(): void {
+ // Why: same-beat open; pin form under fill picker so outer content height
+ // is preserved when the name dialog dismisses.
+ if (drawerTransitionTimerRef.current) {
+ clearTimeout(drawerTransitionTimerRef.current)
+ }
+ drawerTransitionTimerRef.current = null
+ formPinnedUnderSourceRef.current = true
+ setDrawerView('source')
+ }
+
+ return {
+ drawerView,
+ formSheetVisible: resolveNewWorktreeFormSheetVisible({
+ modalVisible,
+ drawerView,
+ formPinnedUnderSource: formPinnedUnderSourceRef.current
+ }),
+ formSheetInteractive: drawerView === 'form',
+ transitionDrawer,
+ openSourceDrawer
+ }
+}
diff --git a/mobile/src/diagnostics/troubleshoot-common-issues.tsx b/mobile/src/diagnostics/troubleshoot-common-issues.tsx
index 5dc91eb65fc..b794ad004d5 100644
--- a/mobile/src/diagnostics/troubleshoot-common-issues.tsx
+++ b/mobile/src/diagnostics/troubleshoot-common-issues.tsx
@@ -14,7 +14,7 @@ export const troubleshootCommonIssues: TroubleshootSection[] = [
icon: ,
title: 'Different WiFi Networks',
steps: [
- 'Both devices must be on the same local network (unless connected through Tailscale).',
+ 'Both devices must be on the same LAN (unless connected through Tailscale).',
'Ethernet and WiFi must share the same subnet.',
'Try reconnecting WiFi on both devices.'
]
diff --git a/mobile/src/dictation/dictation-setup-poll-controller.test.ts b/mobile/src/dictation/dictation-setup-poll-controller.test.ts
index 5ca010759d6..c1eacd0c999 100644
--- a/mobile/src/dictation/dictation-setup-poll-controller.test.ts
+++ b/mobile/src/dictation/dictation-setup-poll-controller.test.ts
@@ -132,6 +132,27 @@ describe('DictationSetupPollController', () => {
poller.dispose()
})
+ it('shares one completion while repeated manual refreshes are pending', async () => {
+ const requests = [deferred(), deferred()]
+ const refresh = vi.fn(() => requests[refresh.mock.calls.length - 1].promise)
+ const poller = new DictationSetupPollController(refresh, POLL_INTERVAL_MS)
+ poller.setPolling(true)
+ poller.setVisible(true)
+ poller.setForeground(true)
+
+ const completions = Array.from({ length: 1_000 }, () => poller.refreshNow())
+ expect(new Set(completions).size).toBe(1)
+ expect(refresh).toHaveBeenCalledOnce()
+
+ requests[0].resolve(true)
+ await flushPromises()
+ expect(refresh).toHaveBeenCalledTimes(2)
+
+ requests[1].resolve(false)
+ await expect(completions[0]).resolves.toBeUndefined()
+ poller.dispose()
+ })
+
it('refreshes immediately when visibility or foreground eligibility resumes', async () => {
const refresh = vi.fn().mockResolvedValue(true)
const poller = new DictationSetupPollController(refresh, POLL_INTERVAL_MS)
diff --git a/mobile/src/dictation/dictation-setup-poll-controller.ts b/mobile/src/dictation/dictation-setup-poll-controller.ts
index cb927f161f9..de74fdb4f8e 100644
--- a/mobile/src/dictation/dictation-setup-poll-controller.ts
+++ b/mobile/src/dictation/dictation-setup-poll-controller.ts
@@ -11,7 +11,7 @@ export class DictationSetupPollController {
private timer: ReturnType | null = null
private inFlight = false
private immediateRefreshPending = false
- private refreshWaiters: Array<() => void> = []
+ private refreshCompletion: { promise: Promise; resolve: () => void } | null = null
private disposed = false
// Why: an explicit setPolling is a newer lifecycle intent than a read that was already on the wire.
// Bumped on every setPolling so an in-flight refresh resolving after an explicit stop/start can be
@@ -40,17 +40,22 @@ export class DictationSetupPollController {
if (this.disposed || !this.isEligible()) {
return Promise.resolve()
}
- return new Promise((resolve) => {
- this.refreshWaiters.push(resolve)
- this.requestRefresh(true)
- })
+ if (!this.refreshCompletion) {
+ let resolve!: () => void
+ const promise = new Promise((nextResolve) => {
+ resolve = nextResolve
+ })
+ this.refreshCompletion = { promise, resolve }
+ }
+ this.requestRefresh(true)
+ return this.refreshCompletion.promise
}
dispose(): void {
this.disposed = true
this.immediateRefreshPending = false
this.clearTimer()
- this.resolveRefreshWaiters()
+ this.resolveRefreshCompletion()
}
private update(next: Partial): void {
@@ -113,7 +118,7 @@ export class DictationSetupPollController {
this.state.polling = shouldContinue
}
if (this.disposed || !this.isEligible()) {
- this.resolveRefreshWaiters()
+ this.resolveRefreshCompletion()
return
}
if (this.immediateRefreshPending) {
@@ -121,7 +126,7 @@ export class DictationSetupPollController {
this.requestRefresh(true)
return
}
- this.resolveRefreshWaiters()
+ this.resolveRefreshCompletion()
if (this.state.polling) {
this.scheduleRefresh()
}
@@ -144,10 +149,9 @@ export class DictationSetupPollController {
}
}
- private resolveRefreshWaiters(): void {
- const waiters = this.refreshWaiters.splice(0)
- for (const resolve of waiters) {
- resolve()
- }
+ private resolveRefreshCompletion(): void {
+ const completion = this.refreshCompletion
+ this.refreshCompletion = null
+ completion?.resolve()
}
}
diff --git a/mobile/src/files/MobileFileExplorerPanel.test.ts b/mobile/src/files/MobileFileExplorerPanel.test.ts
index 0d003e9f456..86719d7c1cd 100644
--- a/mobile/src/files/MobileFileExplorerPanel.test.ts
+++ b/mobile/src/files/MobileFileExplorerPanel.test.ts
@@ -2,6 +2,7 @@ import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { MobileFileExplorerPanel } from './MobileFileExplorerPanel'
+import { LEGACY_MOBILE_FILE_PATH_MAX_BYTES } from './file-list-fallback'
import type { MobileDirEntry } from './file-tree'
import type { RpcResponse } from '../transport/types'
@@ -206,6 +207,18 @@ describe('MobileFileExplorerPanel', () => {
expect(client.sendRequest).toHaveBeenCalledTimes(2)
})
+ it('rejects an extreme directory listing instead of silently truncating it', async () => {
+ const client = createMockClient({
+ '': Array.from({ length: 10_001 }, (_, index) => entry(`file-${index}.txt`))
+ })
+ mockTransport.client = client
+
+ const renderer = await renderExplorer()
+
+ expect(renderedText(renderer)).toContain('This folder is too large to show safely on mobile')
+ expect(renderedText(renderer)).toContain('10,000 items')
+ })
+
it('keeps the loaded tree visible during a transient disconnect', async () => {
const client = createMockClient({
'': [entry('src', true), entry('README.md')]
@@ -366,6 +379,43 @@ describe('MobileFileExplorerPanel', () => {
expect(renderedText(renderer)).toContain('Showing first 5000')
})
+ it('reports a bounded-memory error when a legacy path exceeds the fallback cap', async () => {
+ const legacyClient: MockClient = {
+ sendRequest: vi.fn(async (method: string): Promise => {
+ if (method === 'files.readDir') {
+ return {
+ id: 'response-id',
+ ok: false,
+ error: { code: 'method_not_found', message: 'Unknown method' },
+ _meta: { runtimeId: 'runtime-id' }
+ }
+ }
+ return {
+ id: 'response-id',
+ ok: true,
+ result: {
+ files: [
+ {
+ relativePath: 'x'.repeat(LEGACY_MOBILE_FILE_PATH_MAX_BYTES + 1),
+ basename: 'oversized',
+ kind: 'text'
+ }
+ ],
+ totalCount: 1,
+ truncated: false
+ },
+ _meta: { runtimeId: 'runtime-id' }
+ }
+ })
+ }
+ mockTransport.client = legacyClient
+
+ const renderer = await renderExplorer()
+
+ expect(renderedText(renderer)).toContain('legacy file list is too large')
+ expect(renderedText(renderer)).toContain('Update Orca Desktop')
+ })
+
it('reports the files.list failure when the fallback itself fails', async () => {
const legacyClient: MockClient = {
sendRequest: vi.fn(async (method: string): Promise => {
diff --git a/mobile/src/files/MobileFileExplorerPanel.tsx b/mobile/src/files/MobileFileExplorerPanel.tsx
index 7e67bf81b5f..d34c4ffd07a 100644
--- a/mobile/src/files/MobileFileExplorerPanel.tsx
+++ b/mobile/src/files/MobileFileExplorerPanel.tsx
@@ -16,14 +16,15 @@ import {
flattenDirectoryCache,
getDirectoryCacheState,
type DirectoryCache,
- type FileExplorerRow,
- type MobileDirEntry
+ type DirectoryState,
+ type FileExplorerRow
} from './file-tree'
import type { RpcSuccess } from '../transport/types'
import { colors } from '../theme/mobile-theme'
import {
beginDirectoryLoad,
createDirectoryLoadRevisions,
+ forgetDirectoryLoadBranches,
isCurrentDirectoryLoad,
resetDirectoryLoadRevisions,
type DirectoryLoadRevisions
@@ -36,6 +37,12 @@ import {
import { fileExplorerStyles as styles } from './mobile-file-explorer-styles'
import { MobileFileExplorerRow } from './mobile-file-explorer-row'
import { navigateToMobileFilePreview } from './mobile-file-preview-navigation'
+import {
+ MOBILE_DIRECTORY_CACHE_LIMIT_MESSAGE,
+ parseBoundedMobileDirectoryEntries,
+ removeEvictedExpandedPaths,
+ retainMobileDirectoryState
+} from './mobile-directory-cache-retention'
export function MobileFileExplorerPanel(props: {
hostId: string
@@ -54,13 +61,48 @@ export function MobileFileExplorerPanel(props: {
const directoryLoadRevisionsRef = useRef(createDirectoryLoadRevisions())
const pendingDirectoryRetriesRef = useRef>(new Set())
const directoryCacheRef = useRef({})
+ const directoryCacheAccessRef = useRef(0)
const [directoryCache, setDirectoryCache] = useState({})
const [expanded, setExpanded] = useState>(() => new Set())
+ const expandedRef = useRef(expanded)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [legacyListTruncated, setLegacyListTruncated] = useState(false)
const worktreeLabel = getWorktreeLabel(name, worktreeId)
+ const commitDirectoryState = useCallback(
+ (relativePath: string, state: DirectoryState): boolean => {
+ const retained = retainMobileDirectoryState(
+ directoryCacheRef.current,
+ relativePath,
+ { ...state, lastAccess: ++directoryCacheAccessRef.current },
+ expandedRef.current
+ )
+ if (!retained.admitted) {
+ return false
+ }
+ directoryCacheRef.current = retained.cache
+ setDirectoryCache(retained.cache)
+ if (retained.evictedPaths.length > 0) {
+ forgetDirectoryLoadBranches(directoryLoadRevisionsRef.current, retained.evictedPaths)
+ for (const pendingPath of pendingDirectoryRetriesRef.current) {
+ if (
+ retained.evictedPaths.some(
+ (evicted) => pendingPath === evicted || pendingPath.startsWith(`${evicted}/`)
+ )
+ ) {
+ pendingDirectoryRetriesRef.current.delete(pendingPath)
+ }
+ }
+ const nextExpanded = removeEvictedExpandedPaths(expandedRef.current, retained.evictedPaths)
+ expandedRef.current = nextExpanded
+ setExpanded(nextExpanded)
+ }
+ return true
+ },
+ []
+ )
+
const loadDirectory = useCallback(
async (relativePath: string) => {
const scope = scopeRef.current
@@ -77,13 +119,10 @@ export function MobileFileExplorerPanel(props: {
// Why: transient reconnects should not blank an already browsable tree.
setError(hasLoadedRoot ? null : message)
} else {
- setDirectoryCache((prev) => ({
- ...prev,
- [relativePath]: {
- entries: getDirectoryCacheState(prev, relativePath)?.entries ?? [],
- error: message
- }
- }))
+ commitDirectoryState(relativePath, {
+ entries: getDirectoryCacheState(directoryCacheRef.current, relativePath)?.entries ?? [],
+ error: message
+ })
}
return
}
@@ -98,13 +137,16 @@ export function MobileFileExplorerPanel(props: {
}
setError(null)
}
- setDirectoryCache((prev) => ({
- ...prev,
- [relativePath]: {
- entries: getDirectoryCacheState(prev, relativePath)?.entries ?? [],
- loading: true
- }
- }))
+ const admittedLoading = commitDirectoryState(relativePath, {
+ entries: getDirectoryCacheState(directoryCacheRef.current, relativePath)?.entries ?? [],
+ loading: true
+ })
+ if (!admittedLoading) {
+ forgetDirectoryLoadBranches(directoryLoadRevisionsRef.current, [relativePath])
+ setLoading(false)
+ setError(MOBILE_DIRECTORY_CACHE_LIMIT_MESSAGE)
+ return
+ }
try {
const response = await client.sendRequest('files.readDir', {
@@ -133,7 +175,9 @@ export function MobileFileExplorerPanel(props: {
return
}
const legacyResult = (legacy as RpcSuccess).result as LegacyFilesListResult
- setDirectoryCache(directoryCacheFromFileList(legacyResult.files))
+ const legacyCache = directoryCacheFromFileList(legacyResult.files)
+ directoryCacheRef.current = legacyCache
+ setDirectoryCache(legacyCache)
// Why: the capped list silently omits files past the cap — keep
// the legacy explorer's "Showing first 5000" note.
setLegacyListTruncated(legacyResult.truncated)
@@ -150,14 +194,13 @@ export function MobileFileExplorerPanel(props: {
) {
return
}
- const entries = (response as RpcSuccess).result as MobileDirEntry[]
+ const entries = parseBoundedMobileDirectoryEntries((response as RpcSuccess).result)
if (rootLoad) {
setLegacyListTruncated(false)
}
- setDirectoryCache((prev) => ({
- ...prev,
- [relativePath]: { entries }
- }))
+ if (!commitDirectoryState(relativePath, { entries })) {
+ throw new Error(MOBILE_DIRECTORY_CACHE_LIMIT_MESSAGE)
+ }
} catch (err) {
if (
!isCurrentDirectoryLoad(directoryLoadRevisionsRef.current, scopeRef.current, loadToken)
@@ -170,13 +213,10 @@ export function MobileFileExplorerPanel(props: {
// only a cold load surfaces the full-screen error.
setError(hadLoadedRoot ? null : message)
} else {
- setDirectoryCache((prev) => ({
- ...prev,
- [relativePath]: {
- entries: getDirectoryCacheState(prev, relativePath)?.entries ?? [],
- error: message
- }
- }))
+ commitDirectoryState(relativePath, {
+ entries: getDirectoryCacheState(directoryCacheRef.current, relativePath)?.entries ?? [],
+ error: message
+ })
}
} finally {
if (
@@ -187,7 +227,7 @@ export function MobileFileExplorerPanel(props: {
}
}
},
- [client, connState, worktreeId]
+ [client, commitDirectoryState, connState, worktreeId]
)
useEffect(() => {
@@ -195,17 +235,15 @@ export function MobileFileExplorerPanel(props: {
resetDirectoryLoadRevisions(directoryLoadRevisionsRef.current)
pendingDirectoryRetriesRef.current.clear()
directoryCacheRef.current = {}
+ directoryCacheAccessRef.current = 0
setDirectoryCache({})
- setExpanded(new Set())
+ expandedRef.current = new Set()
+ setExpanded(expandedRef.current)
setLoading(true)
setError(null)
setLegacyListTruncated(false)
}, [scope])
- useEffect(() => {
- directoryCacheRef.current = directoryCache
- }, [directoryCache])
-
useEffect(() => {
void loadDirectory('')
}, [hostId, loadDirectory])
@@ -228,21 +266,21 @@ export function MobileFileExplorerPanel(props: {
const toggleDirectory = useCallback(
(relativePath: string) => {
- setExpanded((prev) => {
- const next = new Set(prev)
- if (next.has(relativePath)) {
- next.delete(relativePath)
- } else {
- next.add(relativePath)
- }
- return next
- })
+ const wasExpanded = expandedRef.current.has(relativePath)
+ const nextExpanded = new Set(expandedRef.current)
+ if (wasExpanded) {
+ nextExpanded.delete(relativePath)
+ } else {
+ nextExpanded.add(relativePath)
+ }
+ expandedRef.current = nextExpanded
+ setExpanded(nextExpanded)
const state = getDirectoryCacheState(directoryCache, relativePath)
- if (!expanded.has(relativePath) && !state?.loading && (!state?.entries || state.error)) {
+ if (!wasExpanded && !state?.loading && (!state?.entries || state.error)) {
void loadDirectory(relativePath)
}
},
- [directoryCache, expanded, loadDirectory]
+ [directoryCache, loadDirectory]
)
const retryDirectory = useCallback(
diff --git a/mobile/src/files/directory-load-revisions.test.ts b/mobile/src/files/directory-load-revisions.test.ts
index 86ebfc3c0e8..b36f88c7022 100644
--- a/mobile/src/files/directory-load-revisions.test.ts
+++ b/mobile/src/files/directory-load-revisions.test.ts
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import {
beginDirectoryLoad,
createDirectoryLoadRevisions,
+ forgetDirectoryLoadBranches,
isCurrentDirectoryLoad,
resetDirectoryLoadRevisions,
type DirectoryLoadRevisions
@@ -41,4 +42,17 @@ describe('directory-load-revisions', () => {
expect(isCurrentDirectoryLoad(revisions, 'host-a:worktree-a', load)).toBe(true)
})
+
+ it('invalidates evicted branches without staling unrelated loads', () => {
+ const revisions = createDirectoryLoadRevisions()
+ const src = beginDirectoryLoad(revisions, 'host-a:worktree-a', 'src')
+ const nested = beginDirectoryLoad(revisions, 'host-a:worktree-a', 'src/lib')
+ const docs = beginDirectoryLoad(revisions, 'host-a:worktree-a', 'docs')
+
+ forgetDirectoryLoadBranches(revisions, ['src'])
+
+ expect(isCurrentDirectoryLoad(revisions, 'host-a:worktree-a', src)).toBe(false)
+ expect(isCurrentDirectoryLoad(revisions, 'host-a:worktree-a', nested)).toBe(false)
+ expect(isCurrentDirectoryLoad(revisions, 'host-a:worktree-a', docs)).toBe(true)
+ })
})
diff --git a/mobile/src/files/directory-load-revisions.ts b/mobile/src/files/directory-load-revisions.ts
index 04153092bbe..584d2bc0aa9 100644
--- a/mobile/src/files/directory-load-revisions.ts
+++ b/mobile/src/files/directory-load-revisions.ts
@@ -19,6 +19,21 @@ export function resetDirectoryLoadRevisions(revisions: DirectoryLoadRevisions):
revisions.revisionsByPath.clear()
}
+export function forgetDirectoryLoadBranches(
+ revisions: DirectoryLoadRevisions,
+ relativePaths: readonly string[]
+): void {
+ for (const loadedPath of revisions.revisionsByPath.keys()) {
+ if (
+ relativePaths.some(
+ (relativePath) => loadedPath === relativePath || loadedPath.startsWith(`${relativePath}/`)
+ )
+ ) {
+ revisions.revisionsByPath.delete(loadedPath)
+ }
+ }
+}
+
export function beginDirectoryLoad(
revisions: DirectoryLoadRevisions,
scope: string,
diff --git a/mobile/src/files/file-list-fallback.test.ts b/mobile/src/files/file-list-fallback.test.ts
index a69affc5fe8..ad6fc7eacec 100644
--- a/mobile/src/files/file-list-fallback.test.ts
+++ b/mobile/src/files/file-list-fallback.test.ts
@@ -1,7 +1,34 @@
import { describe, expect, it } from 'vitest'
-import { directoryCacheFromFileList, isMobileMethodUnavailableError } from './file-list-fallback'
+import {
+ directoryCacheFromFileList,
+ isMobileMethodUnavailableError,
+ LEGACY_MOBILE_FILE_CACHE_MAX_DIRECTORIES,
+ LEGACY_MOBILE_FILE_CACHE_MAX_ENTRIES,
+ LEGACY_MOBILE_FILE_CACHE_MAX_RETAINED_BYTES,
+ LEGACY_MOBILE_FILE_LIST_LIMIT_MESSAGE,
+ LEGACY_MOBILE_FILE_LIST_MAX_FILES,
+ LEGACY_MOBILE_FILE_PATH_MAX_BYTES,
+ LEGACY_MOBILE_FILE_PATH_MAX_DEPTH,
+ type LegacyMobileFileEntry
+} from './file-list-fallback'
import { getDirectoryCacheState } from './file-tree'
+const RETAINED_NODE_ESTIMATE_BYTES = 64
+
+function file(relativePath: string): LegacyMobileFileEntry {
+ return { relativePath, basename: relativePath, kind: 'text' }
+}
+
+function directoryChainPath(prefix: string, directories: number): string {
+ const names = Array.from({ length: directories }, (_, index) => (index === 0 ? prefix : 'd'))
+ return `${names.join('/')}/file`
+}
+
+function fixedLengthUniqueName(index: number, length: number): string {
+ const suffix = `-${index.toString(36)}`
+ return `${'x'.repeat(length - suffix.length)}${suffix}`
+}
+
describe('isMobileMethodUnavailableError', () => {
it('detects old-desktop allowlist and missing-method failures', () => {
expect(isMobileMethodUnavailableError('forbidden', undefined)).toBe(true)
@@ -20,9 +47,9 @@ describe('isMobileMethodUnavailableError', () => {
describe('directoryCacheFromFileList', () => {
it('synthesizes every ancestor directory from flat paths', () => {
const cache = directoryCacheFromFileList([
- { relativePath: 'src/lib/util.ts', basename: 'util.ts', kind: 'text' },
- { relativePath: 'src/app.ts', basename: 'app.ts', kind: 'text' },
- { relativePath: 'README.md', basename: 'README.md', kind: 'text' }
+ file('src/lib/util.ts'),
+ file('src/app.ts'),
+ file('README.md')
])
expect(cache['']?.entries).toEqual(
expect.arrayContaining([
@@ -39,11 +66,27 @@ describe('directoryCacheFromFileList', () => {
expect(cache['src/lib']?.entries).toEqual([{ name: 'util.ts', isDirectory: false }])
})
- it('keeps a name a directory when it appears as both file and dir prefix', () => {
+ it('preserves empty-segment filtering and first-seen entry order', () => {
const cache = directoryCacheFromFileList([
- { relativePath: 'src', basename: 'src', kind: 'text' },
- { relativePath: 'src/app.ts', basename: 'app.ts', kind: 'text' }
+ file('//src///lib//util.ts//'),
+ file('/README.md'),
+ file('src/app.ts'),
+ file('///')
])
+
+ expect(cache['']?.entries).toEqual([
+ { name: 'src', isDirectory: true },
+ { name: 'README.md', isDirectory: false }
+ ])
+ expect(cache['src']?.entries).toEqual([
+ { name: 'lib', isDirectory: true },
+ { name: 'app.ts', isDirectory: false }
+ ])
+ expect(cache['src/lib']?.entries).toEqual([{ name: 'util.ts', isDirectory: false }])
+ })
+
+ it('keeps a name a directory when it appears as both file and dir prefix', () => {
+ const cache = directoryCacheFromFileList([file('src'), file('src/app.ts')])
expect(cache['']?.entries).toEqual([{ name: 'src', isDirectory: true }])
})
@@ -53,13 +96,112 @@ describe('directoryCacheFromFileList', () => {
})
it('stores a __proto__ directory as an own key instead of mutating the prototype', () => {
- const cache = directoryCacheFromFileList([
- { relativePath: '__proto__/pollute.js', basename: 'pollute.js', kind: 'text' }
- ])
+ const cache = directoryCacheFromFileList([file('__proto__/pollute.js')])
expect(Object.getPrototypeOf(cache)).toBe(Object.prototype)
expect(cache['']?.entries).toEqual([{ name: '__proto__', isDirectory: true }])
expect(getDirectoryCacheState(cache, '__proto__')?.entries).toEqual([
{ name: 'pollute.js', isDirectory: false }
])
})
+
+ it('accepts a delimiter-heavy path at the exact byte cap without retaining empty segments', () => {
+ const cache = directoryCacheFromFileList([file('/'.repeat(LEGACY_MOBILE_FILE_PATH_MAX_BYTES))])
+ expect(cache['']?.entries).toEqual([])
+ })
+
+ it('measures the per-file path cap in UTF-8 bytes and rejects one byte over', () => {
+ const exactPath = 'é'.repeat(LEGACY_MOBILE_FILE_PATH_MAX_BYTES / 2)
+ expect(directoryCacheFromFileList([file(exactPath)])['']?.entries).toEqual([
+ { name: exactPath, isDirectory: false }
+ ])
+ expect(() => directoryCacheFromFileList([file(`${exactPath}a`)])).toThrow(
+ LEGACY_MOBILE_FILE_LIST_LIMIT_MESSAGE
+ )
+ })
+
+ it('accepts the exact path depth and rejects the next segment', () => {
+ const exactPath = Array.from({ length: LEGACY_MOBILE_FILE_PATH_MAX_DEPTH }, () => 'd').join('/')
+ const parentPath = exactPath.slice(0, exactPath.lastIndexOf('/'))
+ expect(
+ getDirectoryCacheState(directoryCacheFromFileList([file(exactPath)]), parentPath)
+ ).toEqual({
+ entries: [{ name: 'd', isDirectory: false }]
+ })
+ expect(() => directoryCacheFromFileList([file(`${exactPath}/overflow`)])).toThrow(
+ LEGACY_MOBILE_FILE_LIST_LIMIT_MESSAGE
+ )
+ })
+
+ it('rejects a response above the desktop files.list record cap', () => {
+ const files = Array.from({ length: LEGACY_MOBILE_FILE_LIST_MAX_FILES + 1 }, () => file(''))
+ expect(() => directoryCacheFromFileList(files)).toThrow(LEGACY_MOBILE_FILE_LIST_LIMIT_MESSAGE)
+ })
+
+ it('accepts the exact directory cap and rejects one additional directory', () => {
+ const fullChains = Math.floor((LEGACY_MOBILE_FILE_CACHE_MAX_DIRECTORIES - 1) / 255)
+ const remainingDirectories = LEGACY_MOBILE_FILE_CACHE_MAX_DIRECTORIES - 1 - fullChains * 255
+ const files = Array.from({ length: fullChains }, (_, index) =>
+ file(directoryChainPath(`root-${index}`, 255))
+ )
+ if (remainingDirectories > 0) {
+ files.push(file(directoryChainPath('tail', remainingDirectories)))
+ }
+
+ expect(Object.keys(directoryCacheFromFileList(files))).toHaveLength(
+ LEGACY_MOBILE_FILE_CACHE_MAX_DIRECTORIES
+ )
+ expect(() =>
+ directoryCacheFromFileList([...files, file(directoryChainPath('overflow', 1))])
+ ).toThrow(LEGACY_MOBILE_FILE_LIST_LIMIT_MESSAGE)
+ })
+
+ it('accepts the exact aggregate entry cap and rejects one additional entry', () => {
+ const exactFiles = Array.from({ length: LEGACY_MOBILE_FILE_LIST_MAX_FILES }, (_, index) =>
+ file(`root-${index}/a/b/file`)
+ )
+ const overflowFiles = exactFiles.map((entry, index) =>
+ index === 0 ? file('root-0/a/b/c/file') : entry
+ )
+
+ const cache = directoryCacheFromFileList(exactFiles)
+ const entryCount = Object.values(cache).reduce(
+ (total, state) => total + (state?.entries.length ?? 0),
+ 0
+ )
+ expect(entryCount).toBe(LEGACY_MOBILE_FILE_CACHE_MAX_ENTRIES)
+ expect(() => directoryCacheFromFileList(overflowFiles)).toThrow(
+ LEGACY_MOBILE_FILE_LIST_LIMIT_MESSAGE
+ )
+ })
+
+ it('accepts the exact retained-byte budget and rejects two bytes over', () => {
+ const namesBytes =
+ LEGACY_MOBILE_FILE_CACHE_MAX_RETAINED_BYTES -
+ RETAINED_NODE_ESTIMATE_BYTES -
+ LEGACY_MOBILE_FILE_LIST_MAX_FILES * RETAINED_NODE_ESTIMATE_BYTES
+ const namesCharacters = namesBytes / 2
+ const baseLength = Math.floor(namesCharacters / LEGACY_MOBILE_FILE_LIST_MAX_FILES)
+ const longerNames = namesCharacters % LEGACY_MOBILE_FILE_LIST_MAX_FILES
+ const exactFiles = Array.from({ length: LEGACY_MOBILE_FILE_LIST_MAX_FILES }, (_, index) => {
+ const length = baseLength + (index < longerNames ? 1 : 0)
+ return file(fixedLengthUniqueName(index, length))
+ })
+ const overflowFiles = exactFiles.map((entry, index) =>
+ index === 0 ? file(`${entry.relativePath}x`) : entry
+ )
+
+ expect(directoryCacheFromFileList(exactFiles)['']?.entries).toHaveLength(
+ LEGACY_MOBILE_FILE_LIST_MAX_FILES
+ )
+ expect(() => directoryCacheFromFileList(overflowFiles)).toThrow(
+ LEGACY_MOBILE_FILE_LIST_LIMIT_MESSAGE
+ )
+ })
+
+ it('rejects malformed legacy response shapes with a clear error', () => {
+ expect(() => directoryCacheFromFileList({ files: [] })).toThrow('invalid legacy file list')
+ expect(() => directoryCacheFromFileList([{ relativePath: 42 }])).toThrow(
+ 'invalid legacy file list'
+ )
+ })
})
diff --git a/mobile/src/files/file-list-fallback.ts b/mobile/src/files/file-list-fallback.ts
index ef3041029af..5c2dde0f30e 100644
--- a/mobile/src/files/file-list-fallback.ts
+++ b/mobile/src/files/file-list-fallback.ts
@@ -1,8 +1,22 @@
// Fallback for desktops that predate files.readDir in the mobile RPC
// allowlist: synthesize the lazy directory cache from the flat, capped
// files.list result so the Files tab stays browsable against old desktops.
+import { getUtf8ByteLengthForCodePoint } from '../../../src/shared/utf8-byte-limits'
import type { DirectoryCache, MobileDirEntry } from './file-tree'
+// Why: each flat legacy path can amplify into many retained ancestor records.
+export const LEGACY_MOBILE_FILE_LIST_MAX_FILES = 5_000
+export const LEGACY_MOBILE_FILE_PATH_MAX_BYTES = 16 * 1024
+export const LEGACY_MOBILE_FILE_PATH_MAX_DEPTH = 256
+export const LEGACY_MOBILE_FILE_CACHE_MAX_DIRECTORIES = 16_384
+export const LEGACY_MOBILE_FILE_CACHE_MAX_ENTRIES = 20_000
+export const LEGACY_MOBILE_FILE_CACHE_MAX_RETAINED_BYTES = 16 * 1024 * 1024
+export const LEGACY_MOBILE_FILE_LIST_LIMIT_MESSAGE =
+ 'This legacy file list is too large to show safely on mobile. Update Orca Desktop to browse it folder by folder.'
+
+const DIRECTORY_RETAINED_BYTES = 64
+const ENTRY_RETAINED_BYTES = 64
+
export type LegacyMobileFileEntry = {
relativePath: string
basename: string
@@ -15,6 +29,13 @@ export type LegacyFilesListResult = {
truncated: boolean
}
+type LegacyFileCacheBuildState = {
+ childrenByDir: Map>
+ directories: number
+ entries: number
+ retainedBytes: number
+}
+
// Same detection shape as isMobileGitUnavailable in mobile-git-status.ts:
// 'forbidden' = method exists but is not mobile-allowlisted on the old
// desktop; 'method_not_found' = desktop predates the method entirely.
@@ -29,45 +50,156 @@ export function isMobileMethodUnavailableError(
)
}
-export function directoryCacheFromFileList(files: LegacyMobileFileEntry[]): DirectoryCache {
- const childrenByDir = new Map>()
- const ensureDir = (path: string): Map => {
- let children = childrenByDir.get(path)
- if (!children) {
- children = new Map()
- childrenByDir.set(path, children)
- }
- return children
+export function directoryCacheFromFileList(files: unknown): DirectoryCache {
+ if (!Array.isArray(files)) {
+ throw new Error('Desktop returned an invalid legacy file list.')
}
- ensureDir('')
+ if (files.length > LEGACY_MOBILE_FILE_LIST_MAX_FILES) {
+ throwLegacyFileListLimitError()
+ }
+
+ const state: LegacyFileCacheBuildState = {
+ childrenByDir: new Map(),
+ directories: 0,
+ entries: 0,
+ retainedBytes: 0
+ }
+ ensureDirectory(state, '')
for (const file of files) {
- const parts = file.relativePath.split('/').filter(Boolean)
- let parentPath = ''
- parts.forEach((name, index) => {
- const isDirectory = index < parts.length - 1
- const children = ensureDir(parentPath)
- children.set(name, children.get(name) === true || isDirectory)
- parentPath = parentPath ? `${parentPath}/${name}` : name
- if (isDirectory) {
- ensureDir(parentPath)
+ const relativePath = getLegacyRelativePath(file)
+ if (relativePath === null) {
+ throw new Error('Desktop returned an invalid legacy file list.')
+ }
+ addFilePath(state, relativePath, measureBoundedPathDepth(relativePath))
+ }
+ return createDirectoryCache(state.childrenByDir)
+}
+
+function getLegacyRelativePath(value: unknown): string | null {
+ if (value === null || typeof value !== 'object') {
+ return null
+ }
+ const relativePath = (value as { relativePath?: unknown }).relativePath
+ return typeof relativePath === 'string' ? relativePath : null
+}
+
+function measureBoundedPathDepth(relativePath: string): number {
+ let bytes = 0
+ let depth = 0
+ let insideSegment = false
+ for (let index = 0; index < relativePath.length; index += 1) {
+ const codePoint = relativePath.codePointAt(index) ?? 0
+ bytes += getUtf8ByteLengthForCodePoint(codePoint)
+ if (bytes > LEGACY_MOBILE_FILE_PATH_MAX_BYTES) {
+ throwLegacyFileListLimitError()
+ }
+ if (codePoint === 47) {
+ if (insideSegment) {
+ depth += 1
+ assertPathDepth(depth)
}
+ insideSegment = false
+ } else {
+ insideSegment = true
+ }
+ if (codePoint > 0xffff) {
+ index += 1
+ }
+ }
+ if (insideSegment) {
+ depth += 1
+ assertPathDepth(depth)
+ }
+ return depth
+}
+
+function assertPathDepth(depth: number): void {
+ if (depth > LEGACY_MOBILE_FILE_PATH_MAX_DEPTH) {
+ throwLegacyFileListLimitError()
+ }
+}
+
+function addFilePath(state: LegacyFileCacheBuildState, relativePath: string, depth: number): void {
+ let parentPath = ''
+ let segmentStart = 0
+ let segmentIndex = 0
+ for (let cursor = 0; cursor <= relativePath.length; cursor += 1) {
+ if (cursor < relativePath.length && relativePath.charCodeAt(cursor) !== 47) {
+ continue
+ }
+ if (cursor > segmentStart) {
+ segmentIndex += 1
+ const name = relativePath.slice(segmentStart, cursor)
+ const isDirectory = segmentIndex < depth
+ addDirectoryEntry(state, parentPath, name, isDirectory)
+ if (isDirectory) {
+ parentPath = parentPath ? `${parentPath}/${name}` : name
+ ensureDirectory(state, parentPath)
+ }
+ }
+ segmentStart = cursor + 1
+ }
+}
+
+function ensureDirectory(state: LegacyFileCacheBuildState, path: string): void {
+ if (state.childrenByDir.has(path)) {
+ return
+ }
+ const retainedBytes = path.length * 2 + DIRECTORY_RETAINED_BYTES
+ if (
+ state.directories >= LEGACY_MOBILE_FILE_CACHE_MAX_DIRECTORIES ||
+ state.retainedBytes > LEGACY_MOBILE_FILE_CACHE_MAX_RETAINED_BYTES - retainedBytes
+ ) {
+ throwLegacyFileListLimitError()
+ }
+ state.childrenByDir.set(path, new Map())
+ state.directories += 1
+ state.retainedBytes += retainedBytes
+}
+
+function addDirectoryEntry(
+ state: LegacyFileCacheBuildState,
+ parentPath: string,
+ name: string,
+ isDirectory: boolean
+): void {
+ const children = state.childrenByDir.get(parentPath)
+ if (!children) {
+ throw new Error('Legacy file cache builder lost its parent directory.')
+ }
+ const existing = children.get(name)
+ if (existing) {
+ existing.isDirectory ||= isDirectory
+ return
+ }
+ const retainedBytes = name.length * 2 + ENTRY_RETAINED_BYTES
+ if (
+ state.entries >= LEGACY_MOBILE_FILE_CACHE_MAX_ENTRIES ||
+ state.retainedBytes > LEGACY_MOBILE_FILE_CACHE_MAX_RETAINED_BYTES - retainedBytes
+ ) {
+ throwLegacyFileListLimitError()
+ }
+ children.set(name, { name, isDirectory })
+ state.entries += 1
+ state.retainedBytes += retainedBytes
+}
+
+function createDirectoryCache(
+ childrenByDir: ReadonlyMap>
+): DirectoryCache {
+ const cache: DirectoryCache = {}
+ for (const [path, children] of childrenByDir) {
+ // Why: assignment to a '__proto__' path would invoke its legacy setter.
+ Object.defineProperty(cache, path, {
+ configurable: true,
+ enumerable: true,
+ value: { entries: Array.from(children.values()) },
+ writable: true
})
}
- // Why: plain `cache[path] = ...` with a '__proto__' path segment mutates the
- // object's prototype instead of storing the directory; fromEntries always
- // creates own keys.
- return Object.fromEntries(
- Array.from(childrenByDir, ([path, children]) => [
- path,
- {
- entries: Array.from(
- children,
- ([name, isDirectory]): MobileDirEntry => ({
- name,
- isDirectory
- })
- )
- }
- ])
- )
+ return cache
+}
+
+function throwLegacyFileListLimitError(): never {
+ throw new Error(LEGACY_MOBILE_FILE_LIST_LIMIT_MESSAGE)
}
diff --git a/mobile/src/files/file-tree.ts b/mobile/src/files/file-tree.ts
index a9283cfa457..8a8a2170e07 100644
--- a/mobile/src/files/file-tree.ts
+++ b/mobile/src/files/file-tree.ts
@@ -22,6 +22,7 @@ export type DirectoryState = {
entries: MobileDirEntry[]
loading?: boolean
error?: string
+ lastAccess?: number
}
export type DirectoryCache = Record
diff --git a/mobile/src/files/mobile-diff-image-preview.test.ts b/mobile/src/files/mobile-diff-image-preview.test.ts
index e63f532b28d..a7ae1cdc185 100644
--- a/mobile/src/files/mobile-diff-image-preview.test.ts
+++ b/mobile/src/files/mobile-diff-image-preview.test.ts
@@ -1,44 +1,57 @@
import { describe, expect, it } from 'vitest'
import { mobileDiffImageDataUri } from './mobile-diff-image-preview'
+function pngBase64(width = 1): string {
+ const bytes = Buffer.alloc(24)
+ Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(bytes)
+ bytes.writeUInt32BE(13, 8)
+ bytes.write('IHDR', 12, 'ascii')
+ bytes.writeUInt32BE(width, 16)
+ bytes.writeUInt32BE(1, 20)
+ return bytes.toString('base64')
+}
+
describe('mobileDiffImageDataUri', () => {
it('renders a modified image diff from the post-change bytes', () => {
+ const modifiedContent = pngBase64(2)
expect(
mobileDiffImageDataUri({
kind: 'binary',
originalContent: 'b2xk',
- modifiedContent: 'bmV3',
+ modifiedContent,
isImage: true,
mimeType: 'image/png'
})
- ).toBe('data:image/png;base64,bmV3')
+ ).toBe(`data:image/png;base64,${modifiedContent}`)
})
it('renders an added image diff (no original) from the modified bytes', () => {
+ const modifiedContent = pngBase64()
expect(
mobileDiffImageDataUri({
kind: 'binary',
originalContent: '',
- modifiedContent: 'bmV3',
+ modifiedContent,
isImage: true,
mimeType: 'image/png'
})
- ).toBe('data:image/png;base64,bmV3')
+ ).toBe(`data:image/png;base64,${modifiedContent}`)
})
it('falls back to the original bytes for a proven deletion (modifiedDeleted)', () => {
+ const originalContent = pngBase64()
expect(
mobileDiffImageDataUri({
kind: 'binary',
- originalContent: 'b2xk',
+ originalContent,
originalIsBinary: true,
modifiedContent: '',
modifiedIsBinary: false,
modifiedDeleted: true,
isImage: true,
- mimeType: 'image/jpeg'
+ mimeType: 'image/png'
})
- ).toBe('data:image/jpeg;base64,b2xk')
+ ).toBe(`data:image/png;base64,${originalContent}`)
})
// The reviewer's read-failure case: a relay/SSH read returns an empty modified
diff --git a/mobile/src/files/mobile-directory-cache-retention.test.ts b/mobile/src/files/mobile-directory-cache-retention.test.ts
new file mode 100644
index 00000000000..421d995492d
--- /dev/null
+++ b/mobile/src/files/mobile-directory-cache-retention.test.ts
@@ -0,0 +1,98 @@
+import { describe, expect, it } from 'vitest'
+import type { DirectoryCache, DirectoryState } from './file-tree'
+import {
+ MOBILE_DIRECTORY_CACHE_MAX_DIRECTORIES,
+ parseBoundedMobileDirectoryEntries,
+ removeEvictedExpandedPaths,
+ retainMobileDirectoryState
+} from './mobile-directory-cache-retention'
+
+function state(name: string, lastAccess: number): DirectoryState {
+ return { entries: [{ name, isDirectory: false }], lastAccess }
+}
+
+describe('mobile directory cache retention', () => {
+ it('keeps the cache bounded across an unlimited sequence of visited directories', () => {
+ let cache: DirectoryCache = { '': state('root', 0) }
+
+ for (let index = 1; index <= MOBILE_DIRECTORY_CACHE_MAX_DIRECTORIES * 3; index++) {
+ const result = retainMobileDirectoryState(
+ cache,
+ `dir-${index}`,
+ state(`file-${index}`, index),
+ new Set()
+ )
+ expect(result.admitted).toBe(true)
+ cache = result.cache
+ }
+
+ expect(Object.keys(cache)).toHaveLength(MOBILE_DIRECTORY_CACHE_MAX_DIRECTORIES)
+ expect(cache['']).toBeDefined()
+ expect(cache['dir-1']).toBeUndefined()
+ expect(cache[`dir-${MOBILE_DIRECTORY_CACHE_MAX_DIRECTORIES * 3}`]).toBeDefined()
+ })
+
+ it('evicts a collapsed branch before an older expanded branch', () => {
+ const cache: DirectoryCache = {
+ '': state('root', 0),
+ expanded: state('expanded', 1),
+ collapsed: state('collapsed', 2)
+ }
+
+ const result = retainMobileDirectoryState(
+ cache,
+ 'new',
+ state('new', 3),
+ new Set(['expanded']),
+ { directories: 3, entries: 100, retainedBytes: 10_000 }
+ )
+
+ expect(result.cache.expanded).toBeDefined()
+ expect(result.cache.collapsed).toBeUndefined()
+ expect(result.evictedPaths).toEqual(['collapsed'])
+ })
+
+ it('evicts old content when aggregate entry retention reaches its cap', () => {
+ const cache: DirectoryCache = {
+ '': state('root', 0),
+ old: state('old', 1)
+ }
+
+ const result = retainMobileDirectoryState(cache, 'new', state('new', 2), new Set(), {
+ directories: 10,
+ entries: 2,
+ retainedBytes: 10_000
+ })
+
+ expect(result.cache.old).toBeUndefined()
+ expect(result.cache.new).toBeDefined()
+ })
+
+ it('evicts old content when aggregate retained bytes reach their cap', () => {
+ const cache: DirectoryCache = {
+ '': state('root', 0),
+ old: state('x'.repeat(100), 1)
+ }
+
+ const result = retainMobileDirectoryState(cache, 'new', state('new', 2), new Set(), {
+ directories: 10,
+ entries: 100,
+ retainedBytes: 300
+ })
+
+ expect(result.cache.old).toBeUndefined()
+ expect(result.cache.new).toBeDefined()
+ })
+
+ it('collapses expanded descendants whose cached branch was evicted', () => {
+ expect([...removeEvictedExpandedPaths(new Set(['src', 'src/lib', 'docs']), ['src'])]).toEqual([
+ 'docs'
+ ])
+ })
+
+ it('rejects malformed listings instead of retaining untrusted response shapes', () => {
+ expect(() => parseBoundedMobileDirectoryEntries([{ name: 'src' }])).toThrow(
+ 'invalid folder listing'
+ )
+ })
+})
diff --git a/mobile/src/files/mobile-directory-cache-retention.ts b/mobile/src/files/mobile-directory-cache-retention.ts
new file mode 100644
index 00000000000..b6ec4b9748b
--- /dev/null
+++ b/mobile/src/files/mobile-directory-cache-retention.ts
@@ -0,0 +1,156 @@
+import {
+ assertMobileFileDirectoryWithinLimit,
+ estimateMobileDirectoryEntryBytes,
+ MOBILE_FILE_DIRECTORY_LIMIT_MESSAGE,
+ MOBILE_FILE_DIRECTORY_MAX_ENTRIES
+} from '../../../src/shared/mobile-file-directory-limit'
+import type { DirectoryCache, DirectoryState, MobileDirEntry } from './file-tree'
+
+// Why: old collapsed branches can reload on demand once explorer metadata reaches a phone-safe ceiling.
+export const MOBILE_DIRECTORY_CACHE_MAX_DIRECTORIES = 128
+export const MOBILE_DIRECTORY_CACHE_MAX_ENTRIES = 25_000
+export const MOBILE_DIRECTORY_CACHE_MAX_RETAINED_BYTES = 16 * 1024 * 1024
+export const MOBILE_DIRECTORY_CACHE_LIMIT_MESSAGE =
+ 'Too many folders are open to load this folder safely. Close another folder and retry.'
+
+type CacheLimits = {
+ directories: number
+ entries: number
+ retainedBytes: number
+}
+
+type RetentionResult = {
+ cache: DirectoryCache
+ evictedPaths: string[]
+ admitted: boolean
+}
+
+const DEFAULT_LIMITS: CacheLimits = {
+ directories: MOBILE_DIRECTORY_CACHE_MAX_DIRECTORIES,
+ entries: MOBILE_DIRECTORY_CACHE_MAX_ENTRIES,
+ retainedBytes: MOBILE_DIRECTORY_CACHE_MAX_RETAINED_BYTES
+}
+
+export function parseBoundedMobileDirectoryEntries(value: unknown): MobileDirEntry[] {
+ if (!Array.isArray(value)) {
+ throw new Error('Desktop returned an invalid folder listing.')
+ }
+ if (value.length > MOBILE_FILE_DIRECTORY_MAX_ENTRIES) {
+ throw new Error(MOBILE_FILE_DIRECTORY_LIMIT_MESSAGE)
+ }
+ for (const entry of value) {
+ if (!isMobileDirectoryEntry(entry)) {
+ throw new Error('Desktop returned an invalid folder listing.')
+ }
+ }
+ const entries = value as MobileDirEntry[]
+ assertMobileFileDirectoryWithinLimit(entries)
+ return entries
+}
+
+export function retainMobileDirectoryState(
+ cache: DirectoryCache,
+ relativePath: string,
+ state: DirectoryState,
+ expandedPaths: ReadonlySet,
+ limits: CacheLimits = DEFAULT_LIMITS
+): RetentionResult {
+ const next: DirectoryCache = { ...cache, [relativePath]: state }
+ const evictedPaths: string[] = []
+ const essentialPaths = directoryAncestors(relativePath)
+
+ while (cacheExceedsLimits(next, limits)) {
+ const victim = selectEvictionPath(next, expandedPaths, essentialPaths)
+ if (victim === null) {
+ return { cache, evictedPaths: [], admitted: false }
+ }
+ for (const path of Object.keys(next)) {
+ if (path === victim || path.startsWith(`${victim}/`)) {
+ delete next[path]
+ evictedPaths.push(path)
+ }
+ }
+ }
+ return { cache: next, evictedPaths, admitted: true }
+}
+
+export function removeEvictedExpandedPaths(
+ expandedPaths: ReadonlySet,
+ evictedPaths: readonly string[]
+): Set {
+ if (evictedPaths.length === 0) {
+ return new Set(expandedPaths)
+ }
+ return new Set(
+ [...expandedPaths].filter(
+ (expanded) =>
+ !evictedPaths.some((evicted) => expanded === evicted || expanded.startsWith(`${evicted}/`))
+ )
+ )
+}
+
+function isMobileDirectoryEntry(value: unknown): value is MobileDirEntry {
+ if (!value || typeof value !== 'object') {
+ return false
+ }
+ const entry = value as Record
+ return (
+ typeof entry.name === 'string' &&
+ typeof entry.isDirectory === 'boolean' &&
+ (entry.isSymlink === undefined || typeof entry.isSymlink === 'boolean')
+ )
+}
+
+function directoryAncestors(relativePath: string): Set {
+ const ancestors = new Set(['', relativePath])
+ let cursor = relativePath
+ while (cursor.includes('/')) {
+ cursor = cursor.slice(0, cursor.lastIndexOf('/'))
+ ancestors.add(cursor)
+ }
+ return ancestors
+}
+
+function selectEvictionPath(
+ cache: DirectoryCache,
+ expandedPaths: ReadonlySet,
+ essentialPaths: ReadonlySet
+): string | null {
+ const candidates = Object.keys(cache)
+ .filter((path) => !essentialPaths.has(path))
+ .sort((left, right) => accessOrder(cache, left) - accessOrder(cache, right))
+ return candidates.find((path) => !expandedPaths.has(path)) ?? candidates[0] ?? null
+}
+
+function accessOrder(cache: DirectoryCache, path: string): number {
+ return cache[path]?.lastAccess ?? 0
+}
+
+function cacheExceedsLimits(cache: DirectoryCache, limits: CacheLimits): boolean {
+ const paths = Object.keys(cache)
+ if (paths.length > limits.directories) {
+ return true
+ }
+ let entries = 0
+ let retainedBytes = 0
+ for (const path of paths) {
+ const state = cache[path]
+ if (!state) {
+ continue
+ }
+ entries += state.entries.length
+ retainedBytes += estimateDirectoryStateBytes(path, state)
+ if (entries > limits.entries || retainedBytes > limits.retainedBytes) {
+ return true
+ }
+ }
+ return false
+}
+
+function estimateDirectoryStateBytes(path: string, state: DirectoryState): number {
+ let bytes = path.length * 2 + (state.error?.length ?? 0) * 2 + 64
+ for (const entry of state.entries) {
+ bytes += estimateMobileDirectoryEntryBytes(entry)
+ }
+ return bytes
+}
diff --git a/mobile/src/files/mobile-file-mutation-ownership.test.ts b/mobile/src/files/mobile-file-mutation-ownership.test.ts
new file mode 100644
index 00000000000..f3e98dca9c6
--- /dev/null
+++ b/mobile/src/files/mobile-file-mutation-ownership.test.ts
@@ -0,0 +1,116 @@
+import { describe, expect, it, vi } from 'vitest'
+import type { SshConnectionState } from '../../../src/shared/ssh-types'
+import {
+ FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY,
+ FILE_MUTATION_OWNERSHIP_UPDATE_REQUIRED_MESSAGE
+} from '../../../src/shared/protocol-version'
+import type { RpcClient } from '../transport/rpc-client'
+import type { RpcResponse } from '../transport/types'
+import {
+ buildMobileFileMutationOwnership,
+ captureMobileFileMutationOwnership
+} from './mobile-file-mutation-ownership'
+
+function success(result: unknown): RpcResponse {
+ return { id: 'rpc-1', ok: true, result, _meta: { runtimeId: 'runtime-1' } }
+}
+
+function clientWithResponses(responses: RpcResponse[]): {
+ client: Pick
+ sendRequest: ReturnType
+} {
+ const sendRequest = vi.fn(async () => {
+ const response = responses.shift()
+ if (!response) {
+ throw new Error('Unexpected RPC request')
+ }
+ return response
+ })
+ return { client: { sendRequest }, sendRequest }
+}
+
+function sshState(targetId: string, connectionGeneration: number | undefined): SshConnectionState {
+ return {
+ targetId,
+ status: 'connected',
+ error: null,
+ reconnectAttempt: 0,
+ connectionGeneration
+ }
+}
+
+describe('mobile file mutation ownership', () => {
+ it.each([undefined, 'local', 'runtime:environment-1'])(
+ 'binds %s worktrees to the runtime-local file host',
+ (hostId) => {
+ expect(buildMobileFileMutationOwnership(hostId)).toEqual({
+ expectedExecutionHostId: 'local'
+ })
+ }
+ )
+
+ it('binds SSH worktrees to the target and live connection generation', () => {
+ expect(
+ buildMobileFileMutationOwnership('ssh:target%20one', sshState('target one', 17))
+ ).toEqual({
+ expectedExecutionHostId: 'ssh:target%20one',
+ expectedSshTargetId: 'target one',
+ expectedSshConnectionGeneration: 17
+ })
+ })
+
+ it.each([
+ ['a malformed owner', 'not-an-execution-host', null],
+ ['a missing SSH state', 'ssh:target-1', null],
+ ['a mismatched SSH target', 'ssh:target-1', sshState('target-2', 4)],
+ ['a missing SSH generation', 'ssh:target-1', sshState('target-1', undefined)]
+ ])('rejects %s', (_name, hostId, state) => {
+ expect(() => buildMobileFileMutationOwnership(hostId, state)).toThrow(
+ "Couldn't verify the SSH connection"
+ )
+ })
+
+ it('captures local ownership only after verifying the runtime capability', async () => {
+ const { client, sendRequest } = clientWithResponses([
+ success({ capabilities: [FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY] }),
+ success({ worktree: { hostId: 'local' } })
+ ])
+
+ await expect(captureMobileFileMutationOwnership(client, 'id:worktree-1')).resolves.toEqual({
+ expectedExecutionHostId: 'local'
+ })
+ expect(sendRequest.mock.calls).toEqual([
+ ['status.get', undefined, { timeoutMs: 15_000 }],
+ ['worktree.show', { worktree: 'id:worktree-1' }, { timeoutMs: 15_000 }]
+ ])
+ })
+
+ it('captures SSH generation from the HUB before building mutation params', async () => {
+ const state = sshState('target-1', 9)
+ const { client, sendRequest } = clientWithResponses([
+ success({ capabilities: [FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY] }),
+ success({ worktree: { hostId: 'ssh:target-1' } }),
+ success({ state })
+ ])
+
+ await expect(captureMobileFileMutationOwnership(client, 'id:worktree-1')).resolves.toEqual({
+ expectedExecutionHostId: 'ssh:target-1',
+ expectedSshTargetId: 'target-1',
+ expectedSshConnectionGeneration: 9
+ })
+ expect(sendRequest.mock.calls[2]).toEqual([
+ 'ssh.getState',
+ { targetId: 'target-1' },
+ { timeoutMs: 15_000 }
+ ])
+ })
+
+ it('refuses older runtimes before reading or mutating workspace files', async () => {
+ const { client, sendRequest } = clientWithResponses([success({ capabilities: [] })])
+
+ await expect(captureMobileFileMutationOwnership(client, 'id:worktree-1')).rejects.toThrow(
+ FILE_MUTATION_OWNERSHIP_UPDATE_REQUIRED_MESSAGE
+ )
+ expect(sendRequest).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/mobile/src/files/mobile-file-mutation-ownership.ts b/mobile/src/files/mobile-file-mutation-ownership.ts
new file mode 100644
index 00000000000..e5a1cbbeb65
--- /dev/null
+++ b/mobile/src/files/mobile-file-mutation-ownership.ts
@@ -0,0 +1,81 @@
+import { parseExecutionHostId } from '../../../src/shared/execution-host'
+import { assertFileMutationOwnershipCapability } from '../../../src/shared/file-mutation-ownership'
+import type { RuntimeStatus } from '../../../src/shared/runtime-types'
+import type { SshConnectionState, SshMutationExpectation } from '../../../src/shared/ssh-types'
+import type { RpcClient } from '../transport/rpc-client'
+import type { RpcFailure, RpcSuccess } from '../transport/types'
+
+const FILE_MUTATION_TIMEOUT_MS = 15_000
+const SSH_OWNER_CHANGED_MESSAGE =
+ "Couldn't verify the SSH connection. Reconnect the host and try again."
+
+export type MobileFileMutationOwnership = SshMutationExpectation & {
+ expectedExecutionHostId: 'local' | `ssh:${string}`
+}
+
+export function buildMobileFileMutationOwnership(
+ worktreeHostId: string | null | undefined,
+ sshState: SshConnectionState | null = null
+): MobileFileMutationOwnership {
+ const host = parseExecutionHostId(worktreeHostId)
+ if (worktreeHostId !== undefined && !host) {
+ throw new Error(SSH_OWNER_CHANGED_MESSAGE)
+ }
+ if (!host || host.kind === 'local' || host.kind === 'runtime') {
+ return { expectedExecutionHostId: 'local' }
+ }
+ if (sshState?.targetId !== host.targetId || sshState.connectionGeneration === undefined) {
+ throw new Error(SSH_OWNER_CHANGED_MESSAGE)
+ }
+ return {
+ expectedExecutionHostId: host.id,
+ expectedSshTargetId: host.targetId,
+ expectedSshConnectionGeneration: sshState.connectionGeneration
+ }
+}
+
+export async function captureMobileFileMutationOwnership(
+ client: Pick,
+ worktree: string
+): Promise {
+ const status = await requestResult>(
+ client,
+ 'status.get',
+ undefined
+ )
+ assertFileMutationOwnershipCapability(status)
+
+ const result = await requestResult<{ worktree?: { hostId?: string | null } }>(
+ client,
+ 'worktree.show',
+ { worktree }
+ )
+ if (!result.worktree) {
+ throw new Error(SSH_OWNER_CHANGED_MESSAGE)
+ }
+
+ const host = parseExecutionHostId(result.worktree.hostId)
+ const sshState =
+ host?.kind === 'ssh'
+ ? (
+ await requestResult<{ state: SshConnectionState | null }>(client, 'ssh.getState', {
+ targetId: host.targetId
+ })
+ ).state
+ : null
+ return buildMobileFileMutationOwnership(result.worktree.hostId, sshState)
+}
+
+async function requestResult(
+ client: Pick,
+ method: string,
+ params: unknown
+): Promise {
+ const response = await client.sendRequest(method, params, {
+ timeoutMs: FILE_MUTATION_TIMEOUT_MS
+ })
+ if (!response.ok) {
+ throw new Error((response as RpcFailure).error.message)
+ }
+ return (response as RpcSuccess).result as TResult
+}
diff --git a/mobile/src/files/mobile-file-preview-request.test.ts b/mobile/src/files/mobile-file-preview-request.test.ts
index f7f77fabd19..185962bad72 100644
--- a/mobile/src/files/mobile-file-preview-request.test.ts
+++ b/mobile/src/files/mobile-file-preview-request.test.ts
@@ -28,6 +28,16 @@ function clientWithResponses(responses: RpcResponse[]) {
}
}
+function pngBase64(width = 1, height = 1): string {
+ const bytes = Buffer.alloc(24)
+ Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(bytes)
+ bytes.writeUInt32BE(13, 8)
+ bytes.write('IHDR', 12, 'ascii')
+ bytes.writeUInt32BE(width, 16)
+ bytes.writeUInt32BE(height, 20)
+ return bytes.toString('base64')
+}
+
describe('mobile-file-preview-request', () => {
it('selects readPreview for raster images and read for text-like files', () => {
expect(createMobileFilePreviewRequest('wt-1', 'assets/logo.png')).toEqual({
@@ -45,14 +55,13 @@ describe('mobile-file-preview-request', () => {
})
it('loads images through readPreview and never calls files.open', async () => {
- const client = clientWith(
- ok({ content: 'aW1hZ2U=', isBinary: true, isImage: true, mimeType: 'image/png' })
- )
+ const content = pngBase64()
+ const client = clientWith(ok({ content, isBinary: true, isImage: true, mimeType: 'image/png' }))
await expect(loadMobileFilePreview(client, 'wt-1', 'assets/logo.png')).resolves.toEqual({
status: 'ready',
kind: 'image',
- dataUri: 'data:image/png;base64,aW1hZ2U='
+ dataUri: `data:image/png;base64,${content}`
})
expect(client.sendRequest).toHaveBeenCalledWith('files.readPreview', {
worktree: 'id:wt-1',
@@ -527,9 +536,9 @@ describe('mobile-file-preview-request', () => {
})
it.each([
- ['missing isBinary', { content: 'aW1hZ2U=', isImage: true, mimeType: 'image/png' }],
- ['missing isImage', { content: 'aW1hZ2U=', isBinary: true, mimeType: 'image/png' }],
- ['missing mimeType', { content: 'aW1hZ2U=', isBinary: true, isImage: true }],
+ ['missing isBinary', { content: pngBase64(), isImage: true, mimeType: 'image/png' }],
+ ['missing isImage', { content: pngBase64(), isBinary: true, mimeType: 'image/png' }],
+ ['missing mimeType', { content: pngBase64(), isBinary: true, isImage: true }],
['empty content', { content: '', isBinary: true, isImage: true, mimeType: 'image/png' }]
])('rejects invalid image preview results: %s', (_label, result) => {
expect(normalizeMobileFilePreviewResponse('assets/logo.png', ok(result))).toEqual({
@@ -539,6 +548,24 @@ describe('mobile-file-preview-request', () => {
})
})
+ it('rejects an oversized raster response before React Native receives a data URI', () => {
+ expect(
+ normalizeMobileFilePreviewResponse(
+ 'assets/logo.png',
+ ok({
+ content: pngBase64(32_769, 1),
+ isBinary: true,
+ isImage: true,
+ mimeType: 'image/png'
+ })
+ )
+ ).toEqual({
+ status: 'error',
+ message: 'Binary preview unavailable',
+ reconnect: false
+ })
+ })
+
it('normalizes markdown, html, text, empty, and truncated reads', () => {
expect(
normalizeMobileFilePreviewResponse(
diff --git a/mobile/src/files/mobile-file-preview-response.ts b/mobile/src/files/mobile-file-preview-response.ts
index 0b7b1b23be8..8f57a92edf7 100644
--- a/mobile/src/files/mobile-file-preview-response.ts
+++ b/mobile/src/files/mobile-file-preview-response.ts
@@ -2,6 +2,7 @@ import { classifyMobileArtifact } from '../session/mobile-artifact-kind'
import type { RpcFailure, RpcResponse, RpcSuccess } from '../transport/types'
import { isMarkdownPath } from './file-tree'
import { isTerminalArtifactGrantError } from './terminal-artifact-grant-error'
+import { buildImageDataUri } from '../../../src/shared/image-data-uri'
export type MobileFilePreviewTextKind = 'html' | 'markdown' | 'text'
@@ -117,10 +118,14 @@ function normalizeImagePreviewResult(result: unknown): MobileFilePreviewResult {
) {
return previewError('binary_file')
}
+ const dataUri = buildImageDataUri(preview.mimeType, preview.content)
+ if (!dataUri) {
+ return previewError('binary_file')
+ }
return {
status: 'ready',
kind: 'image',
- dataUri: `data:${preview.mimeType};base64,${preview.content}`
+ dataUri
}
}
diff --git a/mobile/src/files/mobile-file-tab-doc.test.ts b/mobile/src/files/mobile-file-tab-doc.test.ts
index a9570e6cf44..cfe5578a7e0 100644
--- a/mobile/src/files/mobile-file-tab-doc.test.ts
+++ b/mobile/src/files/mobile-file-tab-doc.test.ts
@@ -31,6 +31,16 @@ function clientOf(byMethod: Record): {
const WT = { worktreeId: 'wt1' }
+function pngBase64(width = 1): string {
+ const bytes = Buffer.alloc(24)
+ Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(bytes)
+ bytes.writeUInt32BE(13, 8)
+ bytes.write('IHDR', 12, 'ascii')
+ bytes.writeUInt32BE(width, 16)
+ bytes.writeUInt32BE(1, 20)
+ return bytes.toString('base64')
+}
+
describe('resolveMobileFileTabDoc', () => {
it('renders a staged text diff', async () => {
const client = clientOf({
@@ -46,11 +56,12 @@ describe('resolveMobileFileTabDoc', () => {
})
it('renders an unstaged image diff from the modified bytes', async () => {
+ const modifiedContent = pngBase64()
const client = clientOf({
'git.diff': ok({
kind: 'binary',
originalContent: 'b2xk',
- modifiedContent: 'bmV3',
+ modifiedContent,
modifiedIsBinary: true,
isImage: true,
mimeType: 'image/png'
@@ -61,7 +72,11 @@ describe('resolveMobileFileTabDoc', () => {
relativePath: 'm1.png',
diffSource: 'unstaged'
})
- expect(doc).toEqual({ status: 'ready', kind: 'image', dataUri: 'data:image/png;base64,bmV3' })
+ expect(doc).toEqual({
+ status: 'ready',
+ kind: 'image',
+ dataUri: `data:image/png;base64,${modifiedContent}`
+ })
})
it('throws binary_file for an image modify whose bytes are empty (no stale fallback)', async () => {
@@ -88,11 +103,16 @@ describe('resolveMobileFileTabDoc', () => {
})
it('renders a live image preview via files.readPreview', async () => {
+ const content = pngBase64()
const client = clientOf({
- 'files.readPreview': ok({ content: 'bmV3', isImage: true, mimeType: 'image/png' })
+ 'files.readPreview': ok({ content, isImage: true, mimeType: 'image/png' })
})
const doc = await resolveMobileFileTabDoc(client, { ...WT, relativePath: 'logo.png' })
- expect(doc).toEqual({ status: 'ready', kind: 'image', dataUri: 'data:image/png;base64,bmV3' })
+ expect(doc).toEqual({
+ status: 'ready',
+ kind: 'image',
+ dataUri: `data:image/png;base64,${content}`
+ })
expect(client.calls).toEqual(['files.readPreview'])
})
diff --git a/mobile/src/hooks/mobile-dictation-audio-chunk.test.ts b/mobile/src/hooks/mobile-dictation-audio-chunk.test.ts
new file mode 100644
index 00000000000..69fa92f0265
--- /dev/null
+++ b/mobile/src/hooks/mobile-dictation-audio-chunk.test.ts
@@ -0,0 +1,42 @@
+import { describe, expect, it, vi } from 'vitest'
+import type { RpcClient } from '../transport/rpc-client'
+import {
+ enqueueMobileDictationAudioChunk,
+ MOBILE_DICTATION_MAX_PENDING_CHUNKS
+} from './mobile-dictation-audio-chunk'
+import { MobileDictationPendingAudioBudget } from './mobile-dictation-pending-audio-budget'
+
+describe('enqueueMobileDictationAudioChunk', () => {
+ it('accepts the exact pending-promise cap and rejects one over even for empty chunks', () => {
+ const sendRequest = vi.fn(() => new Promise(() => undefined))
+ const failActiveDictation = vi.fn()
+ const pendingChunks = new Set>()
+ const queue = {
+ pendingChunks,
+ pendingAudioBudget: new MobileDictationPendingAudioBudget(),
+ shouldReleaseBudget: () => true,
+ failActiveDictation
+ }
+ const event = { data: new Uint8Array() }
+
+ for (let index = 0; index < MOBILE_DICTATION_MAX_PENDING_CHUNKS; index += 1) {
+ enqueueMobileDictationAudioChunk(
+ { sendRequest } as unknown as RpcClient,
+ 'dictation',
+ event,
+ queue
+ )
+ }
+ expect(pendingChunks).toHaveLength(MOBILE_DICTATION_MAX_PENDING_CHUNKS)
+ expect(failActiveDictation).not.toHaveBeenCalled()
+
+ enqueueMobileDictationAudioChunk(
+ { sendRequest } as unknown as RpcClient,
+ 'dictation',
+ event,
+ queue
+ )
+ expect(pendingChunks).toHaveLength(MOBILE_DICTATION_MAX_PENDING_CHUNKS)
+ expect(failActiveDictation).toHaveBeenCalledOnce()
+ })
+})
diff --git a/mobile/src/hooks/mobile-dictation-audio-chunk.ts b/mobile/src/hooks/mobile-dictation-audio-chunk.ts
index 792292e6c16..947deee0e61 100644
--- a/mobile/src/hooks/mobile-dictation-audio-chunk.ts
+++ b/mobile/src/hooks/mobile-dictation-audio-chunk.ts
@@ -14,12 +14,21 @@ type MobileDictationAudioChunkQueue = {
failActiveDictation: (dictationId: string, err: unknown) => void
}
+export const MOBILE_DICTATION_MAX_PENDING_CHUNKS = 256
+
export function enqueueMobileDictationAudioChunk(
client: RpcClient,
dictationId: string,
event: MicrophoneDataEvent,
queue: MobileDictationAudioChunkQueue
): void {
+ if (queue.pendingChunks.size >= MOBILE_DICTATION_MAX_PENDING_CHUNKS) {
+ queue.failActiveDictation(
+ dictationId,
+ new Error(MOBILE_DICTATION_CONNECTION_SLOW_ERROR_MESSAGE)
+ )
+ return
+ }
const raw = event.data
const bytes = raw instanceof Uint8Array ? raw : new Uint8Array(raw)
const byteLength = bytes.byteLength
diff --git a/mobile/src/hooks/mobile-dictation-keep-awake.test.ts b/mobile/src/hooks/mobile-dictation-keep-awake.test.ts
index 362f3a0cf68..4bb2803def4 100644
--- a/mobile/src/hooks/mobile-dictation-keep-awake.test.ts
+++ b/mobile/src/hooks/mobile-dictation-keep-awake.test.ts
@@ -11,9 +11,11 @@ vi.mock('expo-keep-awake', () => ({
}))
import {
+ MOBILE_DICTATION_KEEP_AWAKE_MAX_TRACKED_TAGS,
MOBILE_DICTATION_KEEP_AWAKE_NATIVE_TIMEOUT_MS,
MobileDictationKeepAwakeOwner,
- drainMobileDictationKeepAwakeCleanup
+ drainMobileDictationKeepAwakeCleanup,
+ resetMobileDictationKeepAwakeForTests
} from './mobile-dictation-keep-awake'
function deferred(): {
@@ -36,10 +38,33 @@ function deferred(): {
describe('MobileDictationKeepAwakeOwner', () => {
beforeEach(() => {
+ resetMobileDictationKeepAwakeForTests()
keepAwake.activate.mockReset().mockResolvedValue(undefined)
keepAwake.deactivate.mockReset().mockResolvedValue(undefined)
})
+ it('accepts the exact tracked-tag cap, rejects one over, and recovers after release', async () => {
+ const owners = Array.from(
+ { length: MOBILE_DICTATION_KEEP_AWAKE_MAX_TRACKED_TAGS },
+ () => new MobileDictationKeepAwakeOwner()
+ )
+ await Promise.all(owners.map((owner, index) => owner.acquire(`dictation-${index}`)))
+ expect(keepAwake.activate).toHaveBeenCalledTimes(MOBILE_DICTATION_KEEP_AWAKE_MAX_TRACKED_TAGS)
+
+ const oneOver = new MobileDictationKeepAwakeOwner()
+ await expect(oneOver.acquire('one-over')).rejects.toThrow(
+ 'Too many dictation keep-awake operations are pending'
+ )
+ expect(keepAwake.activate).toHaveBeenCalledTimes(MOBILE_DICTATION_KEEP_AWAKE_MAX_TRACKED_TAGS)
+
+ await owners[0]!.release('dictation-0')
+ await expect(oneOver.reacquire('one-over')).resolves.toBeUndefined()
+ await Promise.all([
+ ...owners.slice(1).map((owner, index) => owner.release(`dictation-${index + 1}`)),
+ oneOver.release('one-over')
+ ])
+ })
+
it('retries a failed native deactivation after the hook owner is replaced', async () => {
const firstOwner = new MobileDictationKeepAwakeOwner()
diff --git a/mobile/src/hooks/mobile-dictation-keep-awake.ts b/mobile/src/hooks/mobile-dictation-keep-awake.ts
index f94e3fb785a..08f93ac98be 100644
--- a/mobile/src/hooks/mobile-dictation-keep-awake.ts
+++ b/mobile/src/hooks/mobile-dictation-keep-awake.ts
@@ -5,6 +5,7 @@ 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
+export const MOBILE_DICTATION_KEEP_AWAKE_MAX_TRACKED_TAGS = 128
let nextOwnerId = 0
let keepAwakeOperation: Promise = Promise.resolve()
@@ -51,7 +52,34 @@ function withNativeCallTimeout(nativeCall: Promise): Promise {
})
}
+function trackedTagCount(): number {
+ let count = activeTags.size
+ for (const tag of pendingCleanupTags) {
+ if (!activeTags.has(tag)) {
+ count += 1
+ }
+ }
+ for (const tag of pendingActivations.keys()) {
+ if (!activeTags.has(tag) && !pendingCleanupTags.has(tag)) {
+ count += 1
+ }
+ }
+ return count
+}
+
+function assertTrackedTagCapacity(tag: string): void {
+ if (
+ !activeTags.has(tag) &&
+ !pendingCleanupTags.has(tag) &&
+ !pendingActivations.has(tag) &&
+ trackedTagCount() >= MOBILE_DICTATION_KEEP_AWAKE_MAX_TRACKED_TAGS
+ ) {
+ throw new Error('Too many dictation keep-awake operations are pending')
+ }
+}
+
async function activateTrackedTag(tag: string, isStillWanted: () => boolean): Promise {
+ assertTrackedTagCapacity(tag)
const nativeActivation = activateKeepAwakeAsync(tag)
try {
await withNativeCallTimeout(nativeActivation)
@@ -234,3 +262,12 @@ export function createMobileDictationKeepAwakeOwner(): MobileDictationKeepAwakeO
export function drainMobileDictationKeepAwakeCleanup(): Promise {
return enqueueKeepAwakeOperation(cleanupPendingTags)
}
+
+/** Test-only: drop retained native-operation bookkeeping between cases. */
+export function resetMobileDictationKeepAwakeForTests(): void {
+ nextOwnerId = 0
+ keepAwakeOperation = Promise.resolve()
+ activeTags.clear()
+ pendingCleanupTags.clear()
+ pendingActivations.clear()
+}
diff --git a/mobile/src/notifications/mobile-notification-delivery.ts b/mobile/src/notifications/mobile-notification-delivery.ts
new file mode 100644
index 00000000000..60bc572e291
--- /dev/null
+++ b/mobile/src/notifications/mobile-notification-delivery.ts
@@ -0,0 +1,203 @@
+import * as Notifications from 'expo-notifications'
+import { Platform } from 'react-native'
+import { loadPushNotificationsEnabled } from '../storage/preferences'
+import { buildLocalNotificationData, type DesktopNotificationSource } from './notification-routing'
+import { MobileNotificationDeliveryLedger } from './mobile-notification-retention'
+import {
+ MobileScheduledNotificationRegistry,
+ type MobileScheduledNotificationState
+} from './mobile-scheduled-notification-registry'
+
+export type NotificationEvent = {
+ type: 'notification'
+ source: DesktopNotificationSource
+ title: string
+ body: string
+ worktreeId?: string
+ notificationId?: string
+ notificationSeq?: number
+}
+
+export type DismissNotificationEvent = {
+ type: 'dismiss'
+ notificationId: string
+ notificationSeq?: number
+}
+
+const scheduledNotifications = new MobileScheduledNotificationRegistry()
+const notificationDeliveryLedger = new MobileNotificationDeliveryLedger()
+
+function getStoredNotificationKey(hostId: string, notificationId: string): string {
+ return `${encodeURIComponent(hostId)}:${encodeURIComponent(notificationId)}`
+}
+
+/** Test-only: override the cap (pass no arg to restore the default). */
+export function setScheduledNotificationsMaxForTests(max?: number): void {
+ scheduledNotifications.resetForTests(max)
+ notificationDeliveryLedger.resetForTests(max)
+}
+
+export type NotificationPermissionState = {
+ granted: boolean
+ status: string
+ canAskAgain: boolean
+ authorizationReflectsUserChoice: boolean
+}
+
+export async function getNotificationPermissionState(): Promise {
+ const { status, canAskAgain } = await Notifications.getPermissionsAsync()
+ return {
+ granted: status === 'granted',
+ status,
+ canAskAgain,
+ // Why: Android <33 has no runtime notification permission, so "granted" is capability, not user consent.
+ authorizationReflectsUserChoice:
+ status === 'granted' && (Platform.OS !== 'android' || Number(Platform.Version) >= 33)
+ }
+}
+
+// Why: re-read OS state every call — users can change it in Settings while Orca is backgrounded.
+export async function ensureNotificationPermissions(): Promise {
+ const existing = await getNotificationPermissionState()
+ if (existing.granted) {
+ return true
+ }
+ const { status } = await Notifications.requestPermissionsAsync()
+ return status === 'granted'
+}
+
+export function configureNotificationChannel(): void {
+ if (Platform.OS === 'android') {
+ void Notifications.setNotificationChannelAsync('orca-desktop', {
+ name: 'Desktop Notifications',
+ importance: Notifications.AndroidImportance.HIGH,
+ vibrationPattern: [0, 250],
+ lightColor: '#6366f1'
+ })
+ }
+}
+
+async function showLocalNotification(event: NotificationEvent, hostId: string): Promise {
+ const storedKey = event.notificationId
+ ? getStoredNotificationKey(hostId, event.notificationId)
+ : null
+ if (!storedKey) {
+ if (!(await loadPushNotificationsEnabled()) || !(await ensureNotificationPermissions())) {
+ return
+ }
+ await scheduleLocalNotification(event, hostId)
+ return
+ }
+
+ let state = scheduledNotifications.get(storedKey)
+ let evictedIdentifiers: string[] = []
+ if (state?.pending) {
+ return
+ }
+ if (!state) {
+ const reservation = scheduledNotifications.reserve(storedKey)
+ if (!reservation) {
+ return
+ }
+ state = reservation.state
+ evictedIdentifiers = reservation.evictedIdentifiers
+ }
+ const notificationState = state
+ const pending = scheduleTrackedNotification(event, hostId, notificationState, evictedIdentifiers)
+ notificationState.pending = pending
+
+ try {
+ const scheduledIdentifier = await pending
+ if (!scheduledIdentifier) {
+ if (!notificationState.identifier) {
+ scheduledNotifications.delete(storedKey)
+ }
+ return
+ }
+ if (notificationState.dismissAfterSchedule) {
+ notificationState.dismissAfterSchedule = false
+ scheduledNotifications.delete(storedKey)
+ await Notifications.dismissNotificationAsync(scheduledIdentifier).catch(() => {})
+ return
+ }
+ if (!scheduledNotifications.retainIdentifier(notificationState, scheduledIdentifier)) {
+ scheduledNotifications.delete(storedKey)
+ await Notifications.dismissNotificationAsync(scheduledIdentifier).catch(() => {})
+ }
+ } finally {
+ if (notificationState.pending === pending) {
+ notificationState.pending = undefined
+ notificationState.dismissAfterSchedule = false
+ }
+ }
+}
+
+async function scheduleTrackedNotification(
+ event: NotificationEvent,
+ hostId: string,
+ state: MobileScheduledNotificationState,
+ evictedIdentifiers: string[]
+): Promise {
+ // Why: retaining the native id makes a later desktop dismiss work; eviction must close it before slot reuse.
+ for (const identifier of evictedIdentifiers) {
+ await Notifications.dismissNotificationAsync(identifier).catch(() => {})
+ }
+ if (!(await loadPushNotificationsEnabled()) || !(await ensureNotificationPermissions())) {
+ return null
+ }
+ if (state.identifier) {
+ await Notifications.dismissNotificationAsync(state.identifier).catch(() => {})
+ scheduledNotifications.clearIdentifier(state)
+ }
+ return scheduleLocalNotification(event, hostId)
+}
+
+function scheduleLocalNotification(event: NotificationEvent, hostId: string): Promise {
+ return Notifications.scheduleNotificationAsync({
+ content: {
+ title: event.title,
+ body: event.body,
+ data: buildLocalNotificationData(event, hostId),
+ ...(Platform.OS === 'android' ? { channelId: 'orca-desktop' } : {})
+ },
+ trigger: null
+ })
+}
+
+async function dismissLocalNotification(
+ event: DismissNotificationEvent,
+ hostId: string
+): Promise {
+ const storedKey = getStoredNotificationKey(hostId, event.notificationId)
+ const state = scheduledNotifications.get(storedKey)
+ if (!state) {
+ return
+ }
+ if (state.pending) {
+ // Why: dismiss can arrive while the OS is still scheduling; defer it so no stale banner survives.
+ state.dismissAfterSchedule = true
+ return
+ }
+ if (!state.identifier) {
+ return
+ }
+ scheduledNotifications.delete(storedKey)
+ await Notifications.dismissNotificationAsync(state.identifier).catch(() => {})
+}
+
+export function startLocalNotificationDelivery(
+ event: NotificationEvent | DismissNotificationEvent,
+ hostId: string
+): Promise | null {
+ const releaseDelivery = notificationDeliveryLedger.claim(event, hostId)
+ if (!releaseDelivery) {
+ return null
+ }
+ const delivery =
+ event.type === 'notification'
+ ? showLocalNotification(event, hostId)
+ : dismissLocalNotification(event, hostId)
+ return delivery.finally(() => {
+ releaseDelivery()
+ })
+}
diff --git a/mobile/src/notifications/mobile-notification-retention.test.ts b/mobile/src/notifications/mobile-notification-retention.test.ts
new file mode 100644
index 00000000000..a2fb3e52a22
--- /dev/null
+++ b/mobile/src/notifications/mobile-notification-retention.test.ts
@@ -0,0 +1,112 @@
+import { describe, expect, it } from 'vitest'
+import {
+ MOBILE_NOTIFICATION_ACTIVE_MAX_BYTES,
+ MOBILE_NOTIFICATION_EVENT_MAX_BYTES,
+ MOBILE_NOTIFICATION_HOST_ID_MAX_BYTES,
+ MOBILE_NOTIFICATION_ID_MAX_BYTES,
+ MobileNotificationDeliveryLedger,
+ measureMobileNotificationDeliveryBytes
+} from './mobile-notification-retention'
+import {
+ MOBILE_NATIVE_NOTIFICATION_ID_MAX_BYTES,
+ MOBILE_SCHEDULED_NOTIFICATION_MAX_RETAINED_BYTES,
+ MobileScheduledNotificationRegistry
+} from './mobile-scheduled-notification-registry'
+
+describe('mobile notification retention', () => {
+ it('accepts an exact-limit event and rejects one more byte', () => {
+ const baseEvent = {
+ type: 'notification',
+ source: 'test',
+ title: '',
+ body: '',
+ notificationId: 'notification-1'
+ }
+ const baseBytes = measureMobileNotificationDeliveryBytes(baseEvent, 'host-1')!
+ const exactEvent = {
+ ...baseEvent,
+ body: 'x'.repeat(MOBILE_NOTIFICATION_EVENT_MAX_BYTES - baseBytes)
+ }
+
+ expect(measureMobileNotificationDeliveryBytes(exactEvent, 'host-1')).toBe(
+ MOBILE_NOTIFICATION_EVENT_MAX_BYTES
+ )
+ expect(
+ measureMobileNotificationDeliveryBytes(
+ { ...exactEvent, body: `${exactEvent.body}x` },
+ 'host-1'
+ )
+ ).toBeNull()
+ })
+
+ it('bounds host and notification identifiers before URI key expansion', () => {
+ const event = {
+ type: 'dismiss',
+ notificationId: 'n'.repeat(MOBILE_NOTIFICATION_ID_MAX_BYTES)
+ }
+
+ expect(
+ measureMobileNotificationDeliveryBytes(
+ event,
+ 'h'.repeat(MOBILE_NOTIFICATION_HOST_ID_MAX_BYTES)
+ )
+ ).not.toBeNull()
+ expect(
+ measureMobileNotificationDeliveryBytes(
+ { ...event, notificationId: `${event.notificationId}n` },
+ 'host-1'
+ )
+ ).toBeNull()
+ expect(
+ measureMobileNotificationDeliveryBytes(
+ event,
+ 'h'.repeat(MOBILE_NOTIFICATION_HOST_ID_MAX_BYTES + 1)
+ )
+ ).toBeNull()
+ })
+
+ it('caps aggregate active bytes and restores capacity on release', () => {
+ const ledger = new MobileNotificationDeliveryLedger()
+ const baseEvent = { type: 'notification', title: '', body: '' }
+ const baseBytes = measureMobileNotificationDeliveryBytes(baseEvent, 'host-1')!
+ const exactEvent = {
+ ...baseEvent,
+ body: 'x'.repeat(MOBILE_NOTIFICATION_EVENT_MAX_BYTES - baseBytes)
+ }
+ const exactClaims = MOBILE_NOTIFICATION_ACTIVE_MAX_BYTES / MOBILE_NOTIFICATION_EVENT_MAX_BYTES
+ const releases = Array.from({ length: exactClaims }, () => ledger.claim(exactEvent, 'host-1'))
+
+ expect(releases.every(Boolean)).toBe(true)
+ expect(ledger.claim(exactEvent, 'host-1')).toBeNull()
+ releases[0]?.()
+ expect(ledger.claim(exactEvent, 'host-1')).not.toBeNull()
+ })
+
+ it('rejects a scheduled key that alone exceeds the aggregate budget', () => {
+ const registry = new MobileScheduledNotificationRegistry()
+ const maximumKeyCharacters = (MOBILE_SCHEDULED_NOTIFICATION_MAX_RETAINED_BYTES - 64) / 2
+
+ expect(registry.reserve('x'.repeat(maximumKeyCharacters))).not.toBeNull()
+ registry.resetForTests()
+ expect(registry.reserve('x'.repeat(maximumKeyCharacters + 1))).toBeNull()
+ })
+
+ it('retains exact-limit native ids and rejects oversized values', () => {
+ const registry = new MobileScheduledNotificationRegistry()
+ const exactState = registry.reserve('exact')!.state
+ const oversizedState = registry.reserve('oversized')!.state
+
+ expect(
+ registry.retainIdentifier(exactState, 'x'.repeat(MOBILE_NATIVE_NOTIFICATION_ID_MAX_BYTES))
+ ).toBe(true)
+ expect(
+ registry.retainIdentifier(
+ oversizedState,
+ 'x'.repeat(MOBILE_NATIVE_NOTIFICATION_ID_MAX_BYTES + 1)
+ )
+ ).toBe(false)
+ expect(registry.getRetainedBytesForTests()).toBeLessThanOrEqual(
+ MOBILE_SCHEDULED_NOTIFICATION_MAX_RETAINED_BYTES
+ )
+ })
+})
diff --git a/mobile/src/notifications/mobile-notification-retention.ts b/mobile/src/notifications/mobile-notification-retention.ts
new file mode 100644
index 00000000000..c68f11b66b5
--- /dev/null
+++ b/mobile/src/notifications/mobile-notification-retention.ts
@@ -0,0 +1,95 @@
+import { measureUtf8ByteLength } from '../../../src/shared/utf8-byte-limits'
+
+export const MOBILE_NOTIFICATION_EVENT_MAX_BYTES = 256 * 1024
+export const MOBILE_NOTIFICATION_ACTIVE_MAX_BYTES = 4 * 1024 * 1024
+export const MOBILE_NOTIFICATION_ACTIVE_MAX_ENTRIES = 256
+export const MOBILE_NOTIFICATION_HOST_ID_MAX_BYTES = 8 * 1024
+export const MOBILE_NOTIFICATION_ID_MAX_BYTES = 8 * 1024
+export const MOBILE_NOTIFICATION_WORKTREE_ID_MAX_BYTES = 16 * 1024
+
+type MobileNotificationRetentionEvent = {
+ type: string
+ source?: string
+ title?: string
+ body?: string
+ worktreeId?: string
+ notificationId?: string
+}
+
+function boundedStringBytes(value: unknown, maxBytes: number): number | null {
+ if (value === undefined) {
+ return 0
+ }
+ if (typeof value !== 'string') {
+ return null
+ }
+ const measured = measureUtf8ByteLength(value, { stopAfterBytes: maxBytes })
+ return measured.exceededLimit ? null : measured.byteLength
+}
+
+export function isMobileNotificationHostIdRetainable(hostId: string): boolean {
+ return boundedStringBytes(hostId, MOBILE_NOTIFICATION_HOST_ID_MAX_BYTES) !== null
+}
+
+export function measureMobileNotificationDeliveryBytes(
+ event: MobileNotificationRetentionEvent,
+ hostId: string
+): number | null {
+ const hostBytes = boundedStringBytes(hostId, MOBILE_NOTIFICATION_HOST_ID_MAX_BYTES)
+ const notificationIdBytes = boundedStringBytes(
+ event.notificationId,
+ MOBILE_NOTIFICATION_ID_MAX_BYTES
+ )
+ const worktreeIdBytes = boundedStringBytes(
+ event.worktreeId,
+ MOBILE_NOTIFICATION_WORKTREE_ID_MAX_BYTES
+ )
+ if (hostBytes === null || notificationIdBytes === null || worktreeIdBytes === null) {
+ return null
+ }
+
+ let retainedBytes = 256 + hostBytes + notificationIdBytes + worktreeIdBytes
+ for (const value of [event.type, event.source, event.title, event.body]) {
+ const remaining = MOBILE_NOTIFICATION_EVENT_MAX_BYTES - retainedBytes
+ const valueBytes = boundedStringBytes(value, remaining)
+ if (valueBytes === null) {
+ return null
+ }
+ retainedBytes += valueBytes
+ }
+ return retainedBytes <= MOBILE_NOTIFICATION_EVENT_MAX_BYTES ? retainedBytes : null
+}
+
+export class MobileNotificationDeliveryLedger {
+ private activeEntries = 0
+ private activeBytes = 0
+ private maxEntries = MOBILE_NOTIFICATION_ACTIVE_MAX_ENTRIES
+
+ claim(event: MobileNotificationRetentionEvent, hostId: string): (() => void) | null {
+ const retainedBytes = measureMobileNotificationDeliveryBytes(event, hostId)
+ if (
+ retainedBytes === null ||
+ this.activeEntries >= this.maxEntries ||
+ this.activeBytes + retainedBytes > MOBILE_NOTIFICATION_ACTIVE_MAX_BYTES
+ ) {
+ return null
+ }
+ this.activeEntries += 1
+ this.activeBytes += retainedBytes
+ let released = false
+ return () => {
+ if (released) {
+ return
+ }
+ released = true
+ this.activeEntries = Math.max(0, this.activeEntries - 1)
+ this.activeBytes = Math.max(0, this.activeBytes - retainedBytes)
+ }
+ }
+
+ resetForTests(maxEntries?: number): void {
+ this.activeEntries = 0
+ this.activeBytes = 0
+ this.maxEntries = maxEntries ?? MOBILE_NOTIFICATION_ACTIVE_MAX_ENTRIES
+ }
+}
diff --git a/mobile/src/notifications/mobile-notifications.test.ts b/mobile/src/notifications/mobile-notifications.test.ts
index b1f7c89578e..5fce317b915 100644
--- a/mobile/src/notifications/mobile-notifications.test.ts
+++ b/mobile/src/notifications/mobile-notifications.test.ts
@@ -313,9 +313,11 @@ describe('subscribeToDesktopNotifications', () => {
// Why: notificationId is unique per completion, so the map grew unbounded when
// the desktop never sent a dismiss (the remote-mobile case). It is now capped.
- it('evicts the oldest scheduled entry once the cap is exceeded', async () => {
+ it('dismisses the oldest scheduled entry when reusing its bounded slot', async () => {
setScheduledNotificationsMaxForTests(1)
try {
+ vi.mocked(Notifications.scheduleNotificationAsync).mockReset()
+ vi.mocked(Notifications.dismissNotificationAsync).mockReset()
vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
status: 'granted',
@@ -341,10 +343,12 @@ describe('subscribeToDesktopNotifications', () => {
onEvent?.({ type: 'notification', title: 't', body: 'b', notificationId: 'agent:new' })
await flushAsync()
- // The older entry was evicted by the cap: dismissing it is a no-op...
+ expect(Notifications.dismissNotificationAsync).toHaveBeenCalledWith('scheduled-old')
+
+ // The older entry was already dismissed during eviction, so a later desktop dismiss is a no-op...
onEvent?.({ type: 'dismiss', notificationId: 'agent:old' })
await flushAsync()
- expect(Notifications.dismissNotificationAsync).not.toHaveBeenCalledWith('scheduled-old')
+ expect(Notifications.dismissNotificationAsync).toHaveBeenCalledTimes(1)
// ...while the most-recent entry is retained and still dismissable.
onEvent?.({ type: 'dismiss', notificationId: 'agent:new' })
@@ -354,6 +358,105 @@ describe('subscribeToDesktopNotifications', () => {
setScheduledNotificationsMaxForTests()
}
})
+
+ it('keeps overload gaps replayable and advances after reconnect catches up', async () => {
+ setScheduledNotificationsMaxForTests(1)
+ const firstSchedule = makeDeferred()
+ try {
+ vi.mocked(Notifications.scheduleNotificationAsync).mockReset()
+ vi.mocked(Notifications.dismissNotificationAsync).mockReset()
+ vi.mocked(loadPushNotificationsEnabled).mockResolvedValue(true)
+ vi.mocked(Notifications.getPermissionsAsync).mockResolvedValue({
+ status: 'granted',
+ canAskAgain: true
+ } as never)
+ vi.mocked(Notifications.scheduleNotificationAsync)
+ .mockReturnValueOnce(firstSchedule.promise)
+ .mockResolvedValueOnce('scheduled-after-drain')
+ .mockResolvedValueOnce('scheduled-replayed')
+ vi.mocked(Notifications.dismissNotificationAsync).mockResolvedValue(undefined)
+ let onEvent: ((data: unknown) => void) | null = null
+ const client = {
+ subscribe: vi.fn((_method, _params, callback: (data: unknown) => void) => {
+ onEvent = callback
+ return vi.fn()
+ }),
+ getState: vi.fn(() => 'connected'),
+ sendRequest: vi.fn(async (method: string) => {
+ if (method === 'notifications.getMissedSince') {
+ return {
+ ok: true,
+ result: {
+ notifications: [
+ {
+ type: 'notification',
+ title: 'two',
+ body: 'two',
+ notificationId: 'two',
+ notificationSeq: 2
+ },
+ {
+ type: 'notification',
+ title: 'three',
+ body: 'three',
+ notificationId: 'three',
+ notificationSeq: 3
+ }
+ ]
+ }
+ } as never
+ }
+ return { ok: true, result: undefined } as never
+ })
+ } as unknown as RpcClient
+
+ subscribeToDesktopNotifications(client, 'host-hung')
+ onEvent?.({ type: 'ready', subscriptionId: 'sub-1' })
+ onEvent?.({
+ type: 'notification',
+ title: 'one',
+ body: 'one',
+ notificationId: 'one',
+ notificationSeq: 1
+ })
+ await flushAsync()
+ onEvent?.({
+ type: 'notification',
+ title: 'two',
+ body: 'two',
+ notificationId: 'two',
+ notificationSeq: 2
+ })
+ await flushAsync()
+ expect(Notifications.scheduleNotificationAsync).toHaveBeenCalledOnce()
+ expect(AsyncStorage.setItem).toHaveBeenCalledTimes(1)
+
+ firstSchedule.resolve('scheduled-first')
+ await flushAsync()
+ onEvent?.({
+ type: 'notification',
+ title: 'three',
+ body: 'three',
+ notificationId: 'three',
+ notificationSeq: 3
+ })
+ await flushAsync()
+ expect(Notifications.scheduleNotificationAsync).toHaveBeenCalledTimes(2)
+ expect(AsyncStorage.setItem).toHaveBeenCalledTimes(1)
+
+ onEvent?.({ type: 'ready', subscriptionId: 'sub-2' })
+ await flushAsync()
+ await flushAsync()
+ expect(Notifications.scheduleNotificationAsync).toHaveBeenCalledTimes(3)
+ expect(AsyncStorage.setItem).toHaveBeenLastCalledWith(
+ 'orca:mobileNotificationsLastSeq:host-hung',
+ '3'
+ )
+ } finally {
+ firstSchedule.resolve('scheduled-first')
+ setScheduledNotificationsMaxForTests()
+ }
+ })
})
// Why: #8129 catch-up. On a reconnect the live stream re-emits `ready`; the
diff --git a/mobile/src/notifications/mobile-notifications.ts b/mobile/src/notifications/mobile-notifications.ts
index 7e1444d9b0c..938c5c56ce0 100644
--- a/mobile/src/notifications/mobile-notifications.ts
+++ b/mobile/src/notifications/mobile-notifications.ts
@@ -1,8 +1,14 @@
-import * as Notifications from 'expo-notifications'
-import { Platform } from 'react-native'
import type { RpcClient } from '../transport/rpc-client'
-import { loadPushNotificationsEnabled } from '../storage/preferences'
-import { buildLocalNotificationData, type DesktopNotificationSource } from './notification-routing'
+import {
+ configureNotificationChannel,
+ startLocalNotificationDelivery,
+ type DismissNotificationEvent,
+ type NotificationEvent
+} from './mobile-notification-delivery'
+import {
+ isMobileNotificationHostIdRetainable,
+ measureMobileNotificationDeliveryBytes
+} from './mobile-notification-retention'
import {
createSeenNotificationGuard,
loadLastSeenSeq,
@@ -10,249 +16,81 @@ import {
seenKeyForEvent
} from './notification-reconnect-catchup'
-type NotificationEvent = {
- type: 'notification'
- source: DesktopNotificationSource
- title: string
- body: string
- worktreeId?: string
- notificationId?: string
- // Desktop-assigned seq for reconnect catch-up (#8129); optional since older runtimes may omit it.
- notificationSeq?: number
-}
-
-type DismissNotificationEvent = {
- type: 'dismiss'
- notificationId: string
- notificationSeq?: number
-}
+export {
+ ensureNotificationPermissions,
+ getNotificationPermissionState,
+ setScheduledNotificationsMaxForTests
+} from './mobile-notification-delivery'
+export type { NotificationPermissionState } from './mobile-notification-delivery'
type SubscribeResult = {
type: 'ready'
subscriptionId: string
}
-type ScheduledNotificationState = {
- identifier?: string
- pending?: Promise
- dismissAfterSchedule?: boolean
-}
-
-const scheduledNotificationsByHostAndNotificationId = new Map()
-
-// Why: keys never repeat and are only freed on desktop dismiss (which remote users often miss), so bound the map to stop unbounded growth.
-const MAX_SCHEDULED_NOTIFICATIONS = 256
-let maxScheduledNotifications = MAX_SCHEDULED_NOTIFICATIONS
-
-function getStoredNotificationKey(hostId: string, notificationId: string): string {
- return `${encodeURIComponent(hostId)}:${encodeURIComponent(notificationId)}`
-}
-
-// Evict oldest settled entries (never mid-schedule); Map iteration is insertion order so the first match is oldest.
-function boundScheduledNotifications(): void {
- while (scheduledNotificationsByHostAndNotificationId.size > maxScheduledNotifications) {
- let evicted = false
- for (const [key, state] of scheduledNotificationsByHostAndNotificationId) {
- if (!state.pending) {
- scheduledNotificationsByHostAndNotificationId.delete(key)
- evicted = true
- break
- }
- }
- if (!evicted) {
- break
- }
- }
-}
-
-/** Test-only: override the cap (pass no arg to restore the default). */
-export function setScheduledNotificationsMaxForTests(max?: number): void {
- maxScheduledNotifications = max ?? MAX_SCHEDULED_NOTIFICATIONS
-}
-
-export type NotificationPermissionState = {
- granted: boolean
- status: string
- canAskAgain: boolean
- authorizationReflectsUserChoice: boolean
-}
-
-export async function getNotificationPermissionState(): Promise {
- const { status, canAskAgain } = await Notifications.getPermissionsAsync()
- return {
- granted: status === 'granted',
- status,
- canAskAgain,
- // Why: Android <33 has no runtime notification permission, so "granted" is capability, not user consent.
- authorizationReflectsUserChoice:
- status === 'granted' && (Platform.OS !== 'android' || Number(Platform.Version) >= 33)
- }
-}
-
-// Why: re-read OS state every call — users can change it in Settings while Orca is backgrounded.
-export async function ensureNotificationPermissions(): Promise {
- const existing = await getNotificationPermissionState()
- if (existing.granted) {
- return true
- }
-
- const { status } = await Notifications.requestPermissionsAsync()
- return status === 'granted'
-}
-
-function configureNotificationChannel(): void {
- if (Platform.OS === 'android') {
- void Notifications.setNotificationChannelAsync('orca-desktop', {
- name: 'Desktop Notifications',
- importance: Notifications.AndroidImportance.HIGH,
- vibrationPattern: [0, 250],
- lightColor: '#6366f1'
- })
- }
-}
-
-async function showLocalNotification(event: NotificationEvent, hostId: string): Promise {
- const storedKey = event.notificationId
- ? getStoredNotificationKey(hostId, event.notificationId)
- : null
-
- if (!storedKey) {
- const enabled = await loadPushNotificationsEnabled()
- if (!enabled) {
- return
- }
-
- const granted = await ensureNotificationPermissions()
- if (!granted) {
- return
- }
-
- await Notifications.scheduleNotificationAsync({
- content: {
- title: event.title,
- body: event.body,
- data: buildLocalNotificationData(event, hostId),
- ...(Platform.OS === 'android' ? { channelId: 'orca-desktop' } : {})
- },
- trigger: null
- })
- return
- }
-
- let state = scheduledNotificationsByHostAndNotificationId.get(storedKey)
- if (state?.pending) {
- return
- }
- if (!state) {
- state = {}
- scheduledNotificationsByHostAndNotificationId.set(storedKey, state)
- }
- const notificationState = state
-
- const pending = (async () => {
- const enabled = await loadPushNotificationsEnabled()
- if (!enabled) {
- return null
- }
-
- const granted = await ensureNotificationPermissions()
- if (!granted) {
- return null
- }
-
- if (notificationState.identifier) {
- await Notifications.dismissNotificationAsync(notificationState.identifier).catch(() => {})
- notificationState.identifier = undefined
- }
-
- return Notifications.scheduleNotificationAsync({
- content: {
- title: event.title,
- body: event.body,
- data: buildLocalNotificationData(event, hostId),
- ...(Platform.OS === 'android' ? { channelId: 'orca-desktop' } : {})
- },
- trigger: null
- })
- })()
- notificationState.pending = pending
-
- try {
- const scheduledIdentifier = await pending
- if (!scheduledIdentifier) {
- if (!notificationState.identifier) {
- scheduledNotificationsByHostAndNotificationId.delete(storedKey)
- }
- return
- }
- if (notificationState.dismissAfterSchedule) {
- notificationState.dismissAfterSchedule = false
- scheduledNotificationsByHostAndNotificationId.delete(storedKey)
- await Notifications.dismissNotificationAsync(scheduledIdentifier).catch(() => {})
- return
- }
- notificationState.identifier = scheduledIdentifier
- boundScheduledNotifications()
- } finally {
- if (notificationState.pending === pending) {
- notificationState.pending = undefined
- notificationState.dismissAfterSchedule = false
- }
- }
-}
-
-async function dismissLocalNotification(
- event: DismissNotificationEvent,
- hostId: string
-): Promise {
- if (!event.notificationId) {
- return
- }
- const storedKey = getStoredNotificationKey(hostId, event.notificationId)
- const state = scheduledNotificationsByHostAndNotificationId.get(storedKey)
- if (!state) {
- return
- }
- if (state.pending) {
- // Why: dismiss can arrive while the OS is still scheduling; defer it so no stale banner survives.
- state.dismissAfterSchedule = true
- return
- }
- if (!state.identifier) {
- return
- }
- scheduledNotificationsByHostAndNotificationId.delete(storedKey)
- await Notifications.dismissNotificationAsync(state.identifier).catch(() => {})
-}
-
// Per-connection subscription; a reconnect `ready` triggers watermarked catch-up (#8129) so already-pushed events aren't re-sent.
export function subscribeToDesktopNotifications(client: RpcClient, hostId: string): () => void {
+ if (!isMobileNotificationHostIdRetainable(hostId)) {
+ return () => {}
+ }
configureNotificationChannel()
let subscriptionId: string | null = null
let disposed = false
// Highest seq delivered (live or replay) this connection; persisted per-host so cold start resumes from the right cut.
let lastDeliveredSeq = 0
+ let watermarkBlockedThroughSeq: number | null = null
// Why: defense-in-depth dedup for replayed events if the desktop's bounded buffer evicted across a reconnect boundary.
const seenReplay = createSeenNotificationGuard()
- function deliverLive(
- type: 'notification' | 'dismiss',
- event: NotificationEvent | DismissNotificationEvent
- ): Promise {
- if (event.notificationSeq != null && event.notificationSeq > lastDeliveredSeq) {
- lastDeliveredSeq = event.notificationSeq
- void saveLastSeenSeq(hostId, lastDeliveredSeq)
+ function eventSequence(event: NotificationEvent | DismissNotificationEvent): number | null {
+ return typeof event.notificationSeq === 'number' && Number.isSafeInteger(event.notificationSeq)
+ ? event.notificationSeq
+ : null
+ }
+
+ function advanceDeliveredWatermark(
+ event: NotificationEvent | DismissNotificationEvent,
+ replay: boolean
+ ): void {
+ const seq = eventSequence(event)
+ if (seq == null || seq <= lastDeliveredSeq) {
+ return
}
- // Why (#8129): mark seen on the live path too, so a later replay of an already-pushed id dedups instead of double-pushing.
+ if (watermarkBlockedThroughSeq !== null) {
+ if (!replay) {
+ watermarkBlockedThroughSeq = Math.max(watermarkBlockedThroughSeq, seq)
+ return
+ }
+ if (seq < watermarkBlockedThroughSeq) {
+ return
+ }
+ watermarkBlockedThroughSeq = null
+ }
+ lastDeliveredSeq = seq
+ void saveLastSeenSeq(hostId, lastDeliveredSeq)
+ }
+
+ function deliverLive(
+ event: NotificationEvent | DismissNotificationEvent,
+ replay = false
+ ): Promise {
+ const delivery = startLocalNotificationDelivery(event, hostId)
+ if (!delivery) {
+ const seq = eventSequence(event)
+ if (seq !== null && seq > lastDeliveredSeq) {
+ // Why: advancing past a dropped event would make reconnect catch-up permanently skip it.
+ watermarkBlockedThroughSeq = Math.max(watermarkBlockedThroughSeq ?? seq, seq)
+ }
+ return Promise.resolve()
+ }
+ // Why (#8129): only accepted work is seen; overload drops remain eligible for reconnect catch-up.
const key = seenKeyForEvent(event)
if (key) {
seenReplay.add(key)
}
- if (type === 'notification') {
- return showLocalNotification(event as NotificationEvent, hostId)
- }
- return dismissLocalNotification(event as DismissNotificationEvent, hostId)
+ advanceDeliveredWatermark(event, replay)
+ return delivery
}
// Why: desktop cuts by seq > lastSeenSeq, so re-fetching from the watermark is idempotent (seenReplay guards residual overlap).
@@ -272,17 +110,19 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin
.catch(() => [])
for (const raw of missed) {
const event = raw as NotificationEvent | DismissNotificationEvent
- const key = seenKeyForEvent(event)
- if (key && seenReplay.has(key)) {
+ if (measureMobileNotificationDeliveryBytes(event, hostId) === null) {
+ await deliverLive(event, true)
continue
}
- if (key) {
- seenReplay.add(key)
+ const key = seenKeyForEvent(event)
+ if (key && seenReplay.has(key)) {
+ advanceDeliveredWatermark(event, true)
+ continue
}
if (event.type === 'notification') {
- await deliverLive('notification', event)
+ await deliverLive(event, true)
} else if (event.type === 'dismiss') {
- await deliverLive('dismiss', event)
+ await deliverLive(event, true)
}
}
}
@@ -331,9 +171,9 @@ export function subscribeToDesktopNotifications(client: RpcClient, hostId: strin
return
}
if (event.type === 'notification') {
- void deliverLive('notification', event as NotificationEvent)
+ void deliverLive(event as NotificationEvent)
} else if (event.type === 'dismiss') {
- void deliverLive('dismiss', event as DismissNotificationEvent)
+ void deliverLive(event as DismissNotificationEvent)
}
})
diff --git a/mobile/src/notifications/mobile-scheduled-notification-registry.ts b/mobile/src/notifications/mobile-scheduled-notification-registry.ts
new file mode 100644
index 00000000000..27f662b0453
--- /dev/null
+++ b/mobile/src/notifications/mobile-scheduled-notification-registry.ts
@@ -0,0 +1,127 @@
+import { measureUtf8ByteLength } from '../../../src/shared/utf8-byte-limits'
+
+export const MOBILE_SCHEDULED_NOTIFICATION_MAX_ENTRIES = 256
+export const MOBILE_SCHEDULED_NOTIFICATION_MAX_RETAINED_BYTES = 2 * 1024 * 1024
+export const MOBILE_NATIVE_NOTIFICATION_ID_MAX_BYTES = 8 * 1024
+
+export type MobileScheduledNotificationState = {
+ identifier?: string
+ pending?: Promise
+ dismissAfterSchedule?: boolean
+ retainedKeyBytes: number
+ retainedIdentifierBytes: number
+ retained: boolean
+}
+
+export type MobileScheduledNotificationReservation = {
+ state: MobileScheduledNotificationState
+ evictedIdentifiers: string[]
+}
+
+function retainedStringBytes(value: string): number {
+ return 64 + value.length * 2
+}
+
+export class MobileScheduledNotificationRegistry {
+ private readonly entries = new Map()
+ private retainedBytes = 0
+ private maxEntries = MOBILE_SCHEDULED_NOTIFICATION_MAX_ENTRIES
+
+ get(key: string): MobileScheduledNotificationState | undefined {
+ return this.entries.get(key)
+ }
+
+ reserve(key: string): MobileScheduledNotificationReservation | null {
+ const retainedKeyBytes = retainedStringBytes(key)
+ if (retainedKeyBytes > MOBILE_SCHEDULED_NOTIFICATION_MAX_RETAINED_BYTES) {
+ return null
+ }
+ const evictedIdentifiers: string[] = []
+ while (
+ this.entries.size >= this.maxEntries ||
+ this.retainedBytes + retainedKeyBytes > MOBILE_SCHEDULED_NOTIFICATION_MAX_RETAINED_BYTES
+ ) {
+ const settled = this.findOldestSettled()
+ if (!settled) {
+ return null
+ }
+ this.delete(settled[0])
+ if (settled[1].identifier) {
+ evictedIdentifiers.push(settled[1].identifier)
+ }
+ }
+ const state: MobileScheduledNotificationState = {
+ retainedKeyBytes,
+ retainedIdentifierBytes: 0,
+ retained: true
+ }
+ this.entries.set(key, state)
+ this.retainedBytes += retainedKeyBytes
+ return { state, evictedIdentifiers }
+ }
+
+ delete(key: string): boolean {
+ const state = this.entries.get(key)
+ if (!state) {
+ return false
+ }
+ this.entries.delete(key)
+ this.retainedBytes -= state.retainedKeyBytes + state.retainedIdentifierBytes
+ state.retained = false
+ return true
+ }
+
+ clearIdentifier(state: MobileScheduledNotificationState): void {
+ if (!state.identifier) {
+ return
+ }
+ if (state.retained) {
+ this.retainedBytes -= state.retainedIdentifierBytes
+ }
+ state.identifier = undefined
+ state.retainedIdentifierBytes = 0
+ }
+
+ retainIdentifier(state: MobileScheduledNotificationState, identifier: string): boolean {
+ const measurement = measureUtf8ByteLength(identifier, {
+ stopAfterBytes: MOBILE_NATIVE_NOTIFICATION_ID_MAX_BYTES
+ })
+ if (measurement.exceededLimit || !state.retained) {
+ return false
+ }
+ const identifierBytes = retainedStringBytes(identifier)
+ if (
+ this.retainedBytes - state.retainedIdentifierBytes + identifierBytes >
+ MOBILE_SCHEDULED_NOTIFICATION_MAX_RETAINED_BYTES
+ ) {
+ return false
+ }
+ this.clearIdentifier(state)
+ state.identifier = identifier
+ state.retainedIdentifierBytes = identifierBytes
+ this.retainedBytes += identifierBytes
+ return true
+ }
+
+ resetForTests(maxEntries?: number): void {
+ for (const state of this.entries.values()) {
+ state.retained = false
+ }
+ this.entries.clear()
+ this.retainedBytes = 0
+ this.maxEntries = maxEntries ?? MOBILE_SCHEDULED_NOTIFICATION_MAX_ENTRIES
+ }
+
+ getRetainedBytesForTests(): number {
+ return this.retainedBytes
+ }
+
+ private findOldestSettled(): [string, MobileScheduledNotificationState] | null {
+ for (const entry of this.entries) {
+ if (!entry[1].pending) {
+ return entry
+ }
+ }
+ return null
+ }
+}
diff --git a/mobile/src/notifications/notification-reconnect-catchup.ts b/mobile/src/notifications/notification-reconnect-catchup.ts
index d40af93a090..22172cbfdbf 100644
--- a/mobile/src/notifications/notification-reconnect-catchup.ts
+++ b/mobile/src/notifications/notification-reconnect-catchup.ts
@@ -1,4 +1,6 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
+import { measureUtf8ByteLength } from '../../../src/shared/utf8-byte-limits'
+import { MOBILE_NOTIFICATION_ID_MAX_BYTES } from './mobile-notification-retention'
// Why: the reconnect catch-up watermark + dedup helpers for #8129, extracted
// from mobile-notifications.ts so that file stays under its max-lines budget.
@@ -84,14 +86,23 @@ export function seenKeyForEvent(event: {
notificationSeq?: number
}): string | null {
const id = event.notificationId
- if (id != null && event.notificationSeq != null) {
- return `id:${id}#${event.notificationSeq}`
+ const seq =
+ typeof event.notificationSeq === 'number' && Number.isSafeInteger(event.notificationSeq)
+ ? event.notificationSeq
+ : null
+ const retainedId =
+ typeof id === 'string' &&
+ !measureUtf8ByteLength(id, { stopAfterBytes: MOBILE_NOTIFICATION_ID_MAX_BYTES }).exceededLimit
+ ? id
+ : null
+ if (retainedId !== null && seq !== null) {
+ return `id:${retainedId}#${seq}`
}
- if (id != null) {
- return `id:${id}`
+ if (retainedId !== null) {
+ return `id:${retainedId}`
}
- if (event.notificationSeq != null) {
- return `seq:${event.notificationSeq}`
+ if (seq !== null) {
+ return `seq:${seq}`
}
return null
}
diff --git a/mobile/src/session/MobileNativeChatComposer.test.ts b/mobile/src/session/MobileNativeChatComposer.test.ts
index ef2c1012603..36006d2f633 100644
--- a/mobile/src/session/MobileNativeChatComposer.test.ts
+++ b/mobile/src/session/MobileNativeChatComposer.test.ts
@@ -7,6 +7,7 @@ vi.mock('react-native', async () => {
const React = await import('react')
return {
ActivityIndicator: 'ActivityIndicator',
+ Image: 'Image',
Pressable: 'Pressable',
ScrollView: ({ children, ...props }: { children?: unknown }) =>
React.createElement('ScrollView', props, children),
@@ -24,7 +25,8 @@ vi.mock('lucide-react-native', () => ({
ArrowUp: 'ArrowUp',
ImagePlus: 'ImagePlus',
Mic: 'Mic',
- Square: 'Square'
+ Square: 'Square',
+ X: 'X'
}))
function suppressRendererWarning(): () => void {
@@ -112,6 +114,61 @@ describe('MobileNativeChatComposer', () => {
expect(onSend).not.toHaveBeenCalled()
})
+ it('renders a removable thumbnail for each pending image attachment', async () => {
+ const onRemoveAttachment = vi.fn()
+ const restore = suppressRendererWarning()
+ try {
+ await act(async () => {
+ renderer = create(
+ createElement(MobileNativeChatComposer, {
+ value: '',
+ onChangeText: vi.fn(),
+ onSend: vi.fn().mockResolvedValue(true),
+ attachments: [
+ { id: 'img-1', path: '/tmp/a.png', previewUri: 'file:///a.png' },
+ { id: 'img-2', path: '/tmp/b.png', previewUri: 'file:///b.png' }
+ ],
+ onRemoveAttachment
+ })
+ )
+ })
+ } finally {
+ restore()
+ }
+ const thumbs = renderer!.root.findAll((node) => node.type === 'Image') as Array<{
+ props: { source: { uri: string } }
+ }>
+ expect(thumbs.map((t) => t.props.source.uri)).toEqual(['file:///a.png', 'file:///b.png'])
+
+ const remove = renderer!.root.findAll(
+ (node) => node.type === 'Pressable' && node.props.accessibilityLabel === 'Remove image'
+ ) as Array<{ props: { onPress: () => void } }>
+ remove[1].props.onPress()
+ expect(onRemoveAttachment).toHaveBeenCalledWith('img-2')
+ })
+
+ it('enables send with an attached image even when the text is empty', async () => {
+ const onSend = vi.fn().mockResolvedValue(true)
+ const restore = suppressRendererWarning()
+ try {
+ await act(async () => {
+ renderer = create(
+ createElement(MobileNativeChatComposer, {
+ value: '',
+ onChangeText: vi.fn(),
+ onSend,
+ attachments: [{ id: 'img-1', path: '/tmp/a.png', previewUri: 'file:///a.png' }]
+ })
+ )
+ })
+ } finally {
+ restore()
+ }
+ expect(sendButton().props).toMatchObject({ disabled: false })
+ await act(async () => sendButton().props.onPress())
+ expect(onSend).toHaveBeenCalledWith('')
+ })
+
it('moves the caret to the insert point after an autocomplete pick, then releases control', async () => {
const restore = suppressRendererWarning()
try {
diff --git a/mobile/src/session/MobileNativeChatComposer.tsx b/mobile/src/session/MobileNativeChatComposer.tsx
index ba02fe3d44b..58508be77b5 100644
--- a/mobile/src/session/MobileNativeChatComposer.tsx
+++ b/mobile/src/session/MobileNativeChatComposer.tsx
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import {
ActivityIndicator,
+ Image,
Pressable,
ScrollView,
StyleSheet,
@@ -8,13 +9,14 @@ import {
TextInput,
View
} from 'react-native'
-import { ArrowUp, ImagePlus, Mic, Square } from 'lucide-react-native'
+import { ArrowUp, ImagePlus, Mic, Square, X } from 'lucide-react-native'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import {
applyAutocomplete,
detectAutocompleteTrigger,
rankSuggestions
} from './mobile-native-chat-autocomplete'
+import type { PendingNativeChatImage } from './mobile-native-chat-image-attachment'
// Common agent slash commands offered as autocomplete; sending them is just text
// to the agent's terminal, so the set is intentionally provider-agnostic.
@@ -30,6 +32,7 @@ const SLASH_COMMANDS = [
]
const NO_FILE_PATHS: string[] = []
+const NO_ATTACHMENTS: PendingNativeChatImage[] = []
type Props = {
/** Controlled composer text — owned by the parent so dictation can write to it. */
@@ -37,6 +40,10 @@ type Props = {
onChangeText: (text: string) => void
onSend: (text: string) => Promise
onAttachImage?: () => void
+ /** Images picked-and-uploaded but not yet sent — shown as removable thumbnails
+ * and ridden along on the next send (desktop native-chat parity). */
+ attachments?: PendingNativeChatImage[]
+ onRemoveAttachment?: (id: string) => void
isAttaching?: boolean
onMicPress?: () => void
micActive?: boolean
@@ -55,6 +62,8 @@ export function MobileNativeChatComposer({
onChangeText,
onSend,
onAttachImage,
+ attachments = NO_ATTACHMENTS,
+ onRemoveAttachment,
isAttaching = false,
onMicPress,
micActive = false,
@@ -76,7 +85,10 @@ export function MobileNativeChatComposer({
const sendingRef = useRef(false)
const [sending, setSending] = useState(false)
const trimmed = value.trim()
- const canSend = trimmed.length > 0 && !disabled && !sending && !isAttaching
+ // An attached image alone is a valid send (desktop parity), so the image rides
+ // along even when the user sends no accompanying text.
+ const canSend =
+ (trimmed.length > 0 || attachments.length > 0) && !disabled && !sending && !isAttaching
const trigger = useMemo(() => detectAutocompleteTrigger(value, cursor), [value, cursor])
const suggestions = useMemo(() => {
@@ -145,6 +157,35 @@ export function MobileNativeChatComposer({
) : null}
+ {attachments.length > 0 ? (
+
+ {attachments.map((attachment) => (
+
+
+ {onRemoveAttachment ? (
+ onRemoveAttachment(attachment.id)}
+ hitSlop={8}
+ >
+
+
+ ) : null}
+
+ ))}
+
+ ) : null}
{onAttachImage ? (
{
+ const React = await import('react')
+ return {
+ Image: 'Image',
+ Pressable: 'Pressable',
+ Text: ({ children, ...props }: { children?: unknown }) =>
+ React.createElement('Text', props, children),
+ View: ({ children, ...props }: { children?: unknown }) =>
+ React.createElement('View', props, children),
+ StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }
+ }
+})
+vi.mock('expo-clipboard', () => ({ setStringAsync: vi.fn() }))
+vi.mock('lucide-react-native', () => ({
+ ArrowUp: 'ArrowUp',
+ ChevronDown: 'ChevronDown',
+ Copy: 'Copy',
+ SquareChevronRight: 'SquareChevronRight'
+}))
+vi.mock('../components/MobileMarkdown', () => ({ MobileMarkdown: 'MobileMarkdown' }))
+
+import { MobileNativeChatMessage } from './MobileNativeChatMessage'
+
+function userMessage(blocks: NativeChatMessage['blocks']): NativeChatMessage {
+ return { id: 'u1', role: 'user', blocks, timestamp: null, source: 'transcript' }
+}
+
+describe('MobileNativeChatMessage image-ref rendering', () => {
+ let renderer: ReactTestRenderer | null = null
+
+ beforeEach(() => {
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true
+ })
+ afterEach(() => {
+ act(() => renderer?.unmount())
+ renderer = null
+ })
+
+ function render(message: NativeChatMessage): ReactTestRenderer {
+ const original = console.error
+ const spy = vi.spyOn(console, 'error').mockImplementation((...a) => {
+ if (typeof a[0] === 'string' && a[0].includes('react-test-renderer is deprecated')) {
+ return
+ }
+ original(...a)
+ })
+ try {
+ act(() => {
+ renderer = create(createElement(MobileNativeChatMessage, { message }))
+ })
+ } finally {
+ spy.mockRestore()
+ }
+ return renderer!
+ }
+
+ it('renders a loadable preview URI as an image thumbnail', () => {
+ const tree = render(userMessage([{ type: 'image-ref', url: 'file:///a.jpg', alt: 'a photo' }]))
+ const image = tree.root.findByType('Image' as never)
+ expect(image.props.source).toEqual({ uri: 'file:///a.jpg' })
+ expect(image.props.accessibilityLabel).toBe('a photo')
+ })
+
+ it('prefers the url over the path when both are present', () => {
+ const tree = render(
+ userMessage([{ type: 'image-ref', url: 'file:///local.jpg', path: '/tmp/host.png' }])
+ )
+ expect(tree.root.findByType('Image' as never).props.source).toEqual({
+ uri: 'file:///local.jpg'
+ })
+ })
+
+ it('falls back to a text placeholder for a bare host path', () => {
+ // A host temp path (e.g. on an SSH host) is not loadable on the device.
+ const tree = render(userMessage([{ type: 'image-ref', path: '/tmp/host.png' }]))
+ expect(tree.root.findAllByType('Image' as never)).toHaveLength(0)
+ const texts = tree.root
+ .findAllByType('Text' as never)
+ .map((node) => String(node.children.join('')))
+ expect(texts.some((text) => text.includes('/tmp/host.png'))).toBe(true)
+ })
+})
diff --git a/mobile/src/session/MobileNativeChatMessage.tsx b/mobile/src/session/MobileNativeChatMessage.tsx
index eadfd2043f8..d8458f1b889 100644
--- a/mobile/src/session/MobileNativeChatMessage.tsx
+++ b/mobile/src/session/MobileNativeChatMessage.tsx
@@ -1,5 +1,5 @@
import { memo, useEffect, useRef, useState } from 'react'
-import { Pressable, Text, View } from 'react-native'
+import { Image, Pressable, Text, View } from 'react-native'
import * as Clipboard from 'expo-clipboard'
import { ArrowUp, ChevronDown, Copy, SquareChevronRight } from 'lucide-react-native'
import type { NativeChatBlock, NativeChatMessage } from '../../../src/shared/native-chat-types'
@@ -13,6 +13,7 @@ import {
type ToolPair
} from './mobile-native-chat-blocks'
import { diffFromText, diffFromToolCall, type DiffLine } from './mobile-native-chat-diff'
+import { isRenderableImageUri } from './mobile-native-chat-image-preview'
import { MAX_TOOL_RESULT_CHARS, styles, TEXT_SIZE } from './mobile-native-chat-message-styles'
import { nativeChatMessageText } from './mobile-native-chat-message-text'
import {
@@ -160,6 +161,19 @@ function Prose({
)
}
if (isImageRefBlock(block)) {
+ // A local preview (composer echo) or real URL renders as a thumbnail; a bare
+ // host path (not loadable on the device) falls back to a text placeholder.
+ const uri = block.url ?? block.path
+ if (isRenderableImageUri(uri)) {
+ return (
+
+ )
+ }
return (
🖼 {block.alt ?? block.path ?? block.url ?? 'image'}
diff --git a/mobile/src/session/MobileNativeChatOverlay.tsx b/mobile/src/session/MobileNativeChatOverlay.tsx
index 6acf2d305f6..b1fcfd4c368 100644
--- a/mobile/src/session/MobileNativeChatOverlay.tsx
+++ b/mobile/src/session/MobileNativeChatOverlay.tsx
@@ -1,11 +1,13 @@
import { StyleSheet, View } from 'react-native'
import { MobileNativeChatView, type MobileNativeChatInputLockReason } from './MobileNativeChatView'
+import type { MobileNativeChatImageAttachments } from './use-mobile-native-chat-image-attachments'
import type { MobileNativeChatController } from './use-mobile-native-chat-controller'
type Props = {
controller: MobileNativeChatController
- onAttachImage: () => void
- isAttaching: boolean
+ /** Native-chat image attachments: picking adds a composer chip, and sending
+ * rides the pending images along with the message text (desktop parity). */
+ images: MobileNativeChatImageAttachments
onMicPress: () => void
micActive: boolean
dictationMode: 'toggle' | 'hold'
@@ -19,8 +21,7 @@ type Props = {
* view toggles while the native surface owns the visible composer. */
export function MobileNativeChatOverlay({
controller,
- onAttachImage,
- isAttaching,
+ images,
onMicPress,
micActive,
dictationMode,
@@ -54,12 +55,14 @@ export function MobileNativeChatOverlay({
hasMore={session.hasMore}
loadingEarlier={session.loadingEarlier}
onLoadEarlier={session.loadEarlier}
- onSend={controller.handleNativeChatSend}
+ onSend={images.sendNativeChat}
pending={controller.chatPending}
composerText={controller.chatComposerText}
onComposerTextChange={controller.setChatComposerText}
- onAttachImage={onAttachImage}
- isAttaching={isAttaching}
+ onAttachImage={() => void images.attachImage('library')}
+ attachments={images.attachments}
+ onRemoveAttachment={images.removeAttachment}
+ isAttaching={images.isAttaching}
onMicPress={onMicPress}
micActive={micActive}
dictationMode={dictationMode}
diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx
index 1cfe37e5d4d..f2a23910a26 100644
--- a/mobile/src/session/MobileNativeChatView.tsx
+++ b/mobile/src/session/MobileNativeChatView.tsx
@@ -17,11 +17,13 @@ import { styles } from './mobile-native-chat-view-styles'
import {
buildMobileNativeChatTransientData,
foldMobileNativeChatMessages,
- mobileNativeChatEmptyState
+ mobileNativeChatEmptyState,
+ type MobileNativeChatPendingItem
} from './mobile-native-chat-render-data'
import { useMobileNativeChatAskDismiss } from './use-mobile-native-chat-ask-dismiss'
import { useMobileNativeChatPinchGesture } from './use-mobile-native-chat-pinch-gesture'
import { MobileAgentWorkingIndicator } from './MobileAgentWorkingIndicator'
+import type { PendingNativeChatImage } from './mobile-native-chat-image-attachment'
import { MobileNativeChatComposer } from './MobileNativeChatComposer'
import { MobileNativeChatMessage } from './MobileNativeChatMessage'
import { MobileNativeChatAsk } from './MobileNativeChatAsk'
@@ -53,11 +55,15 @@ type Props = {
onLoadEarlier?: () => void
onSend: (text: string) => Promise
/** Optimistic queued sends (owned by the route so they survive view switches). */
- pending: Array<{ id: string; text: string }>
+ /** Optimistic user echoes, including any ridden-along image preview URIs. */
+ pending: MobileNativeChatPendingItem[]
/** Controlled composer text (owned by the route so dictation can write to it). */
composerText: string
onComposerTextChange: (text: string) => void
onAttachImage?: () => void
+ /** Pending image attachments shown as composer thumbnails until the next send. */
+ attachments?: PendingNativeChatImage[]
+ onRemoveAttachment?: (id: string) => void
isAttaching?: boolean
onMicPress?: () => void
micActive?: boolean
@@ -103,6 +109,8 @@ export function MobileNativeChatView({
composerText,
onComposerTextChange,
onAttachImage,
+ attachments,
+ onRemoveAttachment,
isAttaching,
onMicPress,
micActive,
@@ -403,6 +411,8 @@ export function MobileNativeChatView({
onChangeText={onComposerTextChange}
onSend={handleSend}
onAttachImage={onAttachImage}
+ attachments={attachments}
+ onRemoveAttachment={onRemoveAttachment}
isAttaching={isAttaching}
onMicPress={onMicPress}
micActive={micActive}
diff --git a/mobile/src/session/mobile-image-base64-accumulator.test.ts b/mobile/src/session/mobile-image-base64-accumulator.test.ts
new file mode 100644
index 00000000000..d6da669b7d3
--- /dev/null
+++ b/mobile/src/session/mobile-image-base64-accumulator.test.ts
@@ -0,0 +1,17 @@
+import { describe, expect, it } from 'vitest'
+import { MobileImageBase64Accumulator } from './mobile-image-base64-accumulator'
+
+describe('MobileImageBase64Accumulator', () => {
+ it('preserves bytes delivered as 100,000 one-byte fragments', () => {
+ const accumulator = new MobileImageBase64Accumulator()
+ const expected = Buffer.alloc(100_000)
+
+ for (let index = 0; index < expected.byteLength; index += 1) {
+ const value = index % 251
+ expected[index] = value
+ accumulator.append(Uint8Array.of(value))
+ }
+
+ expect(accumulator.finish()).toBe(expected.toString('base64'))
+ })
+})
diff --git a/mobile/src/session/mobile-image-base64-accumulator.ts b/mobile/src/session/mobile-image-base64-accumulator.ts
new file mode 100644
index 00000000000..079a4d89a0e
--- /dev/null
+++ b/mobile/src/session/mobile-image-base64-accumulator.ts
@@ -0,0 +1,40 @@
+import { Buffer } from 'buffer'
+
+const MOBILE_IMAGE_BASE64_CHUNK_BYTES = 256 * 1024 - 1
+
+export class MobileImageBase64Accumulator {
+ private readonly staging = new Uint8Array(MOBILE_IMAGE_BASE64_CHUNK_BYTES)
+ private readonly encodedChunks: string[] = []
+ private stagingLength = 0
+
+ append(bytes: Uint8Array): void {
+ let offset = 0
+ while (offset < bytes.byteLength) {
+ const copied = Math.min(
+ this.staging.byteLength - this.stagingLength,
+ bytes.byteLength - offset
+ )
+ this.staging.set(bytes.subarray(offset, offset + copied), this.stagingLength)
+ this.stagingLength += copied
+ offset += copied
+ if (this.stagingLength === this.staging.byteLength) {
+ this.flushStaging()
+ }
+ }
+ }
+
+ finish(): string {
+ this.flushStaging()
+ return this.encodedChunks.join('')
+ }
+
+ private flushStaging(): void {
+ if (this.stagingLength === 0) {
+ return
+ }
+ this.encodedChunks.push(
+ Buffer.from(this.staging.subarray(0, this.stagingLength)).toString('base64')
+ )
+ this.stagingLength = 0
+ }
+}
diff --git a/mobile/src/session/mobile-image-source-picker.test.ts b/mobile/src/session/mobile-image-source-picker.test.ts
index 3e35a7bed61..1a582bde3fe 100644
--- a/mobile/src/session/mobile-image-source-picker.test.ts
+++ b/mobile/src/session/mobile-image-source-picker.test.ts
@@ -1,4 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
+import { CLIPBOARD_IMAGE_MAX_SOURCE_BYTES } from '../../../src/shared/clipboard-image'
vi.mock('expo-image-picker', () => ({
requestMediaLibraryPermissionsAsync: vi.fn(),
@@ -7,6 +8,9 @@ vi.mock('expo-image-picker', () => ({
vi.mock('expo-document-picker', () => ({
getDocumentAsync: vi.fn()
}))
+vi.mock('expo-file-system', () => ({
+ File: vi.fn()
+}))
import { ImageLibraryPermissionError, pickMobileImage } from './mobile-image-source-picker'
@@ -15,17 +19,48 @@ const granted = { granted: true } as Awaited<
>
const denied = { granted: false } as typeof granted
+function fileFactory(
+ chunks: Uint8Array[],
+ options?: { fileSize?: number; handleSize?: number | null; readError?: Error }
+) {
+ const close = vi.fn()
+ const readBytes = vi.fn(() => {
+ if (options?.readError) {
+ throw options.readError
+ }
+ return chunks.shift() ?? new Uint8Array()
+ })
+ const open = vi.fn(() => ({
+ size: options?.handleSize ?? options?.fileSize ?? 0,
+ readBytes,
+ close
+ }))
+ const createFile = vi.fn(() => ({ size: options?.fileSize ?? 0, open }))
+ return { close, createFile, open, readBytes }
+}
+
describe('pickMobileImage', () => {
it('returns base64 from the photo library', async () => {
+ const bytes = new Uint8Array([0, 1, 2, 3])
+ const file = fileFactory([bytes])
+ const launchLibrary = vi.fn().mockResolvedValue({
+ canceled: false,
+ assets: [{ uri: 'file:///x.jpg', fileSize: bytes.length }]
+ })
const result = await pickMobileImage('library', {
requestLibraryPermission: vi.fn().mockResolvedValue(granted),
- launchLibrary: vi.fn().mockResolvedValue({
- canceled: false,
- assets: [{ uri: 'file:///x.jpg', base64: 'AAAA' }]
- })
+ launchLibrary,
+ createFile: file.createFile
})
- expect(result).toEqual({ base64: 'AAAA' })
+ expect(result).toEqual({
+ base64: Buffer.from(bytes).toString('base64'),
+ uri: 'file:///x.jpg'
+ })
+ expect(launchLibrary).toHaveBeenCalledWith(
+ expect.objectContaining({ base64: false, allowsMultipleSelection: false })
+ )
+ expect(file.close).toHaveBeenCalledTimes(1)
})
it('throws when photo library permission is denied', async () => {
@@ -48,19 +83,25 @@ describe('pickMobileImage', () => {
it('reads a picked file URI into base64 for the files source', async () => {
const bytes = new Uint8Array([1, 2, 3, 4])
- const fetchSpy = vi
- .spyOn(globalThis, 'fetch')
- .mockResolvedValue(new Response(bytes.buffer, { headers: { 'content-type': 'image/png' } }))
-
- const result = await pickMobileImage('files', {
- launchFiles: vi.fn().mockResolvedValue({
- canceled: false,
- assets: [{ uri: 'file:///doc.png' }]
- })
+ const file = fileFactory([bytes])
+ const launchFiles = vi.fn().mockResolvedValue({
+ canceled: false,
+ assets: [{ uri: 'file:///doc.png', size: bytes.length }]
})
- expect(result).toEqual({ base64: Buffer.from(bytes).toString('base64') })
- fetchSpy.mockRestore()
+ const result = await pickMobileImage('files', {
+ launchFiles,
+ createFile: file.createFile
+ })
+
+ expect(result).toEqual({
+ base64: Buffer.from(bytes).toString('base64'),
+ uri: 'file:///doc.png'
+ })
+ expect(launchFiles).toHaveBeenCalledWith(
+ expect.objectContaining({ copyToCacheDirectory: true })
+ )
+ expect(file.close).toHaveBeenCalledTimes(1)
})
it('returns null when the files picker is cancelled', async () => {
@@ -70,4 +111,72 @@ describe('pickMobileImage', () => {
expect(result).toBeNull()
})
+
+ it('rejects a declared oversized asset before opening it', async () => {
+ const file = fileFactory([], { fileSize: 1 })
+ await expect(
+ pickMobileImage('files', {
+ launchFiles: vi.fn().mockResolvedValue({
+ canceled: false,
+ assets: [{ uri: 'file:///huge.png', size: CLIPBOARD_IMAGE_MAX_SOURCE_BYTES + 1 }]
+ }),
+ createFile: file.createFile
+ })
+ ).rejects.toThrow('Clipboard image is too large')
+ expect(file.createFile).not.toHaveBeenCalled()
+ expect(file.open).not.toHaveBeenCalled()
+ })
+
+ it('does not let stale size metadata bypass the bounded read', async () => {
+ const close = vi.fn()
+ const readBytes = vi.fn((length: number) => new Uint8Array(length))
+ const createFile = vi.fn(() => ({
+ size: 1,
+ open: () => ({ size: 1, readBytes, close })
+ }))
+
+ await expect(
+ pickMobileImage('library', {
+ requestLibraryPermission: vi.fn().mockResolvedValue(granted),
+ launchLibrary: vi.fn().mockResolvedValue({
+ canceled: false,
+ assets: [{ uri: 'file:///grew.png', fileSize: 1 }]
+ }),
+ createFile
+ })
+ ).rejects.toThrow('Clipboard image is too large')
+ expect(readBytes).toHaveBeenLastCalledWith(1)
+ expect(close).toHaveBeenCalledTimes(1)
+ })
+
+ it('closes the file handle when a read fails', async () => {
+ const file = fileFactory([], { fileSize: 4, readError: new Error('read failed') })
+ await expect(
+ pickMobileImage('files', {
+ launchFiles: vi.fn().mockResolvedValue({
+ canceled: false,
+ assets: [{ uri: 'file:///broken.png', size: 4 }]
+ }),
+ createFile: file.createFile
+ })
+ ).rejects.toThrow('read failed')
+ expect(file.close).toHaveBeenCalledTimes(1)
+ })
+
+ it('preserves bytes across chunk boundaries that are not base64 aligned', async () => {
+ const chunks = [new Uint8Array([1]), new Uint8Array([2, 3]), new Uint8Array([4, 5])]
+ const file = fileFactory([...chunks], { fileSize: 5, handleSize: 5 })
+ const result = await pickMobileImage('files', {
+ launchFiles: vi.fn().mockResolvedValue({
+ canceled: false,
+ assets: [{ uri: 'file:///chunked.png', size: 5 }]
+ }),
+ createFile: file.createFile
+ })
+ expect(result).toEqual({
+ base64: Buffer.from([1, 2, 3, 4, 5]).toString('base64'),
+ uri: 'file:///chunked.png'
+ })
+ expect(file.close).toHaveBeenCalledTimes(1)
+ })
})
diff --git a/mobile/src/session/mobile-image-source-picker.ts b/mobile/src/session/mobile-image-source-picker.ts
index 8bc824965fc..bf9c529cc25 100644
--- a/mobile/src/session/mobile-image-source-picker.ts
+++ b/mobile/src/session/mobile-image-source-picker.ts
@@ -1,14 +1,21 @@
-// 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 * as DocumentPicker from 'expo-document-picker'
+import { File as FsFile } from 'expo-file-system'
import * as ImagePicker from 'expo-image-picker'
+import {
+ CLIPBOARD_IMAGE_MAX_SOURCE_BYTES,
+ assertClipboardImageBase64LengthWithinLimit,
+ assertClipboardImageByteLengthWithinLimit
+} from '../../../src/shared/clipboard-image'
+import { MobileImageBase64Accumulator } from './mobile-image-base64-accumulator'
export type MobileImageSource = 'library' | 'files'
export type PickedMobileImage = {
// Raw base64 (no data: prefix); fed straight into the existing upload pipeline.
readonly base64: string
+ // Local file URI of the picked asset — used only to render a composer preview
+ // thumbnail (the host upload uses `base64`); absent when the source can't supply one.
+ readonly uri?: string
}
export class ImageLibraryPermissionError extends Error {
@@ -18,18 +25,68 @@ export class ImageLibraryPermissionError extends Error {
}
}
-// Why: expo-document-picker returns a file URI, not base64. Read it through
-// fetch + Buffer so we match the base64 contract the upload pipeline expects
-// without pulling in expo-file-system.
-async function readUriAsBase64(uri: string): Promise {
- const response = await fetch(uri)
- const bytes = new Uint8Array(await response.arrayBuffer())
- return Buffer.from(bytes).toString('base64')
+const MOBILE_IMAGE_READ_CHUNK_BYTES = 256 * 1024
+
+type MobileImageFileHandle = {
+ readonly size: number | null
+ readBytes(length: number): Uint8Array
+ close(): void
+}
+
+type MobileImageFile = {
+ readonly size: number
+ open(): MobileImageFileHandle
+}
+
+export type MobileImageFileFactory = (uri: string) => MobileImageFile
+
+function defaultMobileImageFileFactory(uri: string): MobileImageFile {
+ return new FsFile(uri)
+}
+
+async function readUriAsBase64(
+ uri: string,
+ declaredSize: number | undefined,
+ createFile: MobileImageFileFactory
+): Promise {
+ if (typeof declaredSize === 'number' && Number.isFinite(declaredSize)) {
+ assertClipboardImageByteLengthWithinLimit(declaredSize)
+ }
+
+ const file = createFile(uri)
+ assertClipboardImageByteLengthWithinLimit(file.size)
+ const handle = file.open()
+ try {
+ if (handle.size !== null) {
+ assertClipboardImageByteLengthWithinLimit(handle.size)
+ }
+ const accumulator = new MobileImageBase64Accumulator()
+ let bytesRead = 0
+ while (bytesRead <= CLIPBOARD_IMAGE_MAX_SOURCE_BYTES) {
+ const requested = Math.min(
+ MOBILE_IMAGE_READ_CHUNK_BYTES,
+ CLIPBOARD_IMAGE_MAX_SOURCE_BYTES - bytesRead + 1
+ )
+ const bytes = handle.readBytes(requested)
+ if (bytes.byteLength === 0) {
+ break
+ }
+ bytesRead += bytes.byteLength
+ assertClipboardImageByteLengthWithinLimit(bytesRead)
+ accumulator.append(bytes)
+ }
+ const base64 = accumulator.finish()
+ assertClipboardImageBase64LengthWithinLimit(base64.length)
+ return base64
+ } finally {
+ handle.close()
+ }
}
async function pickFromLibrary(
requestPermission: typeof ImagePicker.requestMediaLibraryPermissionsAsync = ImagePicker.requestMediaLibraryPermissionsAsync,
- launch: typeof ImagePicker.launchImageLibraryAsync = ImagePicker.launchImageLibraryAsync
+ launch: typeof ImagePicker.launchImageLibraryAsync = ImagePicker.launchImageLibraryAsync,
+ createFile: MobileImageFileFactory = defaultMobileImageFileFactory
): Promise {
const permission = await requestPermission()
// Why: `granted` covers full + limited iOS access; only a hard denial blocks us.
@@ -38,7 +95,7 @@ async function pickFromLibrary(
}
const result = await launch({
mediaTypes: ['images'],
- base64: true,
+ base64: false,
allowsMultipleSelection: false,
quality: 1
})
@@ -46,15 +103,16 @@ async function pickFromLibrary(
return null
}
const asset = result.assets[0]
- const base64 = asset?.base64 ?? (asset?.uri ? await readUriAsBase64(asset.uri) : null)
+ const base64 = asset?.uri ? await readUriAsBase64(asset.uri, asset.fileSize, createFile) : null
if (!base64) {
return null
}
- return { base64 }
+ return { base64, ...(asset?.uri ? { uri: asset.uri } : {}) }
}
async function pickFromFiles(
- launch: typeof DocumentPicker.getDocumentAsync = DocumentPicker.getDocumentAsync
+ launch: typeof DocumentPicker.getDocumentAsync = DocumentPicker.getDocumentAsync,
+ createFile: MobileImageFileFactory = defaultMobileImageFileFactory
): Promise {
const result = await launch({
type: 'image/*',
@@ -68,7 +126,8 @@ async function pickFromFiles(
if (!asset?.uri) {
return null
}
- return { base64: await readUriAsBase64(asset.uri) }
+ const base64 = await readUriAsBase64(asset.uri, asset.size, createFile)
+ return base64 ? { base64, uri: asset.uri } : null
}
export async function pickMobileImage(
@@ -77,10 +136,11 @@ export async function pickMobileImage(
readonly requestLibraryPermission?: typeof ImagePicker.requestMediaLibraryPermissionsAsync
readonly launchLibrary?: typeof ImagePicker.launchImageLibraryAsync
readonly launchFiles?: typeof DocumentPicker.getDocumentAsync
+ readonly createFile?: MobileImageFileFactory
}
): Promise {
if (source === 'library') {
- return pickFromLibrary(deps?.requestLibraryPermission, deps?.launchLibrary)
+ return pickFromLibrary(deps?.requestLibraryPermission, deps?.launchLibrary, deps?.createFile)
}
- return pickFromFiles(deps?.launchFiles)
+ return pickFromFiles(deps?.launchFiles, deps?.createFile)
}
diff --git a/mobile/src/session/mobile-native-chat-draft-reconcile.ts b/mobile/src/session/mobile-native-chat-draft-reconcile.ts
new file mode 100644
index 00000000000..1af010ff503
--- /dev/null
+++ b/mobile/src/session/mobile-native-chat-draft-reconcile.ts
@@ -0,0 +1,103 @@
+import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
+import {
+ isImageSourceUserTurn,
+ stripImagePromptMarker
+} from './mobile-native-chat-image-transcript-markers'
+
+/** An ack-lost ('unknown' outcome) send held until its transcript echo lands or
+ * the deadline surfaces the uncertainty. */
+export type UnconfirmedSend = {
+ draftKey: string
+ pendingKey: string | null
+ text: string
+ normalizedText: string
+ baselineTailMessageId: string | null
+ deadline: ReturnType | null
+}
+
+export function normalizedUserText(message: NativeChatMessage): string | null {
+ if (message.role !== 'user') {
+ return null
+ }
+ const text = message.blocks
+ .filter((block) => block.type === 'text')
+ .map((block) => (block.type === 'text' ? block.text : ''))
+ .join('')
+ // Claude echoes a captioned image send as `[Image #1] caption` — the sent
+ // text must still match its echo, so strip the marker before comparing.
+ const stripped = stripImagePromptMarker(text).trim()
+ return stripped || null
+}
+
+export function countUserTextOccurrences(
+ messages: readonly NativeChatMessage[],
+ text: string
+): number {
+ let count = 0
+ for (const message of messages) {
+ if (normalizedUserText(message) === text) {
+ count++
+ }
+ }
+ return count
+}
+
+/** Number of `[Image: source: …]` echo turns strictly after `tailId` (or the
+ * whole transcript when the tail was paginated out). An image-only send has no
+ * caption to match, so it reconciles by ordinal against this count — counting
+ * only image echoes keeps an unrelated text send's echo from clearing it. */
+export function countImageSourceTurnsAfter(
+ messages: readonly NativeChatMessage[],
+ tailId: string | null
+): number {
+ const tailIndex = tailId ? messages.findIndex((message) => message.id === tailId) : -1
+ let count = 0
+ for (let i = tailIndex + 1; i < messages.length; i++) {
+ const message = messages[i]
+ if (message && isImageSourceUserTurn(message)) {
+ count++
+ }
+ }
+ return count
+}
+
+export function findLandedUnconfirmedSends(
+ messages: readonly NativeChatMessage[],
+ entries: readonly UnconfirmedSend[]
+): UnconfirmedSend[] {
+ // Why: pagination prepends old equal text; only unclaimed matches after each
+ // captured tail prove new echoes. User turns are keyed by text; an image echo
+ // (`[Image: source: …]` or no text) keys under '' so an empty-text send can
+ // claim it.
+ const messageIndexById = new Map()
+ const userMessagesByText = new Map>()
+ for (const [index, message] of messages.entries()) {
+ messageIndexById.set(message.id, index)
+ if (message.role !== 'user') {
+ continue
+ }
+ const key = isImageSourceUserTurn(message) ? '' : (normalizedUserText(message) ?? '')
+ const current = userMessagesByText.get(key) ?? []
+ current.push({ id: message.id, index })
+ userMessagesByText.set(key, current)
+ }
+
+ const claimedMessageIds = new Set()
+ const landed: UnconfirmedSend[] = []
+ for (const entry of entries) {
+ const tailIndex = entry.baselineTailMessageId
+ ? messageIndexById.get(entry.baselineTailMessageId)
+ : -1
+ if (tailIndex === undefined) {
+ continue
+ }
+ const echo = userMessagesByText
+ .get(entry.normalizedText)
+ ?.find((message) => message.index > tailIndex && !claimedMessageIds.has(message.id))
+ if (echo) {
+ claimedMessageIds.add(echo.id)
+ landed.push(entry)
+ }
+ }
+ return landed
+}
diff --git a/mobile/src/session/mobile-native-chat-image-attachment.test.ts b/mobile/src/session/mobile-native-chat-image-attachment.test.ts
new file mode 100644
index 00000000000..57c3f67bd1c
--- /dev/null
+++ b/mobile/src/session/mobile-native-chat-image-attachment.test.ts
@@ -0,0 +1,101 @@
+import { describe, expect, it, vi } from 'vitest'
+import type { RpcClient } from '../transport/rpc-client'
+import type { RpcResponse, RpcSuccess } from '../transport/types'
+import { uploadMobileNativeChatImage } from './mobile-native-chat-image-attachment'
+
+function ok(id: string, result: unknown): RpcSuccess {
+ return { id, ok: true, result, _meta: { runtimeId: 'runtime-1' } }
+}
+
+function methodNotFound(id: string): RpcResponse {
+ return {
+ id,
+ ok: false,
+ error: { code: 'method_not_found', message: 'no' },
+ _meta: { runtimeId: 'r' }
+ }
+}
+
+function clientWithResponses(responses: RpcResponse[]): Pick & {
+ calls: { method: string; params: unknown }[]
+} {
+ const calls: { method: string; params: unknown }[] = []
+ return {
+ calls,
+ sendRequest: vi.fn(async (method: string, params?: unknown) => {
+ calls.push({ method, params })
+ const response = responses.shift()
+ if (!response) {
+ throw new Error(`unexpected request: ${method}`)
+ }
+ return response
+ })
+ }
+}
+
+describe('uploadMobileNativeChatImage', () => {
+ it('uploads the picked image and returns its host path + local preview uri, without any terminal.send', async () => {
+ const client = clientWithResponses([
+ methodNotFound('start'),
+ ok('save', '/tmp/orca-attach.png')
+ ])
+
+ const result = await uploadMobileNativeChatImage('library', {
+ client,
+ getConnectionId: async () => 'conn-7',
+ pickImage: vi.fn().mockResolvedValue({ base64: 'AAAA', uri: 'file:///photo.jpg' })
+ })
+
+ expect(result).toEqual({ path: '/tmp/orca-attach.png', previewUri: 'file:///photo.jpg' })
+ // Native chat defers the paste to submit — nothing is sent to the terminal here.
+ expect(client.calls.some((call) => call.method === 'terminal.send')).toBe(false)
+ const saveCall = client.calls.find((c) => c.method === 'clipboard.saveImageAsTempFile')
+ expect(saveCall?.params).toMatchObject({ connectionId: 'conn-7' })
+ })
+
+ it('returns null when the picker is cancelled and uploads nothing', async () => {
+ const client = clientWithResponses([])
+
+ const result = await uploadMobileNativeChatImage('library', {
+ client,
+ getConnectionId: async () => null,
+ pickImage: vi.fn().mockResolvedValue(null)
+ })
+
+ expect(result).toBeNull()
+ expect(client.calls).toEqual([])
+ })
+
+ it('falls back to an inline data uri for the preview when the picker omits a uri', async () => {
+ const client = clientWithResponses([methodNotFound('start'), ok('save', '/tmp/x.png')])
+
+ const result = await uploadMobileNativeChatImage('files', {
+ client,
+ getConnectionId: async () => null,
+ pickImage: vi.fn().mockResolvedValue({ base64: 'BBBB' })
+ })
+
+ expect(result).toEqual({ path: '/tmp/x.png', previewUri: 'data:image/png;base64,BBBB' })
+ })
+
+ it('signals upload start only after a real image is picked', async () => {
+ const onUploadStart = vi.fn()
+ const cancelledClient = clientWithResponses([])
+ await uploadMobileNativeChatImage('library', {
+ client: cancelledClient,
+ getConnectionId: async () => null,
+ pickImage: vi.fn().mockResolvedValue(null),
+ onUploadStart
+ })
+ expect(onUploadStart).not.toHaveBeenCalled()
+
+ const client = clientWithResponses([methodNotFound('start'), ok('save', '/tmp/y.png')])
+ await uploadMobileNativeChatImage('library', {
+ client,
+ getConnectionId: async () => null,
+ pickImage: vi.fn().mockResolvedValue({ base64: 'CCCC', uri: 'file:///y.jpg' }),
+ onUploadStart
+ })
+ expect(onUploadStart).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/mobile/src/session/mobile-native-chat-image-attachment.ts b/mobile/src/session/mobile-native-chat-image-attachment.ts
new file mode 100644
index 00000000000..f81b9d2ad98
--- /dev/null
+++ b/mobile/src/session/mobile-native-chat-image-attachment.ts
@@ -0,0 +1,46 @@
+import type { RpcClient } from '../transport/rpc-client'
+import { saveMobileClipboardImageAsTempFile } from './mobile-clipboard-image'
+// Type-only import so this module (and its unit test) stays free of the expo/
+// react-native picker chain; the concrete `pickImage` is injected by the hook.
+import type { MobileImageSource, PickedMobileImage } from './mobile-image-source-picker'
+
+/** A picked-and-uploaded image held in the native-chat composer until submit.
+ * `path` is the host temp file pasted into the agent on send; `previewUri` is a
+ * local URI used only to render the composer thumbnail. */
+export type PendingNativeChatImage = {
+ readonly id: string
+ readonly path: string
+ readonly previewUri: string
+}
+
+export type UploadNativeChatImageDeps = {
+ readonly client: Pick
+ readonly getConnectionId: () => Promise
+ // Injected so this module stays free of expo/react-native imports (unit-testable).
+ readonly pickImage: (source: MobileImageSource) => Promise
+ // Fired once the user has picked an image and the host upload is about to start —
+ // lets the UI show the attach spinner only for the transfer, not the picker.
+ readonly onUploadStart?: () => void
+}
+
+/** Picks an image and uploads it to the host, returning the host path + a local
+ * preview URI — but does NOT paste it into the terminal. Unlike the terminal
+ * attach flow, native chat holds the image as a composer chip and rides it along
+ * on submit (desktop parity), so the chip and the agent input never diverge.
+ * Returns null when the user cancels the picker. */
+export async function uploadMobileNativeChatImage(
+ source: MobileImageSource,
+ { client, getConnectionId, pickImage, onUploadStart }: UploadNativeChatImageDeps
+): Promise | null> {
+ const picked = await pickImage(source)
+ if (!picked) {
+ return null
+ }
+ onUploadStart?.()
+ const connectionId = await getConnectionId()
+ const path = await saveMobileClipboardImageAsTempFile(client, picked.base64, { connectionId })
+ // Prefer the picker's local URI for the thumbnail; fall back to an inline data
+ // URI when the source omitted one (RN renders both).
+ const previewUri = picked.uri ?? `data:image/png;base64,${picked.base64}`
+ return { path, previewUri }
+}
diff --git a/mobile/src/session/mobile-native-chat-image-preview.test.ts b/mobile/src/session/mobile-native-chat-image-preview.test.ts
new file mode 100644
index 00000000000..64ec62d57e3
--- /dev/null
+++ b/mobile/src/session/mobile-native-chat-image-preview.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it } from 'vitest'
+import { isRenderableImageUri } from './mobile-native-chat-image-preview'
+
+describe('isRenderableImageUri', () => {
+ it('accepts local previews and real URLs the device can load', () => {
+ for (const uri of [
+ 'file:///var/mobile/a.jpg',
+ 'data:image/png;base64,AAAA',
+ 'content://media/1',
+ 'blob:abc',
+ 'http://host/a.png',
+ 'https://host/a.png'
+ ]) {
+ expect(isRenderableImageUri(uri)).toBe(true)
+ }
+ })
+
+ it('rejects bare host paths (not loadable on the device) and empty values', () => {
+ for (const uri of [
+ '/tmp/orca-attach.png',
+ 'C:\\tmp\\a.png',
+ 'orca-attach.png',
+ '',
+ undefined
+ ]) {
+ expect(isRenderableImageUri(uri)).toBe(false)
+ }
+ })
+})
diff --git a/mobile/src/session/mobile-native-chat-image-preview.ts b/mobile/src/session/mobile-native-chat-image-preview.ts
new file mode 100644
index 00000000000..8062ac5b83f
--- /dev/null
+++ b/mobile/src/session/mobile-native-chat-image-preview.ts
@@ -0,0 +1,9 @@
+// A URI RN can actually load: a local composer/echo preview (file://,
+// data:, content://, blob:) or a real remote URL. A bare host path from the
+// transcript (e.g. /tmp/x.png on an SSH host) is not loadable on the device, so
+// it stays a text placeholder instead of a broken image.
+const RENDERABLE_IMAGE_URI = /^(file:|data:|https?:|content:|blob:)/i
+
+export function isRenderableImageUri(uri: string | undefined): uri is string {
+ return typeof uri === 'string' && RENDERABLE_IMAGE_URI.test(uri)
+}
diff --git a/mobile/src/session/mobile-native-chat-image-send.test.ts b/mobile/src/session/mobile-native-chat-image-send.test.ts
new file mode 100644
index 00000000000..368cd6a8781
--- /dev/null
+++ b/mobile/src/session/mobile-native-chat-image-send.test.ts
@@ -0,0 +1,71 @@
+import { describe, expect, it, vi } from 'vitest'
+import type { RpcClient } from '../transport/rpc-client'
+import type { RpcResponse, RpcSuccess } from '../transport/types'
+import { pasteMobileNativeChatImagePaths } from './mobile-native-chat-image-send'
+
+function sendResult(accepted: boolean, id = 'send'): RpcSuccess {
+ return { id, ok: true, result: { send: { accepted } }, _meta: { runtimeId: 'r' } }
+}
+
+function clientWithResponses(responses: RpcResponse[]): Pick & {
+ calls: { method: string; params: Record }[]
+} {
+ const calls: { method: string; params: Record }[] = []
+ return {
+ calls,
+ sendRequest: vi.fn(async (method: string, params?: unknown) => {
+ calls.push({ method, params: params as Record })
+ const response = responses.shift()
+ if (!response) {
+ throw new Error(`unexpected request: ${method}`)
+ }
+ return response
+ })
+ }
+}
+
+describe('pasteMobileNativeChatImagePaths', () => {
+ it('clears the input line, then pastes each path as a bracketed, non-submitting terminal.send with the mobile client tag', async () => {
+ const client = clientWithResponses([sendResult(true), sendResult(true), sendResult(true)])
+
+ const ok = await pasteMobileNativeChatImagePaths({
+ client,
+ terminal: 'term-1',
+ deviceToken: 'device-9',
+ imagePaths: ['/tmp/a.png', '/tmp/b.png']
+ })
+
+ expect(ok).toBe(true)
+ expect(client.calls).toHaveLength(3)
+ // Leading Ctrl+U clears any stale input so a retry can't duplicate the image.
+ expect(client.calls[0]).toEqual({
+ method: 'terminal.send',
+ params: {
+ terminal: 'term-1',
+ text: '\x15',
+ enter: false,
+ client: { id: 'device-9', type: 'mobile' }
+ }
+ })
+ expect(client.calls[1]?.params.text).toBe('\x1b[200~/tmp/a.png\x1b[201~')
+ expect(client.calls[2]?.params.text).toBe('\x1b[200~/tmp/b.png\x1b[201~')
+ })
+
+ it('stops and reports failure as soon as a paste is rejected', async () => {
+ // Clear accepted, first image paste rejected.
+ const client = clientWithResponses([sendResult(true), sendResult(false)])
+
+ const ok = await pasteMobileNativeChatImagePaths({
+ client,
+ terminal: 'term-1',
+ deviceToken: null,
+ imagePaths: ['/tmp/a.png', '/tmp/b.png']
+ })
+
+ expect(ok).toBe(false)
+ // Never attempts the second path after the first is rejected.
+ expect(client.calls).toHaveLength(2)
+ expect(client.calls[1]?.params.text).toBe('\x1b[200~/tmp/a.png\x1b[201~')
+ expect(client.calls[0]?.params).not.toHaveProperty('client')
+ })
+})
diff --git a/mobile/src/session/mobile-native-chat-image-send.ts b/mobile/src/session/mobile-native-chat-image-send.ts
new file mode 100644
index 00000000000..6d163b41fc3
--- /dev/null
+++ b/mobile/src/session/mobile-native-chat-image-send.ts
@@ -0,0 +1,54 @@
+import type { RpcClient } from '../transport/rpc-client'
+import { buildMobileImagePastePayload } from './mobile-clipboard-image'
+import { isTerminalSendRpcAccepted } from '../terminal/terminal-send-rpc-response'
+
+// Give the agent TUI a beat to register each bracketed image paste before the
+// message text + Enter arrive, so the image attaches instead of being treated as
+// part of the prompt body (mirrors desktop's NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS).
+export const MOBILE_NATIVE_CHAT_IMAGE_SETTLE_MS = 300
+
+// Ctrl+U kills the agent's unsubmitted input line. Sent before pasting so a retry
+// after a rejected body/Enter can't leave a stale image paste that then rides along
+// with (and duplicates) the next attempt — matches desktop clearUnsubmittedAgentInput.
+const MOBILE_NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT = '\x15'
+
+type MobileTerminalClient = { id: string; type: 'mobile' }
+
+type PasteImagesArgs = {
+ readonly client: Pick
+ readonly terminal: string
+ readonly deviceToken: string | null
+ readonly imagePaths: readonly string[]
+}
+
+/** Clears the agent's unsubmitted input line, then pastes each uploaded image
+ * path into the terminal as a bracketed paste (no Enter) — the same payload
+ * desktop native chat rides along on submit. The leading clear keeps a retry
+ * idempotent after a failed body/Enter. Returns false as soon as the host rejects
+ * one, so the caller can abort before Enter. */
+export async function pasteMobileNativeChatImagePaths({
+ client,
+ terminal,
+ deviceToken,
+ imagePaths
+}: PasteImagesArgs): Promise {
+ const mobileClient: MobileTerminalClient | null = deviceToken
+ ? { id: deviceToken, type: 'mobile' }
+ : null
+ const clientField = mobileClient ? { client: mobileClient } : {}
+ for (const text of [
+ MOBILE_NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT,
+ ...imagePaths.map(buildMobileImagePastePayload)
+ ]) {
+ const response = await client.sendRequest('terminal.send', {
+ terminal,
+ text,
+ enter: false,
+ ...clientField
+ })
+ if (!isTerminalSendRpcAccepted(response)) {
+ return false
+ }
+ }
+ return true
+}
diff --git a/mobile/src/session/mobile-native-chat-image-transcript-markers.ts b/mobile/src/session/mobile-native-chat-image-transcript-markers.ts
new file mode 100644
index 00000000000..2c1ecc291ff
--- /dev/null
+++ b/mobile/src/session/mobile-native-chat-image-transcript-markers.ts
@@ -0,0 +1,21 @@
+// Single-sources the marker logic (pure functions over shared types):
+// Claude records an attached image as `[Image: source: /path]` (+ `[Image #N]`
+// prefix on the caption turn), and both render and echo reconciliation must
+// agree with desktop on how those marker turns are interpreted.
+export {
+ imageSourcePathFromText,
+ normalizeImageTranscriptMessages,
+ stripImagePromptMarker
+} from '../../../src/shared/native-chat-image-transcript-markers'
+import { imageSourcePathFromText } from '../../../src/shared/native-chat-image-transcript-markers'
+import { isTextBlock, type NativeChatMessage } from '../../../src/shared/native-chat-types'
+
+/** A raw (un-normalized) transcript user turn that is an image-source marker —
+ * the echo shape of an image riding along on a send. */
+export function isImageSourceUserTurn(message: NativeChatMessage): boolean {
+ if (message.role !== 'user' || message.blocks.length !== 1) {
+ return false
+ }
+ const block = message.blocks[0]
+ return block !== undefined && isTextBlock(block) && imageSourcePathFromText(block.text) !== null
+}
diff --git a/mobile/src/session/mobile-native-chat-message-styles.ts b/mobile/src/session/mobile-native-chat-message-styles.ts
index c67ffb738cc..41d77564fd2 100644
--- a/mobile/src/session/mobile-native-chat-message-styles.ts
+++ b/mobile/src/session/mobile-native-chat-message-styles.ts
@@ -141,6 +141,14 @@ export const styles = StyleSheet.create({
color: colors.textSecondary,
fontSize: TEXT_SIZE
},
+ imageThumb: {
+ width: 200,
+ height: 150,
+ borderRadius: radii.card,
+ backgroundColor: colors.bgRaised,
+ borderWidth: StyleSheet.hairlineWidth,
+ borderColor: colors.borderSubtle
+ },
diff: {
borderRadius: radii.button,
backgroundColor: colors.bgPanel,
diff --git a/mobile/src/session/mobile-native-chat-render-data.test.ts b/mobile/src/session/mobile-native-chat-render-data.test.ts
index e933971220d..4dabff8f600 100644
--- a/mobile/src/session/mobile-native-chat-render-data.test.ts
+++ b/mobile/src/session/mobile-native-chat-render-data.test.ts
@@ -63,6 +63,55 @@ describe('buildMobileNativeChatData', () => {
expect(last.blocks).toEqual([{ type: 'text', text: 'queued' }])
})
+ it('renders a pending send with images as text followed by image-ref thumbnails', () => {
+ const { data } = buildMobileNativeChatData({
+ messages: [],
+ pending: [{ id: 'p1', text: 'look', images: ['file:///a.jpg', 'file:///b.jpg'] }]
+ })
+ const last = data[data.length - 1]
+ expect(last.role).toBe('user')
+ expect(last.blocks).toEqual([
+ { type: 'text', text: 'look' },
+ { type: 'image-ref', url: 'file:///a.jpg' },
+ { type: 'image-ref', url: 'file:///b.jpg' }
+ ])
+ })
+
+ it('renders an image-only pending send (no text) as just the thumbnail', () => {
+ const { data } = buildMobileNativeChatData({
+ messages: [],
+ pending: [{ id: 'p1', text: '', images: ['file:///a.jpg'] }]
+ })
+ expect(data[data.length - 1].blocks).toEqual([{ type: 'image-ref', url: 'file:///a.jpg' }])
+ })
+
+ it('folds transcript image marker turns into image-ref blocks (desktop parity)', () => {
+ // Claude records an attached image as `[Image: source: /path]` + an
+ // `[Image #1] `-prefixed caption turn; the fold must merge them into one
+ // user turn with an image-ref block instead of showing raw marker text.
+ const { data } = buildMobileNativeChatData({
+ messages: [
+ user('u1', '[Image: source: /tmp/a.png]'),
+ user('u2', '[Image #1] look at this'),
+ assistant('a1', 'nice photo')
+ ],
+ pending: []
+ })
+ const merged = data.find((message) => message.role === 'user')
+ expect(merged?.blocks).toEqual([
+ { type: 'image-ref', path: '/tmp/a.png' },
+ { type: 'text', text: 'look at this' }
+ ])
+ })
+
+ it('renders a lone image marker turn (no caption) as an image-ref block', () => {
+ const { data } = buildMobileNativeChatData({
+ messages: [user('u1', '[Image: source: /tmp/a.png]')],
+ pending: []
+ })
+ expect(data[0]?.blocks).toEqual([{ type: 'image-ref', path: '/tmp/a.png' }])
+ })
+
it('adds a synthetic streaming bubble while the partial text leads the transcript', () => {
const { streaming, data } = buildMobileNativeChatData({
messages: [user('u1', 'hi')],
diff --git a/mobile/src/session/mobile-native-chat-render-data.ts b/mobile/src/session/mobile-native-chat-render-data.ts
index 74ca01939ba..068050fc43d 100644
--- a/mobile/src/session/mobile-native-chat-render-data.ts
+++ b/mobile/src/session/mobile-native-chat-render-data.ts
@@ -5,6 +5,7 @@ import {
} from '../../../src/shared/native-chat-empty-state'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import { foldToolMessages } from './mobile-native-chat-blocks'
+import { normalizeImageTranscriptMessages } from './mobile-native-chat-image-transcript-markers'
import { stripNoiseMessages } from './mobile-native-chat-noise'
import type { MobileNativeChatStatus } from './use-mobile-native-chat-session'
@@ -34,6 +35,14 @@ export function mobileNativeChatEmptyState(
}
}
+/** An optimistic user echo: the text and/or the local preview URIs of any images
+ * ridden along on the send, shown until the transcript catches up. */
+export type MobileNativeChatPendingItem = {
+ id: string
+ text: string
+ images?: string[]
+}
+
/** Derive the list data from the raw transcript: fold tool turns into the
* assistant turn, optionally append a synthetic streaming bubble, then the
* route-owned optimistic "queued" messages at the tail. Returns the
@@ -45,14 +54,16 @@ export function buildMobileNativeChatData({
}: {
messages: NativeChatMessage[]
streamingText?: string
- pending: Array<{ id: string; text: string }>
+ pending: MobileNativeChatPendingItem[]
}): { folded: NativeChatMessage[]; streaming: string | null; data: NativeChatMessage[] } {
const folded = foldMobileNativeChatMessages(messages)
return buildMobileNativeChatTransientData({ folded, streamingText, pending })
}
export function foldMobileNativeChatMessages(messages: NativeChatMessage[]): NativeChatMessage[] {
- return foldToolMessages(stripNoiseMessages(messages))
+ // Normalize first (desktop assembler parity): image marker turns fold into
+ // image-ref blocks instead of rendering as raw `[Image: …]` text.
+ return foldToolMessages(stripNoiseMessages(normalizeImageTranscriptMessages(messages)))
}
export function buildMobileNativeChatTransientData({
@@ -62,7 +73,7 @@ export function buildMobileNativeChatTransientData({
}: {
folded: NativeChatMessage[]
streamingText?: string
- pending: Array<{ id: string; text: string }>
+ pending: MobileNativeChatPendingItem[]
}): { folded: NativeChatMessage[]; streaming: string | null; data: NativeChatMessage[] } {
// Only show the streaming bubble while its text leads the transcript — once the
// real assistant turn lands with the same text, drop the synthetic one.
@@ -83,7 +94,12 @@ export function buildMobileNativeChatTransientData({
...pending.map((p) => ({
id: p.id,
role: 'user' as const,
- blocks: [{ type: 'text' as const, text: p.text }],
+ // Text first (when present), then a thumbnail per ridden-along image so the
+ // sent photo shows immediately, before the transcript echo lands.
+ blocks: [
+ ...(p.text ? [{ type: 'text' as const, text: p.text }] : []),
+ ...(p.images ?? []).map((uri) => ({ type: 'image-ref' as const, url: uri }))
+ ],
timestamp: null,
source: 'transcript' as const
}))
diff --git a/mobile/src/session/mobile-native-chat-scope-key.ts b/mobile/src/session/mobile-native-chat-scope-key.ts
new file mode 100644
index 00000000000..e5b3b4ef544
--- /dev/null
+++ b/mobile/src/session/mobile-native-chat-scope-key.ts
@@ -0,0 +1,10 @@
+/** Identity of a native-chat composer surface: host + worktree + tab. Drafts
+ * and pending image chips are both keyed by it, so a tab switch cannot leak
+ * one tab's composer state into another tab's terminal. */
+export function mobileNativeChatScopeKey(
+ hostId: string,
+ worktreeId: string,
+ tabId: string | null
+): string | null {
+ return tabId ? `${hostId}\0${worktreeId}\0${tabId}` : null
+}
diff --git a/mobile/src/session/mobile-session-file-doc-lifecycle.test.ts b/mobile/src/session/mobile-session-file-doc-lifecycle.test.ts
new file mode 100644
index 00000000000..f29685b7bca
--- /dev/null
+++ b/mobile/src/session/mobile-session-file-doc-lifecycle.test.ts
@@ -0,0 +1,128 @@
+import { describe, expect, it } from 'vitest'
+import {
+ MobileSessionFileDocLifecycle,
+ beginMobileFileDocLoad,
+ createMobileFileDocLifecycle,
+ finishMobileFileDocLoad,
+ forgetMobileFileDocTab,
+ reconcileMobileFileDocTabs,
+ removeMobileFileDoc,
+ resetMobileFileDocLifecycle,
+ retainLiveMobileFileDocs,
+ type MobileFileTabIdentity
+} from './mobile-session-file-doc-lifecycle'
+import type { MobileFileTabDoc } from '../files/mobile-file-tab-doc'
+
+type TestFileDoc = MobileFileTabDoc | { status: 'loading' } | { status: 'error'; message: string }
+
+function fileTab(id: string, relativePath = `${id}.ts`): MobileFileTabIdentity {
+ return {
+ id,
+ filePath: `/repo/${relativePath}`,
+ relativePath,
+ mode: 'edit'
+ }
+}
+
+describe('mobile session file document lifecycle', () => {
+ it('retains payloads only for file tabs in the accepted live snapshot', () => {
+ const lifecycle = createMobileFileDocLifecycle()
+ reconcileMobileFileDocTabs(lifecycle, [fileTab('live-a'), fileTab('live-b')])
+ const docs = new Map([
+ ['closed', { payload: 'x'.repeat(1_000_000) }],
+ ['live-a', { payload: 'a' }],
+ ['live-b', { payload: 'b' }]
+ ])
+
+ const retained = retainLiveMobileFileDocs(docs, lifecycle)
+
+ expect([...retained.keys()]).toEqual(['live-a', 'live-b'])
+ expect(retained.get('live-a')).toBe(docs.get('live-a'))
+ })
+
+ it('deletes a successfully closed tab immediately', () => {
+ const lifecycle = createMobileFileDocLifecycle()
+ reconcileMobileFileDocTabs(lifecycle, [fileTab('closed')])
+ forgetMobileFileDocTab(lifecycle, 'closed')
+
+ expect(removeMobileFileDoc(new Map([['closed', { payload: 'large' }]]), 'closed').size).toBe(0)
+ })
+
+ it('rejects a late read after its tab closes', () => {
+ const lifecycle = createMobileFileDocLifecycle()
+ const tab = fileTab('file')
+ reconcileMobileFileDocTabs(lifecycle, [tab])
+ const token = beginMobileFileDocLoad(lifecycle, tab)
+ expect(token).not.toBeNull()
+
+ forgetMobileFileDocTab(lifecycle, tab.id)
+
+ expect(finishMobileFileDocLoad(lifecycle, token!)).toBe(false)
+ })
+
+ it('keeps route state empty when a read resolves after close', async () => {
+ const lifecycle = new MobileSessionFileDocLifecycle()
+ const tab = fileTab('file')
+ let docs = new Map()
+ const updateDocs = (update: (current: typeof docs) => typeof docs) => {
+ docs = update(docs)
+ }
+ lifecycle.reconcile([{ ...tab, type: 'file' }], updateDocs)
+ let resolveRead: (doc: MobileFileTabDoc) => void = () => undefined
+ const read = new Promise((resolve) => {
+ resolveRead = resolve
+ })
+
+ const pending = lifecycle.load(tab, updateDocs, () => read)
+ expect(docs.get(tab.id)).toEqual({ status: 'loading' })
+ lifecycle.close(tab.id, updateDocs)
+ resolveRead({ status: 'ready', kind: 'file', content: 'late', truncated: false, byteLength: 4 })
+ await pending
+
+ expect(docs.size).toBe(0)
+ })
+
+ it('rejects a late read when the same tab id is replaced with another file', () => {
+ const lifecycle = createMobileFileDocLifecycle()
+ const oldTab = fileTab('file', 'old.ts')
+ reconcileMobileFileDocTabs(lifecycle, [oldTab])
+ const token = beginMobileFileDocLoad(lifecycle, oldTab)
+ expect(token).not.toBeNull()
+
+ const replaced = reconcileMobileFileDocTabs(lifecycle, [fileTab('file', 'replacement.ts')])
+
+ expect(finishMobileFileDocLoad(lifecycle, token!)).toBe(false)
+ expect(
+ retainLiveMobileFileDocs(
+ new Map([['file', { payload: 'old contents' }]]),
+ lifecycle,
+ replaced
+ )
+ ).toEqual(new Map())
+ })
+
+ it('lets only the newest read for a live tab commit', () => {
+ const lifecycle = createMobileFileDocLifecycle()
+ const tab = fileTab('file')
+ reconcileMobileFileDocTabs(lifecycle, [tab])
+ const first = beginMobileFileDocLoad(lifecycle, tab)
+ const second = beginMobileFileDocLoad(lifecycle, tab)
+
+ expect(finishMobileFileDocLoad(lifecycle, first!)).toBe(false)
+ expect(finishMobileFileDocLoad(lifecycle, second!)).toBe(true)
+ })
+
+ it('invalidates requests when the route scope resets even if tab ids are reused', () => {
+ const lifecycle = createMobileFileDocLifecycle()
+ const tab = fileTab('reused')
+ reconcileMobileFileDocTabs(lifecycle, [tab])
+ const oldScope = beginMobileFileDocLoad(lifecycle, tab)
+
+ resetMobileFileDocLifecycle(lifecycle)
+ reconcileMobileFileDocTabs(lifecycle, [tab])
+ const newScope = beginMobileFileDocLoad(lifecycle, tab)
+
+ expect(finishMobileFileDocLoad(lifecycle, oldScope!)).toBe(false)
+ expect(finishMobileFileDocLoad(lifecycle, newScope!)).toBe(true)
+ })
+})
diff --git a/mobile/src/session/mobile-session-file-doc-lifecycle.ts b/mobile/src/session/mobile-session-file-doc-lifecycle.ts
new file mode 100644
index 00000000000..843aa21758e
--- /dev/null
+++ b/mobile/src/session/mobile-session-file-doc-lifecycle.ts
@@ -0,0 +1,242 @@
+import type { MobileFileTabDoc } from '../files/mobile-file-tab-doc'
+
+export type MobileFileTabIdentity = {
+ id: string
+ filePath: string
+ relativePath: string
+ mode?: 'edit' | 'diff'
+ diffSource?: 'staged' | 'unstaged' | 'branch' | 'commit'
+}
+
+export type MobileFileDocLoadToken = {
+ generation: number
+ requestId: number
+ tabId: string
+ tabIdentity: string
+}
+
+export type MobileFileDocLifecycle = {
+ generation: number
+ nextRequestId: number
+ liveTabIdentityById: Map
+ activeRequestIdByTabId: Map
+}
+
+type MobileFileTabCandidate = {
+ type: string
+ id: string
+ filePath?: unknown
+ relativePath?: unknown
+ mode?: unknown
+ diffSource?: unknown
+}
+
+type MobileSessionFileDoc =
+ | MobileFileTabDoc
+ | { status: 'loading' }
+ | { status: 'error'; message: string }
+
+type MobileSessionFileDocUpdater = (
+ update: (current: Map) => Map
+) => void
+
+export class MobileSessionFileDocLifecycle {
+ private readonly lifecycle = createMobileFileDocLifecycle()
+
+ reconcile(
+ tabs: readonly MobileFileTabCandidate[],
+ updateDocs: MobileSessionFileDocUpdater
+ ): void {
+ const replacedTabIds = reconcileMobileFileDocTabs(
+ this.lifecycle,
+ tabs.filter(isMobileFileTabIdentity)
+ )
+ updateDocs((current) => retainLiveMobileFileDocs(current, this.lifecycle, replacedTabIds))
+ }
+
+ async load(
+ tab: MobileFileTabIdentity,
+ updateDocs: MobileSessionFileDocUpdater,
+ read: () => Promise
+ ): Promise {
+ const token = beginMobileFileDocLoad(this.lifecycle, tab)
+ if (!token) {
+ return
+ }
+ updateDocs((current) => new Map(current).set(tab.id, { status: 'loading' }))
+ try {
+ const doc = await read()
+ if (finishMobileFileDocLoad(this.lifecycle, token)) {
+ updateDocs((current) => new Map(current).set(tab.id, doc))
+ }
+ } catch (error) {
+ if (finishMobileFileDocLoad(this.lifecycle, token)) {
+ updateDocs((current) =>
+ new Map(current).set(tab.id, {
+ status: 'error',
+ message: getMobileFileDocLoadErrorMessage(tab, error)
+ })
+ )
+ }
+ }
+ }
+
+ close(tabId: string, updateDocs: MobileSessionFileDocUpdater): void {
+ forgetMobileFileDocTab(this.lifecycle, tabId)
+ updateDocs((current) => removeMobileFileDoc(current, tabId))
+ }
+
+ reset(): void {
+ resetMobileFileDocLifecycle(this.lifecycle)
+ }
+}
+
+export function createMobileFileDocLifecycle(): MobileFileDocLifecycle {
+ return {
+ generation: 0,
+ nextRequestId: 0,
+ liveTabIdentityById: new Map(),
+ activeRequestIdByTabId: new Map()
+ }
+}
+
+export function reconcileMobileFileDocTabs(
+ lifecycle: MobileFileDocLifecycle,
+ tabs: readonly MobileFileTabIdentity[]
+): ReadonlySet {
+ const nextIdentityById = new Map(tabs.map((tab) => [tab.id, getMobileFileTabIdentity(tab)]))
+ const replacedTabIds = new Set()
+ for (const tabId of lifecycle.activeRequestIdByTabId.keys()) {
+ const nextIdentity = nextIdentityById.get(tabId)
+ const currentIdentity = lifecycle.liveTabIdentityById.get(tabId)
+ if (!nextIdentity || nextIdentity !== currentIdentity) {
+ lifecycle.activeRequestIdByTabId.delete(tabId)
+ }
+ }
+ for (const [tabId, currentIdentity] of lifecycle.liveTabIdentityById) {
+ const nextIdentity = nextIdentityById.get(tabId)
+ if (nextIdentity && nextIdentity !== currentIdentity) {
+ replacedTabIds.add(tabId)
+ }
+ }
+ lifecycle.liveTabIdentityById.clear()
+ for (const [tabId, identity] of nextIdentityById) {
+ lifecycle.liveTabIdentityById.set(tabId, identity)
+ }
+ return replacedTabIds
+}
+
+export function beginMobileFileDocLoad(
+ lifecycle: MobileFileDocLifecycle,
+ tab: MobileFileTabIdentity
+): MobileFileDocLoadToken | null {
+ const tabIdentity = getMobileFileTabIdentity(tab)
+ if (lifecycle.liveTabIdentityById.get(tab.id) !== tabIdentity) {
+ return null
+ }
+ const requestId = ++lifecycle.nextRequestId
+ lifecycle.activeRequestIdByTabId.set(tab.id, requestId)
+ return {
+ generation: lifecycle.generation,
+ requestId,
+ tabId: tab.id,
+ tabIdentity
+ }
+}
+
+export function finishMobileFileDocLoad(
+ lifecycle: MobileFileDocLifecycle,
+ token: MobileFileDocLoadToken
+): boolean {
+ if (!isCurrentMobileFileDocLoad(lifecycle, token)) {
+ return false
+ }
+ lifecycle.activeRequestIdByTabId.delete(token.tabId)
+ return true
+}
+
+export function forgetMobileFileDocTab(lifecycle: MobileFileDocLifecycle, tabId: string): void {
+ lifecycle.liveTabIdentityById.delete(tabId)
+ lifecycle.activeRequestIdByTabId.delete(tabId)
+}
+
+export function resetMobileFileDocLifecycle(lifecycle: MobileFileDocLifecycle): void {
+ lifecycle.generation += 1
+ lifecycle.liveTabIdentityById.clear()
+ lifecycle.activeRequestIdByTabId.clear()
+}
+
+export function retainLiveMobileFileDocs(
+ docs: Map,
+ lifecycle: MobileFileDocLifecycle,
+ replacedTabIds?: ReadonlySet
+): Map {
+ let next: Map | null = null
+ for (const tabId of docs.keys()) {
+ if (lifecycle.liveTabIdentityById.has(tabId) && !replacedTabIds?.has(tabId)) {
+ continue
+ }
+ next ??= new Map(docs)
+ next.delete(tabId)
+ }
+ return next ?? docs
+}
+
+export function removeMobileFileDoc(docs: Map, tabId: string): Map {
+ if (!docs.has(tabId)) {
+ return docs
+ }
+ const next = new Map(docs)
+ next.delete(tabId)
+ return next
+}
+
+function isCurrentMobileFileDocLoad(
+ lifecycle: MobileFileDocLifecycle,
+ token: MobileFileDocLoadToken
+): boolean {
+ return (
+ lifecycle.generation === token.generation &&
+ lifecycle.liveTabIdentityById.get(token.tabId) === token.tabIdentity &&
+ lifecycle.activeRequestIdByTabId.get(token.tabId) === token.requestId
+ )
+}
+
+function getMobileFileTabIdentity(tab: MobileFileTabIdentity): string {
+ return JSON.stringify([
+ tab.id,
+ tab.filePath,
+ tab.relativePath,
+ tab.mode ?? '',
+ tab.diffSource ?? ''
+ ])
+}
+
+function isMobileFileTabIdentity(
+ tab: MobileFileTabCandidate
+): tab is MobileFileTabCandidate & MobileFileTabIdentity {
+ return (
+ tab.type === 'file' &&
+ typeof tab.filePath === 'string' &&
+ typeof tab.relativePath === 'string' &&
+ (tab.mode === undefined || tab.mode === 'edit' || tab.mode === 'diff') &&
+ (tab.diffSource === undefined ||
+ tab.diffSource === 'staged' ||
+ tab.diffSource === 'unstaged' ||
+ tab.diffSource === 'branch' ||
+ tab.diffSource === 'commit')
+ )
+}
+
+function getMobileFileDocLoadErrorMessage(tab: MobileFileTabIdentity, error: unknown): string {
+ const message = error instanceof Error ? error.message : ''
+ if (message === 'binary_file') {
+ return 'Binary preview unavailable'
+ }
+ if (message === 'file_too_large') {
+ return 'File too large for mobile preview'
+ }
+ return tab.diffSource === 'staged' || tab.diffSource === 'unstaged'
+ ? "Couldn't load diff preview"
+ : "Couldn't load file preview"
+}
diff --git a/mobile/src/session/mobile-session-markdown-doc-lifecycle.test.ts b/mobile/src/session/mobile-session-markdown-doc-lifecycle.test.ts
new file mode 100644
index 00000000000..ce566029f3e
--- /dev/null
+++ b/mobile/src/session/mobile-session-markdown-doc-lifecycle.test.ts
@@ -0,0 +1,106 @@
+import { describe, expect, it } from 'vitest'
+import type {
+ MarkdownDocState,
+ MobileSessionTab
+} from '../../app/h/[hostId]/session/mobile-session-route-types'
+import { MobileSessionMarkdownDocLifecycle } from './mobile-session-markdown-doc-lifecycle'
+
+type MarkdownTab = Extract
+type ReadyMarkdownDoc = Extract
+
+function markdownTab(id: string, relativePath = `${id}.md`): MarkdownTab {
+ return {
+ type: 'markdown',
+ id,
+ title: relativePath,
+ filePath: `/repo/${relativePath}`,
+ relativePath,
+ isDirty: false,
+ isActive: false,
+ documentVersion: 'v1'
+ }
+}
+
+function readyDoc(content: string, isDirty = false): ReadyMarkdownDoc {
+ return {
+ status: 'ready',
+ content,
+ localContent: content,
+ baseVersion: 'v1',
+ isDirty,
+ editable: true
+ }
+}
+
+describe('mobile session markdown document lifecycle', () => {
+ it('retains payloads only for markdown tabs in the accepted snapshot', () => {
+ const lifecycle = new MobileSessionMarkdownDocLifecycle()
+ let docs = new Map([
+ ['closed', readyDoc('x'.repeat(1_000_000))],
+ ['live', readyDoc('live')]
+ ])
+
+ lifecycle.reconcile([markdownTab('live')], (update) => {
+ docs = update(docs)
+ })
+
+ expect([...docs.keys()]).toEqual(['live'])
+ })
+
+ it('retains a dirty orphan when reconciliation keeps its draft tab', () => {
+ const lifecycle = new MobileSessionMarkdownDocLifecycle()
+ let docs = new Map([['draft', readyDoc('unsaved', true)]])
+
+ lifecycle.reconcile([markdownTab('draft')], (update) => {
+ docs = update(docs)
+ })
+
+ expect(docs.get('draft')).toEqual(readyDoc('unsaved', true))
+ })
+
+ it('does not let a late read resurrect a closed tab', async () => {
+ const lifecycle = new MobileSessionMarkdownDocLifecycle()
+ const tab = markdownTab('late')
+ let docs = new Map()
+ const updateDocs = (update: (current: typeof docs) => typeof docs) => {
+ docs = update(docs)
+ }
+ lifecycle.reconcile([tab], updateDocs)
+ let resolveRead: (doc: ReadyMarkdownDoc) => void = () => undefined
+ const read = new Promise((resolve) => {
+ resolveRead = resolve
+ })
+
+ const pending = lifecycle.load(tab, updateDocs, () => read)
+ lifecycle.close(tab.id, updateDocs)
+ resolveRead(readyDoc('late payload'))
+ await pending
+
+ expect(docs.size).toBe(0)
+ })
+
+ it('does not let an old path overwrite a replacement using the same tab id', async () => {
+ const lifecycle = new MobileSessionMarkdownDocLifecycle()
+ const oldTab = markdownTab('same', 'old.md')
+ let docs = new Map()
+ const updateDocs = (update: (current: typeof docs) => typeof docs) => {
+ docs = update(docs)
+ }
+ lifecycle.reconcile([oldTab], updateDocs)
+ let resolveRead: (doc: ReadyMarkdownDoc) => void = () => undefined
+ const pending = lifecycle.load(
+ oldTab,
+ updateDocs,
+ () =>
+ new Promise((resolve) => {
+ resolveRead = resolve
+ })
+ )
+
+ lifecycle.reconcile([markdownTab('same', 'new.md')], updateDocs)
+ resolveRead(readyDoc('old payload'))
+ await pending
+
+ expect(docs.size).toBe(0)
+ })
+})
diff --git a/mobile/src/session/mobile-session-markdown-doc-lifecycle.ts b/mobile/src/session/mobile-session-markdown-doc-lifecycle.ts
new file mode 100644
index 00000000000..29554ce3c69
--- /dev/null
+++ b/mobile/src/session/mobile-session-markdown-doc-lifecycle.ts
@@ -0,0 +1,147 @@
+import type {
+ MarkdownDocState,
+ MobileSessionTab
+} from '../../app/h/[hostId]/session/mobile-session-route-types'
+
+type MobileMarkdownTab = Extract
+
+type MobileMarkdownDocUpdater = (
+ update: (current: Map) => Map
+) => void
+
+type MobileMarkdownDocLoadToken = {
+ generation: number
+ requestId: number
+ tabId: string
+ tabIdentity: string
+}
+
+export class MobileSessionMarkdownDocLifecycle {
+ private generation = 0
+ private nextRequestId = 0
+ private readonly liveTabIdentityById = new Map()
+ private readonly activeRequestIdByTabId = new Map()
+
+ reconcile(tabs: readonly MobileSessionTab[], updateDocs: MobileMarkdownDocUpdater): void {
+ const nextIdentityById = new Map(
+ tabs
+ .filter((tab): tab is MobileMarkdownTab => tab.type === 'markdown')
+ .map((tab) => [tab.id, markdownTabIdentity(tab)])
+ )
+ const replacedTabIds = new Set()
+ for (const [tabId, currentIdentity] of this.liveTabIdentityById) {
+ const nextIdentity = nextIdentityById.get(tabId)
+ if (nextIdentity && nextIdentity !== currentIdentity) {
+ replacedTabIds.add(tabId)
+ }
+ }
+ for (const tabId of this.activeRequestIdByTabId.keys()) {
+ if (nextIdentityById.get(tabId) !== this.liveTabIdentityById.get(tabId)) {
+ this.activeRequestIdByTabId.delete(tabId)
+ }
+ }
+ this.liveTabIdentityById.clear()
+ for (const [tabId, identity] of nextIdentityById) {
+ this.liveTabIdentityById.set(tabId, identity)
+ }
+ updateDocs((current) => retainLiveMarkdownDocs(current, nextIdentityById, replacedTabIds))
+ }
+
+ async load(
+ tab: MobileMarkdownTab,
+ updateDocs: MobileMarkdownDocUpdater,
+ read: () => Promise>
+ ): Promise {
+ const token = this.beginLoad(tab)
+ if (!token) {
+ return
+ }
+ updateDocs((current) => new Map(current).set(tab.id, { status: 'loading' }))
+ try {
+ const doc = await read()
+ if (this.finishLoad(token)) {
+ updateDocs((current) => new Map(current).set(tab.id, doc))
+ }
+ } catch {
+ if (this.finishLoad(token)) {
+ updateDocs((current) =>
+ new Map(current).set(tab.id, {
+ status: 'error',
+ message: "Couldn't load markdown"
+ })
+ )
+ }
+ }
+ }
+
+ close(tabId: string, updateDocs: MobileMarkdownDocUpdater): void {
+ this.liveTabIdentityById.delete(tabId)
+ this.activeRequestIdByTabId.delete(tabId)
+ updateDocs((current) => removeMarkdownDoc(current, tabId))
+ }
+
+ reset(): void {
+ this.generation += 1
+ this.liveTabIdentityById.clear()
+ this.activeRequestIdByTabId.clear()
+ }
+
+ private beginLoad(tab: MobileMarkdownTab): MobileMarkdownDocLoadToken | null {
+ const tabIdentity = markdownTabIdentity(tab)
+ if (this.liveTabIdentityById.get(tab.id) !== tabIdentity) {
+ return null
+ }
+ const requestId = ++this.nextRequestId
+ this.activeRequestIdByTabId.set(tab.id, requestId)
+ return {
+ generation: this.generation,
+ requestId,
+ tabId: tab.id,
+ tabIdentity
+ }
+ }
+
+ private finishLoad(token: MobileMarkdownDocLoadToken): boolean {
+ if (
+ token.generation !== this.generation ||
+ this.liveTabIdentityById.get(token.tabId) !== token.tabIdentity ||
+ this.activeRequestIdByTabId.get(token.tabId) !== token.requestId
+ ) {
+ return false
+ }
+ this.activeRequestIdByTabId.delete(token.tabId)
+ return true
+ }
+}
+
+function retainLiveMarkdownDocs(
+ docs: Map,
+ liveIdentityById: ReadonlyMap,
+ replacedTabIds: ReadonlySet
+): Map {
+ let next: Map | null = null
+ for (const tabId of docs.keys()) {
+ if (liveIdentityById.has(tabId) && !replacedTabIds.has(tabId)) {
+ continue
+ }
+ next ??= new Map(docs)
+ next.delete(tabId)
+ }
+ return next ?? docs
+}
+
+function removeMarkdownDoc(
+ docs: Map,
+ tabId: string
+): Map {
+ if (!docs.has(tabId)) {
+ return docs
+ }
+ const next = new Map(docs)
+ next.delete(tabId)
+ return next
+}
+
+function markdownTabIdentity(tab: MobileMarkdownTab): string {
+ return JSON.stringify([tab.id, tab.filePath, tab.relativePath])
+}
diff --git a/mobile/src/session/mobile-terminal-diagnostics.test.ts b/mobile/src/session/mobile-terminal-diagnostics.test.ts
index 831ad4bfccc..d1c49794664 100644
--- a/mobile/src/session/mobile-terminal-diagnostics.test.ts
+++ b/mobile/src/session/mobile-terminal-diagnostics.test.ts
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
import {
getMobileTerminalDiagnosticErrorName,
logMobileTerminalDiagnostic,
+ MOBILE_TERMINAL_DIAGNOSTIC_MAX_TRACKED_HANDLES,
MobileTerminalDiagnostics,
shortenMobileTerminalDiagnosticId
} from './mobile-terminal-diagnostics'
@@ -43,4 +44,28 @@ describe('mobile terminal diagnostics', () => {
expect(log).toHaveBeenCalledTimes(2)
log.mockRestore()
})
+
+ it('accepts the exact diagnostic handle cap and evicts oldest at one over', () => {
+ const log = vi.spyOn(console, 'log').mockImplementation(() => {})
+ const diagnostics = new MobileTerminalDiagnostics()
+ for (let index = 0; index < MOBILE_TERMINAL_DIAGNOSTIC_MAX_TRACKED_HANDLES; index += 1) {
+ diagnostics.streamSkipped(`terminal-${index}`, 'inactive', false)
+ diagnostics.firstStreamEvent(`terminal-${index}`, 1, 'subscribed')
+ }
+ expect(diagnostics.retainedHandleCountsForTests()).toEqual({
+ streamGates: MOBILE_TERMINAL_DIAGNOSTIC_MAX_TRACKED_HANDLES,
+ firstEvents: MOBILE_TERMINAL_DIAGNOSTIC_MAX_TRACKED_HANDLES
+ })
+
+ diagnostics.streamSkipped('one-over', 'inactive', false)
+ diagnostics.firstStreamEvent('one-over', 1, 'subscribed')
+ expect(diagnostics.retainedHandleCountsForTests()).toEqual({
+ streamGates: MOBILE_TERMINAL_DIAGNOSTIC_MAX_TRACKED_HANDLES,
+ firstEvents: MOBILE_TERMINAL_DIAGNOSTIC_MAX_TRACKED_HANDLES
+ })
+ diagnostics.streamSkipped('terminal-0', 'inactive', false)
+ diagnostics.firstStreamEvent('terminal-0', 1, 'subscribed')
+ expect(log).toHaveBeenCalledTimes(MOBILE_TERMINAL_DIAGNOSTIC_MAX_TRACKED_HANDLES * 2 + 4)
+ log.mockRestore()
+ })
})
diff --git a/mobile/src/session/mobile-terminal-diagnostics.ts b/mobile/src/session/mobile-terminal-diagnostics.ts
index 8695928ed03..4005d564ad3 100644
--- a/mobile/src/session/mobile-terminal-diagnostics.ts
+++ b/mobile/src/session/mobile-terminal-diagnostics.ts
@@ -1,4 +1,5 @@
const MOBILE_TERMINAL_DIAGNOSTIC_TAG = '[terminal-diagnostic]'
+export const MOBILE_TERMINAL_DIAGNOSTIC_MAX_TRACKED_HANDLES = 1_024
type MobileTerminalDiagnosticValue = string | number | boolean | null | undefined
@@ -90,7 +91,7 @@ export class MobileTerminalDiagnostics {
if (this.streamGateByHandle.get(handle) === reason) {
return
}
- this.streamGateByHandle.set(handle, reason)
+ setBoundedHandleState(this.streamGateByHandle, handle, reason)
logMobileTerminalDiagnostic('stream-skipped', {
handle: shortenMobileTerminalDiagnosticId(handle),
reason,
@@ -113,7 +114,7 @@ export class MobileTerminalDiagnostics {
if (this.firstStreamEventSeqByHandle.get(handle) === seq) {
return
}
- this.firstStreamEventSeqByHandle.set(handle, seq)
+ setBoundedHandleState(this.firstStreamEventSeqByHandle, handle, seq)
logMobileTerminalDiagnostic('stream-first-event', {
handle: shortenMobileTerminalDiagnosticId(handle),
seq,
@@ -267,6 +268,14 @@ export class MobileTerminalDiagnostics {
})
}
+ /** Test-only evidence for process-retained diagnostic dedupe state. */
+ retainedHandleCountsForTests(): { streamGates: number; firstEvents: number } {
+ return {
+ streamGates: this.streamGateByHandle.size,
+ firstEvents: this.firstStreamEventSeqByHandle.size
+ }
+ }
+
private logTabs(
event: 'tabs-applied' | 'tabs-fetch-success',
snapshot: DiagnosticTabsSnapshot,
@@ -289,3 +298,15 @@ export class MobileTerminalDiagnostics {
})
}
}
+
+function setBoundedHandleState(map: Map, handle: string, value: T): void {
+ map.delete(handle)
+ map.set(handle, value)
+ while (map.size > MOBILE_TERMINAL_DIAGNOSTIC_MAX_TRACKED_HANDLES) {
+ const oldestHandle = map.keys().next().value
+ if (typeof oldestHandle !== 'string') {
+ return
+ }
+ map.delete(oldestHandle)
+ }
+}
diff --git a/mobile/src/session/pr-actions-engine.test.ts b/mobile/src/session/pr-actions-engine.test.ts
index 3656b2732f6..17b22fa87f1 100644
--- a/mobile/src/session/pr-actions-engine.test.ts
+++ b/mobile/src/session/pr-actions-engine.test.ts
@@ -1,5 +1,9 @@
import { describe, expect, it, vi } from 'vitest'
-import { PrActionsEngine, type PrActionMutations } from './pr-actions-engine'
+import {
+ MOBILE_PR_ACTIONS_MAX_REVIEWER_FIELDS,
+ PrActionsEngine,
+ type PrActionMutations
+} from './pr-actions-engine'
import type { GitHubPrMutationOutcome } from './github-pr-mutations'
function deferred() {
@@ -238,3 +242,32 @@ describe('PrActionsEngine — PR identity changes', () => {
expect(refetch).not.toHaveBeenCalled()
})
})
+
+describe('PrActionsEngine — reviewer retention', () => {
+ it('releases settled reviewer fields so sequential reviewer actions do not accumulate', async () => {
+ const engine = makeEngine({})
+
+ for (let index = 0; index < MOBILE_PR_ACTIONS_MAX_REVIEWER_FIELDS + 1; index += 1) {
+ await engine.requestReviewer(`reviewer-${index}`)
+ }
+
+ expect(engine.retainedReviewerFieldCountForTests()).toBe(0)
+ })
+
+ it('accepts the exact concurrent reviewer cap and rejects one over', async () => {
+ const pending = deferred()
+ const engine = makeEngine({ requestReviewers: () => pending.promise })
+ const actions = Array.from({ length: MOBILE_PR_ACTIONS_MAX_REVIEWER_FIELDS }, (_, index) =>
+ engine.requestReviewer(`reviewer-${index}`)
+ )
+ expect(engine.retainedReviewerFieldCountForTests()).toBe(MOBILE_PR_ACTIONS_MAX_REVIEWER_FIELDS)
+
+ await expect(engine.requestReviewer('one-over')).rejects.toThrow(
+ 'Too many reviewer actions are pending'
+ )
+
+ pending.resolve({ ok: true })
+ await Promise.all(actions)
+ expect(engine.retainedReviewerFieldCountForTests()).toBe(0)
+ })
+})
diff --git a/mobile/src/session/pr-actions-engine.ts b/mobile/src/session/pr-actions-engine.ts
index ce19c9bbbf9..04bc1ed0afe 100644
--- a/mobile/src/session/pr-actions-engine.ts
+++ b/mobile/src/session/pr-actions-engine.ts
@@ -8,6 +8,9 @@ import type { PrActionMutations } from './pr-action-mutation-contract'
export type { PrActionMutations } from './pr-action-mutation-contract'
+export const MOBILE_PR_ACTIONS_MAX_REVIEWER_FIELDS = 64
+const MOBILE_PR_ACTIONS_MAX_REVIEWER_LOGIN_CHARACTERS = 256
+
// Pure (React-free) engine for the PR mutation actions: owns optimistic fields,
// busy/error/blocked state, and the success/transient/permanent routing. The hook
// is a thin adapter that subscribes to `onChange` and exposes these methods. Kept
@@ -96,12 +99,25 @@ export class PrActionsEngine {
private reviewerField(login: string): OptimisticField {
let f = this.reviewerFields.get(login)
if (!f) {
+ if (
+ login.length === 0 ||
+ login.length > MOBILE_PR_ACTIONS_MAX_REVIEWER_LOGIN_CHARACTERS ||
+ this.reviewerFields.size >= MOBILE_PR_ACTIONS_MAX_REVIEWER_FIELDS
+ ) {
+ throw new Error('Too many reviewer actions are pending')
+ }
f = createOptimisticField(this.cfg.onChange)
this.reviewerFields.set(login, f)
}
return f
}
+ private releaseReviewerFieldIfIdle(login: string, field: OptimisticField): void {
+ if (field.peek() === undefined && this.reviewerFields.get(login) === field) {
+ this.reviewerFields.delete(login)
+ }
+ }
+
// Why: action start pairs setBusy + setError(null); skip notify when unchanged
// so we don't force a full PR panel re-render for free.
private setBusy(key: PrActionBusyKey | null): void {
@@ -264,6 +280,7 @@ export class PrActionsEngine {
})
} finally {
this.clearBusyIfOwned(identity, { kind: 'reviewer', login })
+ this.releaseReviewerFieldIfIdle(login, field)
}
}
@@ -286,6 +303,7 @@ export class PrActionsEngine {
})
} finally {
this.clearBusyIfOwned(identity, { kind: 'reviewer', login })
+ this.releaseReviewerFieldIfIdle(login, field)
}
}
@@ -319,4 +337,9 @@ export class PrActionsEngine {
const f = this.reviewerFields.get(login)
return f ? f.resolve(authoritative) : authoritative
}
+
+ /** Test-only evidence for retained optimistic reviewer state. */
+ retainedReviewerFieldCountForTests(): number {
+ return this.reviewerFields.size
+ }
}
diff --git a/mobile/src/session/use-mobile-native-chat-controller.test.ts b/mobile/src/session/use-mobile-native-chat-controller.test.ts
new file mode 100644
index 00000000000..487e0a4b2bb
--- /dev/null
+++ b/mobile/src/session/use-mobile-native-chat-controller.test.ts
@@ -0,0 +1,152 @@
+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'
+
+const acceptSend = vi.fn()
+const captureSendOrigin = vi.fn()
+const clearDraftForSend = vi.fn()
+const restoreRejectedDraft = vi.fn()
+const holdUnconfirmedSend = vi.fn()
+
+// The controller composes many session hooks; each is mocked to a minimal shape
+// so this test isolates the send seam (outcome -> drafts accounting).
+vi.mock('./use-mobile-session-view-mode', () => ({
+ useMobileSessionViewMode: () => ({ isTabChatView: () => true, toggleTabChatView: vi.fn() })
+}))
+vi.mock('./use-mobile-native-chat-session', () => ({
+ useMobileNativeChatSession: () => ({ messages: [] })
+}))
+vi.mock('./use-mobile-native-chat-drafts', () => ({
+ useMobileNativeChatDrafts: () => ({
+ composerText: '',
+ setComposerText: vi.fn(),
+ pending: [],
+ captureSendOrigin,
+ clearDraftForSend,
+ restoreRejectedDraft,
+ acceptSend,
+ holdUnconfirmedSend
+ })
+}))
+vi.mock('./use-mobile-native-chat-prompts', () => ({
+ useMobileNativeChatPrompts: () => ({ permission: null, question: null, ask: null })
+}))
+vi.mock('./use-mobile-native-chat-answer-send', () => ({
+ useMobileNativeChatAnswerSend: () => ({ answerAsk: vi.fn(), cancelPending: vi.fn() })
+}))
+vi.mock('./mobile-native-chat-permission-send', () => ({
+ useMobileNativeChatPermissionSend: () => vi.fn()
+}))
+vi.mock('./use-mobile-native-chat-stop', () => ({
+ useMobileNativeChatStop: () => vi.fn()
+}))
+vi.mock('./use-mobile-native-chat-file-search', () => ({
+ useMobileNativeChatFileSearch: () => ({ nativeChatFilePaths: [], loadNativeChatFiles: vi.fn() })
+}))
+vi.mock('./mobile-native-chat-send', () => ({
+ sendMobileNativeChatMessageWithOutcome: vi.fn()
+}))
+
+import { sendMobileNativeChatMessageWithOutcome } from './mobile-native-chat-send'
+import {
+ useMobileNativeChatController,
+ type MobileNativeChatController
+} from './use-mobile-native-chat-controller'
+
+const sendWithOutcome = vi.mocked(sendMobileNativeChatMessageWithOutcome)
+
+const ORIGIN = {
+ draftKey: 'h\0w\0tab-1',
+ pendingKey: 'h\0w\0tab-1\0session-1',
+ normalizedText: 'look',
+ baselineOccurrences: 0,
+ baselineTailMessageId: null
+}
+
+describe('useMobileNativeChatController handleNativeChatSend', () => {
+ let renderer: ReactTestRenderer | null = null
+ let controller: MobileNativeChatController | null = null
+ const onSendError = vi.fn()
+
+ function Harness(): null {
+ controller = useMobileNativeChatController({
+ client: {} as RpcClient,
+ hostId: 'h',
+ worktreeId: 'w',
+ activeSessionTab: null,
+ activeSessionTabId: 'tab-1',
+ activeHandleRef: { current: 'term-1' },
+ deviceTokenRef: { current: null },
+ nativeChatTranscriptIsLocalReadable: true,
+ nativeChatInputLeaseReady: true,
+ onSendError
+ })
+ return null
+ }
+
+ beforeEach(() => {
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true
+ vi.clearAllMocks()
+ captureSendOrigin.mockReturnValue(ORIGIN)
+ const original = console.error
+ const spy = vi.spyOn(console, 'error').mockImplementation((...a) => {
+ if (typeof a[0] === 'string' && a[0].includes('react-test-renderer is deprecated')) {
+ return
+ }
+ original(...a)
+ })
+ try {
+ act(() => {
+ renderer = create(createElement(Harness))
+ })
+ } finally {
+ spy.mockRestore()
+ }
+ })
+ afterEach(() => {
+ act(() => renderer?.unmount())
+ renderer = null
+ controller = null
+ })
+
+ it('threads the optimistic-echo image URIs into acceptSend on an accepted send', async () => {
+ sendWithOutcome.mockResolvedValue('accepted')
+ let accepted = false
+ await act(async () => {
+ accepted = await controller!.handleNativeChatSend('look', ['file:///a.jpg'])
+ })
+ expect(accepted).toBe(true)
+ expect(acceptSend).toHaveBeenCalledWith(ORIGIN, 'look', ['file:///a.jpg'])
+ // Optimistic clear happens at send time, never a restore on success.
+ expect(clearDraftForSend).toHaveBeenCalledWith(ORIGIN, 'look')
+ expect(restoreRejectedDraft).not.toHaveBeenCalled()
+ })
+
+ it('holds an unknown-outcome send without posting the optimistic echo', async () => {
+ sendWithOutcome.mockResolvedValue('unknown')
+ let accepted = false
+ await act(async () => {
+ accepted = await controller!.handleNativeChatSend('look', ['file:///a.jpg'])
+ })
+ expect(accepted).toBe(true)
+ expect(acceptSend).not.toHaveBeenCalled()
+ expect(holdUnconfirmedSend).toHaveBeenCalledWith(ORIGIN, 'look', expect.any(Function))
+ // Delivery-unknown usually means delivered — keep the composer clear.
+ expect(clearDraftForSend).toHaveBeenCalledWith(ORIGIN, 'look')
+ expect(restoreRejectedDraft).not.toHaveBeenCalled()
+ })
+
+ it('reports a rejected send and posts no echo', async () => {
+ sendWithOutcome.mockResolvedValue('rejected')
+ let accepted = true
+ await act(async () => {
+ accepted = await controller!.handleNativeChatSend('look', ['file:///a.jpg'])
+ })
+ expect(accepted).toBe(false)
+ expect(acceptSend).not.toHaveBeenCalled()
+ expect(onSendError).toHaveBeenCalledWith('Message not sent')
+ // A definite rejection puts the optimistically-cleared text back.
+ expect(restoreRejectedDraft).toHaveBeenCalledWith(ORIGIN, 'look')
+ })
+})
diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts
index 6b1ae3f1033..f3a963bb3da 100644
--- a/mobile/src/session/use-mobile-native-chat-controller.ts
+++ b/mobile/src/session/use-mobile-native-chat-controller.ts
@@ -19,7 +19,10 @@ import { openMobileNativeChatFile } from './mobile-native-chat-open-file'
import { useMobileNativeChatPermissionSend } from './mobile-native-chat-permission-send'
import { sendMobileNativeChatMessageWithOutcome } from './mobile-native-chat-send'
import { useMobileNativeChatAnswerSend } from './use-mobile-native-chat-answer-send'
-import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts'
+import {
+ useMobileNativeChatDrafts,
+ type MobileNativeChatPendingMessage
+} from './use-mobile-native-chat-drafts'
import { useMobileNativeChatFileSearch } from './use-mobile-native-chat-file-search'
import { useMobileNativeChatSession } from './use-mobile-native-chat-session'
import { useMobileNativeChatPrompts } from './use-mobile-native-chat-prompts'
@@ -38,7 +41,7 @@ export type MobileNativeChatController = {
nativeChatAgent: string | null
chatComposerText: string
setChatComposerText: Dispatch>
- chatPending: Array<{ id: string; text: string }>
+ chatPending: MobileNativeChatPendingMessage[]
nativeChatSession: ReturnType
nativeChatAgentWorking: boolean
nativeChatStreamingText?: string
@@ -55,7 +58,7 @@ export type MobileNativeChatController = {
handleNativeChatStop: () => void
nativeChatFilePaths: string[]
loadNativeChatFiles: (query: string) => void
- handleNativeChatSend: (text: string) => Promise
+ handleNativeChatSend: (text: string, images?: string[]) => Promise
}
/** Owns mobile native-chat state and teardown outside the already dense session
@@ -224,7 +227,7 @@ export function useMobileNativeChatController(args: {
})
const handleNativeChatSend = useCallback(
- async (text: string): Promise => {
+ async (text: string, images?: string[]): Promise => {
const handle = activeHandleRef.current
const origin = captureSendOrigin(text)
if (!client || !handle || !origin || !nativeChatInputLeaseReady) {
@@ -256,7 +259,9 @@ export function useMobileNativeChatController(args: {
onSendError('Message not sent')
return false
}
- acceptSend(origin, text)
+ // `images` are local preview URIs for the optimistic echo only — the actual
+ // image bytes already rode along as a bracketed paste before this text send.
+ acceptSend(origin, text, images)
return true
},
[
diff --git a/mobile/src/session/use-mobile-native-chat-drafts.test.ts b/mobile/src/session/use-mobile-native-chat-drafts.test.ts
index 3d7042d8dda..233ae34eb7c 100644
--- a/mobile/src/session/use-mobile-native-chat-drafts.test.ts
+++ b/mobile/src/session/use-mobile-native-chat-drafts.test.ts
@@ -218,6 +218,126 @@ describe('useMobileNativeChatDrafts', () => {
expect(state?.pending.map((pending) => pending.text)).toEqual(['ping'])
})
+ it('keeps an image-only echo through an agent reply, clearing only when the user turn lands', async () => {
+ await mount('a')
+ await act(async () =>
+ renderer?.update(
+ createElement(Harness, { tabId: 'a', messages: [assistantTextMessage('a1', 'hi')] })
+ )
+ )
+ const origin = state?.captureSendOrigin('')
+ act(() => {
+ if (origin) {
+ state?.acceptSend(origin, '', ['file:///a.jpg'])
+ }
+ })
+ // The echo carries the preview thumbnail and has no text to match against.
+ expect(state?.pending.map((pending) => pending.images)).toEqual([['file:///a.jpg']])
+
+ // An agent reply grows the transcript but must NOT clear the photo echo early.
+ await act(async () =>
+ renderer?.update(
+ createElement(Harness, {
+ tabId: 'a',
+ messages: [assistantTextMessage('a1', 'hi'), assistantTextMessage('a2', 'nice photo')]
+ })
+ )
+ )
+ expect(state?.pending.map((pending) => pending.images)).toEqual([['file:///a.jpg']])
+
+ // The user's own image echo landing (Claude records it as an
+ // `[Image: source: …]` turn) clears it.
+ await act(async () =>
+ renderer?.update(
+ createElement(Harness, {
+ tabId: 'a',
+ messages: [
+ assistantTextMessage('a1', 'hi'),
+ assistantTextMessage('a2', 'nice photo'),
+ userTextMessage('u1', '[Image: source: /tmp/a.png]')
+ ]
+ })
+ )
+ )
+ expect(state?.pending).toEqual([])
+ })
+
+ it("keeps an image-only echo when an unrelated text send's echo lands", async () => {
+ await mount('a')
+ await act(async () =>
+ renderer?.update(
+ createElement(Harness, { tabId: 'a', messages: [assistantTextMessage('a1', 'hi')] })
+ )
+ )
+ const textOrigin = state?.captureSendOrigin('ping')
+ const imageOrigin = state?.captureSendOrigin('')
+ act(() => {
+ if (textOrigin && imageOrigin) {
+ state?.acceptSend(textOrigin, 'ping')
+ state?.acceptSend(imageOrigin, '', ['file:///a.jpg'])
+ }
+ })
+ expect(state?.pending).toHaveLength(2)
+
+ // The text echo lands first: it must clear only the text pending — a user
+ // turn that is not an image echo cannot reconcile the photo.
+ await act(async () =>
+ renderer?.update(
+ createElement(Harness, {
+ tabId: 'a',
+ messages: [assistantTextMessage('a1', 'hi'), userTextMessage('u1', 'ping')]
+ })
+ )
+ )
+ expect(state?.pending.map((pending) => pending.images)).toEqual([['file:///a.jpg']])
+
+ await act(async () =>
+ renderer?.update(
+ createElement(Harness, {
+ tabId: 'a',
+ messages: [
+ assistantTextMessage('a1', 'hi'),
+ userTextMessage('u1', 'ping'),
+ userTextMessage('u2', '[Image: source: /tmp/a.png]')
+ ]
+ })
+ )
+ )
+ expect(state?.pending).toEqual([])
+ })
+
+ it('reconciles a captioned image echo that carries the [Image #N] marker', async () => {
+ await mount('a')
+ await act(async () =>
+ renderer?.update(
+ createElement(Harness, { tabId: 'a', messages: [assistantTextMessage('a1', 'hi')] })
+ )
+ )
+ const origin = state?.captureSendOrigin('look at this')
+ act(() => {
+ if (origin) {
+ state?.acceptSend(origin, 'look at this', ['file:///a.jpg'])
+ }
+ })
+ expect(state?.pending).toHaveLength(1)
+
+ // Claude echoes a captioned image send as two turns: the source marker and
+ // the caption prefixed with `[Image #1] ` — the pending must still match.
+ await act(async () =>
+ renderer?.update(
+ createElement(Harness, {
+ tabId: 'a',
+ messages: [
+ assistantTextMessage('a1', 'hi'),
+ userTextMessage('u1', '[Image: source: /tmp/a.png]'),
+ userTextMessage('u2', '[Image #1] look at this')
+ ]
+ })
+ )
+ )
+ expect(state?.pending).toEqual([])
+ })
+
it('does not reconcile a repeated send against an older identical turn', async () => {
await mount('a')
await act(async () =>
@@ -296,6 +416,84 @@ describe('useMobileNativeChatDrafts', () => {
}
})
+ it('reconciles an image-only unconfirmed send against the next user turn (no false warning)', async () => {
+ vi.useFakeTimers()
+ try {
+ await mount('a')
+ await act(async () =>
+ renderer?.update(
+ createElement(Harness, { tabId: 'a', messages: [assistantTextMessage('a1', 'hi')] })
+ )
+ )
+ // Image-only send: empty text, so it can only reconcile against a new user turn.
+ const origin = state?.captureSendOrigin('')
+ const onUnconfirmed = vi.fn()
+ act(() => {
+ if (origin) {
+ state?.holdUnconfirmedSend(origin, '', onUnconfirmed)
+ }
+ })
+
+ // An agent reply must not confirm it...
+ await act(async () =>
+ renderer?.update(
+ createElement(Harness, {
+ tabId: 'a',
+ messages: [assistantTextMessage('a1', 'hi'), assistantTextMessage('a2', 'ok')]
+ })
+ )
+ )
+ // ...but the user's own turn landing does, so the deadline never warns.
+ await act(async () =>
+ renderer?.update(
+ createElement(Harness, {
+ tabId: 'a',
+ messages: [
+ assistantTextMessage('a1', 'hi'),
+ assistantTextMessage('a2', 'ok'),
+ userTextMessage('u1', '')
+ ]
+ })
+ )
+ )
+ act(() => vi.advanceTimersByTime(30_000))
+ expect(onUnconfirmed).not.toHaveBeenCalled()
+ } finally {
+ vi.useRealTimers()
+ }
+ })
+
+ it('clears image-only echoes one per landed user turn, not all at once', async () => {
+ await mount('a')
+ await act(async () =>
+ renderer?.update(
+ createElement(Harness, { tabId: 'a', messages: [assistantTextMessage('a1', 'hi')] })
+ )
+ )
+ const origin = state?.captureSendOrigin('')
+ act(() => {
+ if (origin) {
+ state?.acceptSend(origin, '', ['file:///a.jpg'])
+ state?.acceptSend(origin, '', ['file:///b.jpg'])
+ }
+ })
+ expect(state?.pending).toHaveLength(2)
+
+ // Only one image echo has landed — exactly one photo reconciles.
+ await act(async () =>
+ renderer?.update(
+ createElement(Harness, {
+ tabId: 'a',
+ messages: [
+ assistantTextMessage('a1', 'hi'),
+ userTextMessage('u1', '[Image: source: /tmp/a.png]')
+ ]
+ })
+ )
+ )
+ expect(state?.pending.map((pending) => pending.images)).toEqual([['file:///b.jpg']])
+ })
+
it('registers no deadline when the transcript echo beat the ambiguous RPC rejection', async () => {
vi.useFakeTimers()
try {
diff --git a/mobile/src/session/use-mobile-native-chat-drafts.ts b/mobile/src/session/use-mobile-native-chat-drafts.ts
index b0adbf07dd5..a72a08f68ad 100644
--- a/mobile/src/session/use-mobile-native-chat-drafts.ts
+++ b/mobile/src/session/use-mobile-native-chat-drafts.ts
@@ -1,10 +1,25 @@
import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
+import {
+ countImageSourceTurnsAfter,
+ countUserTextOccurrences,
+ findLandedUnconfirmedSends,
+ normalizedUserText,
+ type UnconfirmedSend
+} from './mobile-native-chat-draft-reconcile'
+import { mobileNativeChatScopeKey } from './mobile-native-chat-scope-key'
export type MobileNativeChatPendingMessage = {
id: string
text: string
expectedOccurrence: number
+ /** Local preview URIs of images ridden along on the send, rendered as thumbnails
+ * on the echo bubble so the sent photo shows before the transcript catches up. */
+ images?: string[]
+ /** Transcript tail when sent — an image-only echo (no text to match) reconciles
+ * against new `[Image: source: …]` echo turns after this id, so pagination,
+ * agent replies, and unrelated text echoes can't clear it early. */
+ baselineTailMessageId: string | null
}
export type MobileNativeChatSendOrigin = {
draftKey: string
@@ -20,74 +35,6 @@ const NO_PENDING_MESSAGES: MobileNativeChatPendingMessage[] = []
// that delivery remains unconfirmed.
const UNCONFIRMED_SEND_DEADLINE_MS = 20_000
-type UnconfirmedSend = {
- draftKey: string
- pendingKey: string | null
- text: string
- normalizedText: string
- baselineTailMessageId: string | null
- deadline: ReturnType | null
-}
-
-function normalizedUserText(message: NativeChatMessage): string | null {
- if (message.role !== 'user') {
- return null
- }
- const text = message.blocks
- .filter((block) => block.type === 'text')
- .map((block) => (block.type === 'text' ? block.text : ''))
- .join('')
- .trim()
- return text || null
-}
-
-function countUserTextOccurrences(messages: readonly NativeChatMessage[], text: string): number {
- let count = 0
- for (const message of messages) {
- if (normalizedUserText(message) === text) {
- count++
- }
- }
- return count
-}
-
-function findLandedUnconfirmedSends(
- messages: readonly NativeChatMessage[],
- entries: readonly UnconfirmedSend[]
-): UnconfirmedSend[] {
- // Why: pagination prepends old equal text; only unclaimed matches after each captured tail prove new echoes.
- const messageIndexById = new Map()
- const userMessagesByText = new Map>()
- for (const [index, message] of messages.entries()) {
- messageIndexById.set(message.id, index)
- const text = normalizedUserText(message)
- if (text) {
- const current = userMessagesByText.get(text) ?? []
- current.push({ id: message.id, index })
- userMessagesByText.set(text, current)
- }
- }
-
- const claimedMessageIds = new Set()
- const landed: UnconfirmedSend[] = []
- for (const entry of entries) {
- const tailIndex = entry.baselineTailMessageId
- ? messageIndexById.get(entry.baselineTailMessageId)
- : -1
- if (tailIndex === undefined) {
- continue
- }
- const echo = userMessagesByText
- .get(entry.normalizedText)
- ?.find((message) => message.index > tailIndex && !claimedMessageIds.has(message.id))
- if (echo) {
- claimedMessageIds.add(echo.id)
- landed.push(entry)
- }
- }
- return landed
-}
-
export function useMobileNativeChatDrafts(args: {
hostId: string
worktreeId: string
@@ -103,7 +50,7 @@ export function useMobileNativeChatDrafts(args: {
clearDraftForSend: (origin: MobileNativeChatSendOrigin, text: string) => void
/** Put the text back after a definite rejection, unless newer edits exist. */
restoreRejectedDraft: (origin: MobileNativeChatSendOrigin, text: string) => void
- acceptSend: (origin: MobileNativeChatSendOrigin, text: string) => void
+ acceptSend: (origin: MobileNativeChatSendOrigin, text: string, images?: string[]) => void
holdUnconfirmedSend: (
origin: MobileNativeChatSendOrigin,
text: string,
@@ -111,7 +58,7 @@ export function useMobileNativeChatDrafts(args: {
) => void
} {
const { hostId, worktreeId, tabId, sessionId, messages } = args
- const draftKey = tabId ? `${hostId}\0${worktreeId}\0${tabId}` : null
+ const draftKey = mobileNativeChatScopeKey(hostId, worktreeId, tabId)
const pendingKey = draftKey && sessionId ? `${draftKey}\0${sessionId}` : null
const [drafts, setDrafts] = useState>({})
const [pendingBySession, setPendingBySession] = useState<
@@ -176,29 +123,46 @@ export function useMobileNativeChatDrafts(args: {
)
}, [])
- const acceptSend = useCallback((origin: MobileNativeChatSendOrigin, text: string) => {
- // Why: the first prompt can be sent before the provider reports a session
- // id; wait for an id before keying an optimistic echo.
- if (!origin.pendingKey) {
- return
- }
- const pendingKey = origin.pendingKey
- pendingCounterRef.current += 1
- setPendingBySession((previous) => {
- const current = previous[pendingKey] ?? NO_PENDING_MESSAGES
- const earlierOutstanding = current.filter(
- (pending) =>
- pending.text.trim() === origin.normalizedText &&
- pending.expectedOccurrence > origin.baselineOccurrences
- ).length
- const pending = {
- id: `pending-${pendingCounterRef.current}`,
- text,
- expectedOccurrence: origin.baselineOccurrences + earlierOutstanding + 1
+ const acceptSend = useCallback(
+ (origin: MobileNativeChatSendOrigin, text: string, images?: string[]) => {
+ // Why: the first prompt can be sent before the provider reports a session
+ // id; wait for an id before keying an optimistic echo.
+ if (!origin.pendingKey) {
+ return
}
- return { ...previous, [pendingKey]: [...current, pending] }
- })
- }, [])
+ const pendingKey = origin.pendingKey
+ pendingCounterRef.current += 1
+ setPendingBySession((previous) => {
+ const current = previous[pendingKey] ?? NO_PENDING_MESSAGES
+ const earlierOutstanding = current.filter(
+ (pending) =>
+ pending.text.trim() === origin.normalizedText &&
+ pending.expectedOccurrence > origin.baselineOccurrences
+ ).length
+ // An empty-text send reconciles by image-echo ordinal: every outstanding
+ // send's ridden-along images echo as `[Image: source: …]` turns after
+ // this send's baseline tail, ahead of this send's own echo.
+ const expectedImageEchoOrdinal =
+ current.reduce(
+ (sum, pending) =>
+ sum + (pending.images?.length ?? (pending.text.trim() === '' ? 1 : 0)),
+ 0
+ ) + 1
+ const pending: MobileNativeChatPendingMessage = {
+ id: `pending-${pendingCounterRef.current}`,
+ text,
+ expectedOccurrence:
+ origin.normalizedText === ''
+ ? expectedImageEchoOrdinal
+ : origin.baselineOccurrences + earlierOutstanding + 1,
+ baselineTailMessageId: origin.baselineTailMessageId,
+ ...(images && images.length > 0 ? { images } : {})
+ }
+ return { ...previous, [pendingKey]: [...current, pending] }
+ })
+ },
+ []
+ )
// Why: a relay drop mid-send loses only the ack in the common case — the
// desktop already delivered the message. Hold the send instead of claiming
@@ -291,8 +255,16 @@ export function useMobileNativeChatDrafts(args: {
}
// Why: compare against the count captured before send; historical equal
// turns cannot clear a new echo, while duplicates land one occurrence each.
- const next = current.filter(
- (item) => (landedCounts.get(item.text.trim()) ?? 0) < item.expectedOccurrence
+ // An image-only echo has no text to match, so it reconciles by ORDINAL
+ // against the count of new `[Image: source: …]` echo turns after its
+ // baseline tail — text echoes are excluded so an unrelated outstanding
+ // text send cannot clear it. Ordinal-vs-count stays stable when the effect
+ // re-runs on the shrunken list, and ignores paginated-in history.
+ const next = current.filter((item) =>
+ item.text.trim() === ''
+ ? countImageSourceTurnsAfter(messages, item.baselineTailMessageId) <
+ item.expectedOccurrence
+ : (landedCounts.get(item.text.trim()) ?? 0) < item.expectedOccurrence
)
if (next.length === current.length) {
return previous
diff --git a/mobile/src/session/use-mobile-native-chat-file-search.test.ts b/mobile/src/session/use-mobile-native-chat-file-search.test.ts
index e0500019844..bbd4e8bb4f7 100644
--- a/mobile/src/session/use-mobile-native-chat-file-search.test.ts
+++ b/mobile/src/session/use-mobile-native-chat-file-search.test.ts
@@ -2,7 +2,10 @@ 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 { useMobileNativeChatFileSearch } from './use-mobile-native-chat-file-search'
+import {
+ retainMobileNativeChatFilePaths,
+ useMobileNativeChatFileSearch
+} from './use-mobile-native-chat-file-search'
type SearchState = ReturnType
@@ -163,3 +166,15 @@ describe('useMobileNativeChatFileSearch', () => {
expect(state?.nativeChatFilePaths).toEqual(['docs/readme.md'])
})
})
+
+describe('retainMobileNativeChatFilePaths', () => {
+ it('accepts exact count and retained-byte limits and truncates at one over', () => {
+ const result = {
+ files: [{ relativePath: 'a' }, { relativePath: 'bb' }, { relativePath: 'ignored' }]
+ }
+ const exactBytes = 'a'.length * 2 + 64 + ('bb'.length * 2 + 64)
+
+ expect(retainMobileNativeChatFilePaths(result, 2, exactBytes)).toEqual(['a', 'bb'])
+ expect(retainMobileNativeChatFilePaths(result, 3, exactBytes - 1)).toEqual(['a'])
+ })
+})
diff --git a/mobile/src/session/use-mobile-native-chat-file-search.ts b/mobile/src/session/use-mobile-native-chat-file-search.ts
index 53df98e3ee0..53296947f32 100644
--- a/mobile/src/session/use-mobile-native-chat-file-search.ts
+++ b/mobile/src/session/use-mobile-native-chat-file-search.ts
@@ -5,12 +5,31 @@ import { rankSuggestions } from './mobile-native-chat-autocomplete'
const FILE_SEARCH_DEBOUNCE_MS = 120
const FILE_SEARCH_RESULT_LIMIT = 16
const FILE_SEARCH_QUERY_CACHE_LIMIT = 20
+export const MOBILE_NATIVE_CHAT_LEGACY_FILE_MAX_PATHS = 50_000
+export const MOBILE_NATIVE_CHAT_LEGACY_FILE_MAX_RETAINED_BYTES = 8 * 1024 * 1024
+const FILE_SEARCH_RESULT_MAX_RETAINED_BYTES = 256 * 1024
-function extractPaths(result: unknown): string[] {
+export function retainMobileNativeChatFilePaths(
+ result: unknown,
+ maxPaths: number,
+ maxRetainedBytes: number
+): string[] {
const files = (result as { files?: Array<{ relativePath?: string }> }).files ?? []
- return files
- .map((file) => file.relativePath ?? '')
- .filter((path): path is string => path.length > 0)
+ const paths: string[] = []
+ let retainedBytes = 0
+ for (const file of files) {
+ const path = file.relativePath
+ if (typeof path !== 'string' || path.length === 0) {
+ continue
+ }
+ const nextBytes = retainedBytes + path.length * 2 + 64
+ if (paths.length >= maxPaths || nextBytes > maxRetainedBytes) {
+ break
+ }
+ paths.push(path)
+ retainedBytes = nextBytes
+ }
+ return paths
}
/** Debounces current-host path searches, bounds the mobile result/cache, and
@@ -94,7 +113,11 @@ export function useMobileNativeChatFileSearch(args: {
if (!response.ok || generationRef.current !== generation) {
return null
}
- const paths = extractPaths(response.result)
+ const paths = retainMobileNativeChatFilePaths(
+ response.result,
+ MOBILE_NATIVE_CHAT_LEGACY_FILE_MAX_PATHS,
+ MOBILE_NATIVE_CHAT_LEGACY_FILE_MAX_RETAINED_BYTES
+ )
legacyPathsRef.current = paths
return paths
})
@@ -129,7 +152,13 @@ export function useMobileNativeChatFileSearch(args: {
})
if (response.ok) {
searchSupportedRef.current = true
- applyPaths(extractPaths(response.result))
+ applyPaths(
+ retainMobileNativeChatFilePaths(
+ response.result,
+ FILE_SEARCH_RESULT_LIMIT,
+ FILE_SEARCH_RESULT_MAX_RETAINED_BYTES
+ )
+ )
return
}
if (response.error.code === 'method_not_found') {
diff --git a/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts b/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts
new file mode 100644
index 00000000000..7c91bc4cb78
--- /dev/null
+++ b/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts
@@ -0,0 +1,532 @@
+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 type { RpcResponse, RpcSuccess } from '../transport/types'
+import { useMobileNativeChatImageAttachments } from './use-mobile-native-chat-image-attachments'
+
+// Fully stub the picker so the real expo/react-native chain never loads under
+// the vitest transform (react-native ships Flow syntax rolldown can't parse).
+vi.mock('./mobile-image-source-picker', () => ({
+ pickMobileImage: vi.fn(),
+ ImageLibraryPermissionError: class ImageLibraryPermissionError extends Error {}
+}))
+
+import { pickMobileImage } from './mobile-image-source-picker'
+
+const pick = vi.mocked(pickMobileImage)
+
+function ok(id: string, result: unknown): RpcSuccess {
+ return { id, ok: true, result, _meta: { runtimeId: 'r' } }
+}
+function methodNotFound(id: string): RpcResponse {
+ return {
+ id,
+ ok: false,
+ error: { code: 'method_not_found', message: 'no' },
+ _meta: { runtimeId: 'r' }
+ }
+}
+function sendResult(accepted: boolean): RpcSuccess {
+ return { id: 'send', ok: true, result: { send: { accepted } }, _meta: { runtimeId: 'r' } }
+}
+
+function makeClient(responses: RpcResponse[]): Pick & {
+ calls: { method: string; params: Record }[]
+} {
+ const calls: { method: string; params: Record }[] = []
+ return {
+ calls,
+ sendRequest: vi.fn(async (method: string, params?: unknown) => {
+ calls.push({ method, params: params as Record })
+ const response = responses.shift()
+ if (!response) {
+ throw new Error(`unexpected request: ${method}`)
+ }
+ return response
+ })
+ }
+}
+
+type HookArgs = Parameters[0]
+type Hook = ReturnType
+
+const SCOPE_A = 'h\0w\0tab-a'
+
+function baseArgs(overrides: Partial & Pick): HookArgs {
+ return {
+ activeHandleRef: { current: 'term-1' },
+ deviceTokenRef: { current: null },
+ getActiveWorktreeConnectionId: async () => null,
+ connState: 'connected',
+ scopeKey: SCOPE_A,
+ enabled: true,
+ showToast: vi.fn(),
+ baseSend: vi.fn().mockResolvedValue(true),
+ sleep: async () => {},
+ ...overrides
+ }
+}
+
+describe('useMobileNativeChatImageAttachments', () => {
+ let renderer: ReactTestRenderer | null = null
+ let hook: Hook | null = null
+
+ function Harness({ args }: { args: HookArgs }): null {
+ hook = useMobileNativeChatImageAttachments(args)
+ return null
+ }
+
+ beforeEach(() => {
+ globalThis.IS_REACT_ACT_ENVIRONMENT = true
+ pick.mockReset()
+ })
+ afterEach(() => {
+ act(() => renderer?.unmount())
+ renderer = null
+ hook = null
+ })
+
+ function mount(args: HookArgs): void {
+ const original = console.error
+ const spy = vi.spyOn(console, 'error').mockImplementation((...a) => {
+ if (typeof a[0] === 'string' && a[0].includes('react-test-renderer is deprecated')) {
+ return
+ }
+ original(...a)
+ })
+ try {
+ act(() => {
+ renderer = create(createElement(Harness, { args }))
+ })
+ } finally {
+ spy.mockRestore()
+ }
+ }
+
+ function update(args: HookArgs): void {
+ act(() => {
+ renderer!.update(createElement(Harness, { args }))
+ })
+ }
+
+ it('adds an uploaded image as a chip without pasting to the terminal', async () => {
+ pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
+ const client = makeClient([methodNotFound('start'), ok('save', '/tmp/a.png')])
+ mount(
+ baseArgs({
+ client: client as unknown as RpcClient,
+ deviceTokenRef: { current: 'device-1' },
+ getActiveWorktreeConnectionId: async () => 'conn-1'
+ })
+ )
+
+ await act(async () => {
+ await hook!.attachImage('library')
+ })
+
+ expect(hook!.attachments).toEqual([
+ { id: 'img-1', path: '/tmp/a.png', previewUri: 'file:///a.jpg' }
+ ])
+ expect(client.calls.some((c) => c.method === 'terminal.send')).toBe(false)
+ })
+
+ it('rides pending images along on send: pastes the path, settles, then delegates the text', async () => {
+ pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
+ const client = makeClient([
+ methodNotFound('start'),
+ ok('save', '/tmp/a.png'),
+ sendResult(true), // Ctrl+U clear
+ sendResult(true) // the image paste (enter:false)
+ ])
+ const order: string[] = []
+ const sleep = vi.fn(async () => {
+ order.push('settle')
+ })
+ const baseSend = vi.fn(async (t: string) => {
+ order.push(`text:${t}`)
+ return true
+ })
+ // Record each terminal write so the paste-before-settle order is asserted,
+ // not just implied by the call counts.
+ const trackedClient: Pick = {
+ sendRequest: (method, params) => {
+ if (method === 'terminal.send') {
+ order.push((params as { text?: string }).text === '\x15' ? 'clear' : 'paste')
+ }
+ return client.sendRequest(method, params)
+ }
+ }
+ mount(
+ baseArgs({
+ client: trackedClient as RpcClient,
+ deviceTokenRef: { current: 'device-1' },
+ baseSend,
+ sleep
+ })
+ )
+
+ await act(async () => {
+ await hook!.attachImage('library')
+ })
+
+ let accepted = false
+ await act(async () => {
+ accepted = await hook!.sendNativeChat('look at this')
+ })
+
+ expect(accepted).toBe(true)
+ const sendCalls = client.calls.filter((c) => c.method === 'terminal.send')
+ // Ctrl+U clear, then the bracketed image paste.
+ expect(sendCalls).toHaveLength(2)
+ expect(sendCalls[0]?.params).toMatchObject({ text: '\x15', enter: false })
+ expect(sendCalls[1]?.params).toMatchObject({
+ text: '\x1b[200~/tmp/a.png\x1b[201~',
+ enter: false
+ })
+ // Clear, then paste, then settle, then the text send — in that order.
+ expect(order).toEqual(['clear', 'paste', 'settle', 'text:look at this'])
+ // The local preview URI rides along so the sent bubble shows the photo.
+ expect(baseSend).toHaveBeenCalledWith('look at this', ['file:///a.jpg'])
+ // Chips clear once the send is accepted.
+ expect(hook!.attachments).toEqual([])
+ })
+
+ it('routes an attachments-only send through baseSend with empty text so the echo still shows the photo', async () => {
+ pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
+ const client = makeClient([
+ methodNotFound('start'),
+ ok('save', '/tmp/a.png'),
+ sendResult(true), // Ctrl+U clear
+ sendResult(true) // image paste
+ ])
+ const baseSend = vi.fn().mockResolvedValue(true)
+ mount(baseArgs({ client: client as unknown as RpcClient, baseSend }))
+
+ await act(async () => {
+ await hook!.attachImage('library')
+ })
+ let accepted = false
+ await act(async () => {
+ accepted = await hook!.sendNativeChat('')
+ })
+
+ expect(accepted).toBe(true)
+ // Empty text still goes through baseSend (which submits the bare Enter) so the
+ // optimistic echo carries the preview URI.
+ expect(baseSend).toHaveBeenCalledWith('', ['file:///a.jpg'])
+ const sendCalls = client.calls.filter((c) => c.method === 'terminal.send')
+ // Only the clear + image paste hit the wire here; baseSend owns the submit.
+ expect(sendCalls).toHaveLength(2)
+ expect(hook!.attachments).toEqual([])
+ })
+
+ it('delegates straight to baseSend when there are no attachments', async () => {
+ const client = makeClient([])
+ const baseSend = vi.fn().mockResolvedValue(true)
+ mount(baseArgs({ client: client as unknown as RpcClient, baseSend }))
+
+ await act(async () => {
+ await hook!.sendNativeChat('just text')
+ })
+ expect(baseSend).toHaveBeenCalledWith('just text')
+ expect(client.calls).toHaveLength(0)
+ })
+
+ it('keeps the chips and does not submit when the image paste is rejected', async () => {
+ pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
+ const client = makeClient([
+ methodNotFound('start'),
+ ok('save', '/tmp/a.png'),
+ sendResult(true), // Ctrl+U clear
+ sendResult(false) // image paste rejected
+ ])
+ const baseSend = vi.fn().mockResolvedValue(true)
+ const showToast = vi.fn()
+ mount(baseArgs({ client: client as unknown as RpcClient, baseSend, showToast }))
+ await act(async () => {
+ await hook!.attachImage('library')
+ })
+ let accepted = true
+ await act(async () => {
+ accepted = await hook!.sendNativeChat('hi')
+ })
+ expect(accepted).toBe(false)
+ expect(baseSend).not.toHaveBeenCalled()
+ expect(showToast).toHaveBeenCalledWith('Message not sent', 1500)
+ expect(hook!.attachments).toHaveLength(1)
+ })
+
+ it('keeps the chips and reports failure when the paste transport throws', async () => {
+ pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
+ // No terminal.send responses queued: the clear write throws (dropped transport).
+ const client = makeClient([methodNotFound('start'), ok('save', '/tmp/a.png')])
+ const baseSend = vi.fn().mockResolvedValue(true)
+ const showToast = vi.fn()
+ mount(baseArgs({ client: client as unknown as RpcClient, baseSend, showToast }))
+ await act(async () => {
+ await hook!.attachImage('library')
+ })
+ let accepted = true
+ await act(async () => {
+ accepted = await hook!.sendNativeChat('hi')
+ })
+ expect(accepted).toBe(false)
+ expect(baseSend).not.toHaveBeenCalled()
+ expect(showToast).toHaveBeenCalledWith('Message not sent', 1500)
+ expect(hook!.attachments).toHaveLength(1)
+ })
+
+ it('surfaces a toast instead of a silent no-op when the input lease gate is closed', async () => {
+ pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
+ const client = makeClient([methodNotFound('start'), ok('save', '/tmp/a.png')])
+ const baseSend = vi.fn().mockResolvedValue(true)
+ const showToast = vi.fn()
+ // Attaching is allowed without the lease; only the send is gated on it.
+ mount(baseArgs({ client: client as unknown as RpcClient, enabled: false, baseSend, showToast }))
+ await act(async () => {
+ await hook!.attachImage('library')
+ })
+ let accepted = true
+ await act(async () => {
+ accepted = await hook!.sendNativeChat('hi')
+ })
+ expect(accepted).toBe(false)
+ expect(baseSend).not.toHaveBeenCalled()
+ expect(showToast).toHaveBeenCalledWith('Message not sent (disconnected)', 1500)
+ expect(hook!.attachments).toHaveLength(1)
+ })
+
+ it('scopes chips to the tab that attached them', async () => {
+ pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
+ const client = makeClient([methodNotFound('start'), ok('save', '/tmp/a.png')])
+ const baseSend = vi.fn().mockResolvedValue(true)
+ const args = baseArgs({ client: client as unknown as RpcClient, baseSend })
+ mount(args)
+ await act(async () => {
+ await hook!.attachImage('library')
+ })
+ expect(hook!.attachments).toHaveLength(1)
+
+ // Another tab sees no chip, and a send there is plain text — no image paste.
+ update({ ...args, scopeKey: 'h\0w\0tab-b' })
+ expect(hook!.attachments).toEqual([])
+ await act(async () => {
+ await hook!.sendNativeChat('hi')
+ })
+ expect(baseSend).toHaveBeenCalledWith('hi')
+ expect(client.calls.some((c) => c.method === 'terminal.send')).toBe(false)
+
+ // Back on the original tab the chip is still pending.
+ update(args)
+ expect(hook!.attachments).toHaveLength(1)
+ })
+
+ it('keeps isAttaching true when a cancelled pick overlaps a genuine in-flight upload', async () => {
+ // Park a real upload right after onUploadStart (count -> 1, isAttaching true)
+ // by holding its getConnectionId, then fire a cancelled pick. The cancelled
+ // call never incremented, so its finally must not drop the shared counter.
+ let releaseConnection: ((id: string | null) => void) | null = null
+ const client = makeClient([methodNotFound('start'), ok('save', '/tmp/a.png')])
+ const args = baseArgs({
+ client: client as unknown as RpcClient,
+ getActiveWorktreeConnectionId: () =>
+ new Promise((resolve) => {
+ releaseConnection = resolve
+ })
+ })
+ mount(args)
+
+ pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
+ let firstAttach: Promise | null = null
+ await act(async () => {
+ firstAttach = hook!.attachImage('library')
+ for (let i = 0; i < 50 && !releaseConnection; i++) {
+ await Promise.resolve()
+ }
+ })
+ expect(releaseConnection).not.toBeNull()
+ expect(hook!.isAttaching).toBe(true)
+
+ // A concurrent cancelled pick — its finally must leave the counter alone.
+ pick.mockResolvedValue(null)
+ await act(async () => {
+ await hook!.attachImage('library')
+ })
+ expect(hook!.isAttaching).toBe(true)
+
+ // The real upload finishes and clears the flag on its own.
+ await act(async () => {
+ releaseConnection!('conn-1')
+ await firstAttach
+ })
+ expect(hook!.isAttaching).toBe(false)
+ expect(hook!.attachments).toHaveLength(1)
+ })
+
+ it('clears only the chips that were sent, keeping one attached mid-send', async () => {
+ pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
+ const client = makeClient([
+ methodNotFound('start'),
+ ok('save', '/tmp/a.png'), // first attach
+ sendResult(true), // Ctrl+U clear
+ sendResult(true), // first image paste
+ methodNotFound('start'),
+ ok('save', '/tmp/b.png') // second attach, while the send is parked on settle
+ ])
+ const baseSend = vi.fn().mockResolvedValue(true)
+ let releaseSettle: (() => void) | null = null
+ const args = baseArgs({
+ client: client as unknown as RpcClient,
+ baseSend,
+ sleep: () =>
+ new Promise((resolve) => {
+ releaseSettle = resolve
+ })
+ })
+ mount(args)
+ await act(async () => {
+ await hook!.attachImage('library')
+ })
+
+ let sendPromise: Promise | null = null
+ await act(async () => {
+ sendPromise = hook!.sendNativeChat('hi')
+ // Drain microtasks until the send parks on the settle sleep.
+ for (let i = 0; i < 50 && !releaseSettle; i++) {
+ await Promise.resolve()
+ }
+ })
+ expect(releaseSettle).not.toBeNull()
+
+ pick.mockResolvedValue({ base64: 'BBBB', uri: 'file:///b.jpg' })
+ await act(async () => {
+ await hook!.attachImage('library')
+ })
+ await act(async () => {
+ releaseSettle!()
+ await sendPromise
+ })
+
+ // Only the first (sent) image rode along; the mid-send chip survives.
+ expect(baseSend).toHaveBeenCalledWith('hi', ['file:///a.jpg'])
+ expect(hook!.attachments.map((a) => a.previewUri)).toEqual(['file:///b.jpg'])
+ })
+
+ it('aborts the send when the active terminal changes during the settle window', async () => {
+ pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
+ const client = makeClient([
+ methodNotFound('start'),
+ ok('save', '/tmp/a.png'),
+ sendResult(true), // Ctrl+U clear
+ sendResult(true) // image paste — into term-1
+ ])
+ const baseSend = vi.fn().mockResolvedValue(true)
+ const showToast = vi.fn()
+ const activeHandleRef = { current: 'term-1' }
+ let releaseSettle: (() => void) | null = null
+ const args = baseArgs({
+ client: client as unknown as RpcClient,
+ activeHandleRef,
+ baseSend,
+ showToast,
+ sleep: () =>
+ new Promise((resolve) => {
+ releaseSettle = resolve
+ })
+ })
+ mount(args)
+ await act(async () => {
+ await hook!.attachImage('library')
+ })
+
+ let sendPromise: Promise | null = null
+ await act(async () => {
+ sendPromise = hook!.sendNativeChat('hi')
+ for (let i = 0; i < 50 && !releaseSettle; i++) {
+ await Promise.resolve()
+ }
+ })
+ expect(releaseSettle).not.toBeNull()
+ // The user switches tabs while the paste settles: the text + Enter must not
+ // land in term-2 when the images went to term-1.
+ activeHandleRef.current = 'term-2'
+ let accepted = true
+ await act(async () => {
+ releaseSettle!()
+ accepted = await sendPromise!
+ })
+ expect(accepted).toBe(false)
+ expect(baseSend).not.toHaveBeenCalled()
+ expect(showToast).toHaveBeenCalledWith('Message not sent', 1500)
+ expect(hook!.attachments).toHaveLength(1)
+ })
+
+ it('leads the next text-only send with Ctrl+U after a failed paste, even with the chip removed', async () => {
+ pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
+ const client = makeClient([
+ methodNotFound('start'),
+ ok('save', '/tmp/a.png'),
+ sendResult(true), // Ctrl+U clear
+ sendResult(false), // image paste rejected — stale input left in term-1
+ sendResult(true) // healing Ctrl+U before the text-only send
+ ])
+ const baseSend = vi.fn().mockResolvedValue(true)
+ mount(baseArgs({ client: client as unknown as RpcClient, baseSend }))
+ await act(async () => {
+ await hook!.attachImage('library')
+ })
+ await act(async () => {
+ await hook!.sendNativeChat('hi')
+ })
+ expect(baseSend).not.toHaveBeenCalled()
+
+ // The user gives up on the image and removes its chip, then sends plain text.
+ await act(async () => {
+ hook!.removeAttachment('img-1')
+ })
+ expect(hook!.attachments).toEqual([])
+ let accepted = false
+ await act(async () => {
+ accepted = await hook!.sendNativeChat('hi again')
+ })
+ expect(accepted).toBe(true)
+ const sendCalls = client.calls.filter((c) => c.method === 'terminal.send')
+ // Failed attempt's clear + rejected paste, then the healing clear.
+ expect(sendCalls).toHaveLength(3)
+ expect(sendCalls[2]?.params).toMatchObject({ text: '\x15', enter: false })
+ expect(baseSend).toHaveBeenCalledWith('hi again')
+ })
+
+ it('reports a disconnected attach failure via the live connection state', async () => {
+ const client = makeClient([])
+ const showToast = vi.fn()
+ let failUpload: ((error: Error) => void) | null = null
+ const args = baseArgs({
+ client: client as unknown as RpcClient,
+ showToast,
+ getActiveWorktreeConnectionId: () =>
+ new Promise((_resolve, reject) => {
+ failUpload = reject
+ })
+ })
+ mount(args)
+ pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
+ let attach: Promise | null = null
+ await act(async () => {
+ attach = hook!.attachImage('library')
+ for (let i = 0; i < 50 && !failUpload; i++) {
+ await Promise.resolve()
+ }
+ })
+ expect(failUpload).not.toBeNull()
+ // The connection drops mid-upload, then the in-flight RPC fails. The closure
+ // captured 'connected' at call time — only a live read can toast accurately.
+ update({ ...args, connState: 'connecting' })
+ await act(async () => {
+ failUpload!(new Error('socket closed'))
+ await attach
+ })
+ expect(showToast).toHaveBeenCalledWith('Attach failed (disconnected)', 1500)
+ })
+})
diff --git a/mobile/src/session/use-mobile-native-chat-image-attachments.ts b/mobile/src/session/use-mobile-native-chat-image-attachments.ts
new file mode 100644
index 00000000000..fded5e2f547
--- /dev/null
+++ b/mobile/src/session/use-mobile-native-chat-image-attachments.ts
@@ -0,0 +1,298 @@
+import { useCallback, useRef, useState } from 'react'
+import { CLIPBOARD_IMAGE_TOO_LARGE_ERROR } from '../../../src/shared/clipboard-image'
+import type { RpcClient } from '../transport/rpc-client'
+import type { ConnectionState } from '../transport/types'
+import {
+ ImageLibraryPermissionError,
+ pickMobileImage,
+ type MobileImageSource
+} from './mobile-image-source-picker'
+import {
+ uploadMobileNativeChatImage,
+ type PendingNativeChatImage
+} from './mobile-native-chat-image-attachment'
+import {
+ MOBILE_NATIVE_CHAT_IMAGE_SETTLE_MS,
+ pasteMobileNativeChatImagePaths
+} from './mobile-native-chat-image-send'
+
+type CurrentRef = { readonly current: T }
+type ShowToast = (message: string, durationMs?: number) => void
+
+type Args = {
+ readonly client: RpcClient | null
+ readonly activeHandleRef: CurrentRef
+ readonly deviceTokenRef: CurrentRef
+ readonly getActiveWorktreeConnectionId: () => Promise
+ readonly connState: ConnectionState
+ /** Identity of the active composer surface (same key shape as the drafts hook):
+ * chips are scoped to the tab that picked them, so a tab switch cannot ride
+ * one tab's image into another tab's terminal. Null disables attaching. */
+ readonly scopeKey: string | null
+ /** The native-chat input lease is ready — same gate `handleNativeChatSend` uses. */
+ readonly enabled: boolean
+ readonly showToast: ShowToast
+ /** The plain text send (controller.handleNativeChatSend); wrapped so images ride
+ * along. The optional URIs drive the optimistic echo's thumbnails. */
+ readonly baseSend: (text: string, imagePreviewUris?: string[]) => Promise
+ readonly onAttachSuccess?: () => void
+ readonly onError?: () => void
+ // Injected so the settle between image paste and submit is instant in tests.
+ readonly sleep?: (ms: number) => Promise
+}
+
+export type MobileNativeChatImageAttachments = {
+ /** Pending chips for the active scope (tab) only. */
+ readonly attachments: PendingNativeChatImage[]
+ readonly isAttaching: boolean
+ readonly attachImage: (source: MobileImageSource) => Promise
+ readonly removeAttachment: (id: string) => void
+ /** Ride any pending images along with `text`, then submit; clears the sent
+ * chips (and only those) once the send is accepted. */
+ readonly sendNativeChat: (text: string) => Promise
+}
+
+function getErrorMessage(error: unknown): string {
+ return error instanceof Error ? error.message : String(error)
+}
+
+const NO_ATTACHMENTS: PendingNativeChatImage[] = []
+
+function withScopeAttachments(
+ byScope: Record,
+ scope: string,
+ next: PendingNativeChatImage[]
+): Record {
+ if (next.length > 0) {
+ return { ...byScope, [scope]: next }
+ }
+ const remaining = { ...byScope }
+ delete remaining[scope]
+ return remaining
+}
+
+const defaultSleep = (ms: number): Promise =>
+ new Promise((resolve) => setTimeout(resolve, ms))
+
+export function useMobileNativeChatImageAttachments({
+ client,
+ activeHandleRef,
+ deviceTokenRef,
+ getActiveWorktreeConnectionId,
+ connState,
+ scopeKey,
+ enabled,
+ showToast,
+ baseSend,
+ onAttachSuccess,
+ onError,
+ sleep = defaultSleep
+}: Args): MobileNativeChatImageAttachments {
+ const [attachmentsByScope, setAttachmentsByScope] = useState<
+ Record
+ >({})
+ const [isAttaching, setIsAttaching] = useState(false)
+ const idCounter = useRef(0)
+ // Count in-flight uploads so an overlapping attach can't clear the flag early.
+ const attachingCount = useRef(0)
+ // Live connState for attachImage's catch: the closure's value was already
+ // checked 'connected' at entry, so only a ref can see a mid-upload disconnect.
+ const connStateRef = useRef(connState)
+ connStateRef.current = connState
+ // Terminal whose input line may hold a partial paste from a failed send; the
+ // next send TO THAT terminal must lead with Ctrl+U even if it has no images.
+ const staleInputTerminalRef = useRef(null)
+
+ const attachments = (scopeKey ? attachmentsByScope[scopeKey] : undefined) ?? NO_ATTACHMENTS
+
+ const attachImage = useCallback(
+ async (source: MobileImageSource): Promise => {
+ // The chip lands in the scope that initiated the pick, even if the user
+ // switches tabs while the upload is in flight.
+ const scope = scopeKey
+ if (!client || !scope || !activeHandleRef.current || connState !== 'connected') {
+ return
+ }
+ // Only this call's own increment may be undone in `finally`; a cancelled
+ // pick or pre-upload error never ran `onUploadStart`, so decrementing the
+ // shared counter would clear a concurrent upload's in-flight flag early.
+ let started = false
+ try {
+ const uploaded = await uploadMobileNativeChatImage(source, {
+ client,
+ getConnectionId: getActiveWorktreeConnectionId,
+ pickImage: pickMobileImage,
+ onUploadStart: () => {
+ started = true
+ attachingCount.current += 1
+ setIsAttaching(true)
+ }
+ })
+ // Cancelled picker: no error, no toast.
+ if (!uploaded) {
+ return
+ }
+ idCounter.current += 1
+ const chip = { id: `img-${idCounter.current}`, ...uploaded }
+ setAttachmentsByScope((prev) => ({ ...prev, [scope]: [...(prev[scope] ?? []), chip] }))
+ onAttachSuccess?.()
+ } catch (error) {
+ onError?.()
+ if (connStateRef.current !== 'connected') {
+ showToast('Attach failed (disconnected)', 1500)
+ return
+ }
+ if (error instanceof ImageLibraryPermissionError) {
+ showToast('Photo permission denied', 1500)
+ return
+ }
+ if (getErrorMessage(error) === CLIPBOARD_IMAGE_TOO_LARGE_ERROR) {
+ showToast('Image too large to attach', 1500)
+ return
+ }
+ showToast('Attach failed', 1500)
+ } finally {
+ if (started) {
+ attachingCount.current -= 1
+ if (attachingCount.current <= 0) {
+ attachingCount.current = 0
+ setIsAttaching(false)
+ }
+ }
+ }
+ },
+ [
+ activeHandleRef,
+ client,
+ connState,
+ getActiveWorktreeConnectionId,
+ onAttachSuccess,
+ onError,
+ scopeKey,
+ showToast
+ ]
+ )
+
+ const removeAttachment = useCallback(
+ (id: string): void => {
+ const scope = scopeKey
+ if (!scope) {
+ return
+ }
+ setAttachmentsByScope((prev) =>
+ withScopeAttachments(
+ prev,
+ scope,
+ (prev[scope] ?? []).filter((attachment) => attachment.id !== id)
+ )
+ )
+ },
+ [scopeKey]
+ )
+
+ const sendNativeChat = useCallback(
+ async (text: string): Promise => {
+ const scope = scopeKey
+ const pendingImages = (scope ? attachmentsByScope[scope] : undefined) ?? NO_ATTACHMENTS
+ if (pendingImages.length === 0 || !scope) {
+ // Heal a previously failed paste: a text-only send to that terminal would
+ // otherwise glue the stale image paste onto this message. Best-effort —
+ // on failure the marker stays set and the send proceeds as before.
+ const staleTerminal = staleInputTerminalRef.current
+ if (staleTerminal && staleTerminal === activeHandleRef.current && client) {
+ try {
+ await pasteMobileNativeChatImagePaths({
+ client,
+ terminal: staleTerminal,
+ deviceToken: deviceTokenRef.current,
+ imagePaths: []
+ })
+ staleInputTerminalRef.current = null
+ } catch {
+ // Leave marked for the next attempt.
+ }
+ }
+ return baseSend(text)
+ }
+ const handle = activeHandleRef.current
+ if (!client || !handle || !enabled || connState !== 'connected') {
+ onError?.()
+ // Mirror the text path's failure surface (the base send is never reached).
+ showToast('Message not sent (disconnected)', 1500)
+ return false
+ }
+ try {
+ const pasted = await pasteMobileNativeChatImagePaths({
+ client,
+ terminal: handle,
+ deviceToken: deviceTokenRef.current,
+ imagePaths: pendingImages.map((attachment) => attachment.path)
+ })
+ if (!pasted) {
+ // Keep the chips so the user can retry; the failed paste never submitted.
+ staleInputTerminalRef.current = handle
+ onError?.()
+ showToast('Message not sent', 1500)
+ return false
+ }
+ // The paste's leading Ctrl+U cleared any earlier stale input in `handle`.
+ if (staleInputTerminalRef.current === handle) {
+ staleInputTerminalRef.current = null
+ }
+ // Let the TUI absorb the image paste before the text + Enter follow. The
+ // preview URIs ride along to baseSend so the sent bubble shows the photo
+ // immediately (empty text still submits a bare Enter through baseSend).
+ await sleep(MOBILE_NATIVE_CHAT_IMAGE_SETTLE_MS)
+ // The paste above targeted `handle`; a tab switch during the settle would
+ // route the text + Enter to a different terminal than the images. Abort —
+ // the chips keep their scope and a retry's Ctrl+U clears the stale paste.
+ if (activeHandleRef.current !== handle) {
+ staleInputTerminalRef.current = handle
+ onError?.()
+ showToast('Message not sent', 1500)
+ return false
+ }
+ const accepted = await baseSend(
+ text,
+ pendingImages.map((attachment) => attachment.previewUri)
+ )
+ if (accepted) {
+ // Drop only what rode along — a chip attached while this send was in
+ // flight keeps waiting for its own send.
+ const sentIds = new Set(pendingImages.map((attachment) => attachment.id))
+ setAttachmentsByScope((prev) =>
+ withScopeAttachments(
+ prev,
+ scope,
+ (prev[scope] ?? []).filter((attachment) => !sentIds.has(attachment.id))
+ )
+ )
+ }
+ return accepted
+ } catch {
+ // A thrown paste/send (network/RPC) keeps the chips and honors the
+ // Promise contract instead of rejecting. Retry-safe: the next
+ // attempt's leading Ctrl+U clears whatever fraction of the paste landed.
+ staleInputTerminalRef.current = handle
+ onError?.()
+ showToast('Message not sent', 1500)
+ return false
+ }
+ },
+ [
+ activeHandleRef,
+ attachmentsByScope,
+ baseSend,
+ client,
+ connState,
+ deviceTokenRef,
+ enabled,
+ onError,
+ scopeKey,
+ showToast,
+ sleep
+ ]
+ )
+
+ return { attachments, isAttaching, attachImage, removeAttachment, sendNativeChat }
+}
diff --git a/mobile/src/session/use-mobile-session-image-attachments.ts b/mobile/src/session/use-mobile-session-image-attachments.ts
new file mode 100644
index 00000000000..47698b031e2
--- /dev/null
+++ b/mobile/src/session/use-mobile-session-image-attachments.ts
@@ -0,0 +1,81 @@
+import type { RpcClient } from '../transport/rpc-client'
+import type { ConnectionState } from '../transport/types'
+import type { MobileImageSource } from './mobile-image-source-picker'
+import { useMobileImageAttachment } from './use-mobile-image-attachment'
+import {
+ useMobileNativeChatImageAttachments,
+ type MobileNativeChatImageAttachments
+} from './use-mobile-native-chat-image-attachments'
+
+type CurrentRef = { readonly current: T }
+
+type Args = {
+ readonly client: RpcClient | null
+ readonly activeHandle: string | null
+ readonly activeHandleRef: CurrentRef
+ readonly canSend: boolean
+ readonly connState: ConnectionState
+ readonly deviceTokenRef: CurrentRef
+ /** Active-tab identity (same key shape as the drafts hook) — native-chat chips
+ * are scoped per tab so a switch can't ride an image into another terminal. */
+ readonly nativeChatScopeKey: string | null
+ readonly nativeChatInputLeaseReady: boolean
+ readonly getActiveWorktreeConnectionId: () => Promise
+ readonly beforeTerminalSend: (terminal: string) => Promise
+ readonly nativeChatBaseSend: (text: string, images?: string[]) => Promise
+ readonly showToast: (message: string, durationMs?: number) => void
+ readonly onSuccess: () => void
+ readonly onError: () => void
+}
+
+/** A session exposes image attachment on two surfaces that share one upload
+ * pipeline and host wiring: the visible terminal input (immediate bracketed
+ * paste) and the native-chat composer (chips deferred to submit). Owning both
+ * here keeps the already-dense session route to a single wiring point. */
+export function useMobileSessionImageAttachments({
+ client,
+ activeHandle,
+ activeHandleRef,
+ canSend,
+ connState,
+ deviceTokenRef,
+ nativeChatScopeKey,
+ nativeChatInputLeaseReady,
+ getActiveWorktreeConnectionId,
+ beforeTerminalSend,
+ nativeChatBaseSend,
+ showToast,
+ onSuccess,
+ onError
+}: Args): {
+ attachImage: (source: MobileImageSource) => Promise
+ isAttaching: boolean
+ nativeChatImages: MobileNativeChatImageAttachments
+} {
+ const { attachImage, isAttaching } = useMobileImageAttachment({
+ client,
+ activeHandle,
+ canSend,
+ connState,
+ deviceTokenRef,
+ beforeTerminalSend,
+ getActiveWorktreeConnectionId,
+ showToast,
+ onSuccess,
+ onError
+ })
+ const nativeChatImages = useMobileNativeChatImageAttachments({
+ client,
+ activeHandleRef,
+ deviceTokenRef,
+ getActiveWorktreeConnectionId,
+ connState,
+ scopeKey: nativeChatScopeKey,
+ enabled: nativeChatInputLeaseReady,
+ showToast,
+ baseSend: nativeChatBaseSend,
+ onAttachSuccess: onSuccess,
+ onError
+ })
+ return { attachImage, isAttaching, nativeChatImages }
+}
diff --git a/mobile/src/storage/preferences.test.ts b/mobile/src/storage/preferences.test.ts
index b636ea12d3e..3090be4a130 100644
--- a/mobile/src/storage/preferences.test.ts
+++ b/mobile/src/storage/preferences.test.ts
@@ -1,5 +1,5 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
-import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
HOST_DOCK_MAX_WIDTH,
HOST_DOCK_MIN_WIDTH,
@@ -10,22 +10,29 @@ import {
clampHostSidebarWidth,
loadDisabledTerminalLiveInputHandles,
loadHostSidebarWidth,
+ loadPinnedIds,
loadPushNotificationsEnabled,
loadTerminalAutocompleteEnabled,
loadTerminalLinkOpenMode,
readPushNotificationsPreference,
readDisabledTerminalLiveInputHandlesPreference,
+ MOBILE_STORED_ID_SET_MAX_ENTRIES,
+ MOBILE_STORED_ID_SET_MAX_STORAGE_CHARACTERS,
saveDisabledTerminalLiveInputHandles,
saveHostSidebarWidth,
+ savePinnedIds,
savePushNotificationsEnabled,
saveTerminalAutocompleteEnabled,
saveTerminalLinkOpenMode
} from './preferences'
import {
+ SESSION_VIEW_OVERRIDE_MAX_ACTIVE_BARRIERS,
+ SESSION_VIEW_OVERRIDE_MAX_ENTRIES,
loadDefaultSessionView,
loadSessionViewOverrides,
readDefaultSessionViewPreference,
readSessionViewOverridesPreference,
+ resetSessionViewPreferencesForTests,
saveDefaultSessionView,
updateSessionViewOverride
} from './session-view-preferences'
@@ -47,10 +54,15 @@ function deferred(): { promise: Promise; resolve: (value: T) => void } {
describe('session view preference', () => {
beforeEach(() => {
+ resetSessionViewPreferencesForTests()
vi.mocked(AsyncStorage.getItem).mockReset()
vi.mocked(AsyncStorage.setItem).mockReset()
})
+ afterEach(() => {
+ resetSessionViewPreferencesForTests()
+ })
+
it('defaults to terminal and persists the chat default', async () => {
vi.mocked(AsyncStorage.getItem).mockResolvedValue(null)
await expect(loadDefaultSessionView()).resolves.toBe('terminal')
@@ -270,6 +282,72 @@ describe('session view preference', () => {
JSON.stringify({ tab: 'chat' })
)
})
+
+ it('loads the exact override count and rejects one over without parsing it into state', async () => {
+ const exact = Object.fromEntries(
+ Array.from({ length: SESSION_VIEW_OVERRIDE_MAX_ENTRIES }, (_, index) => [
+ `tab-${index}`,
+ 'chat'
+ ])
+ )
+ vi.mocked(AsyncStorage.getItem).mockResolvedValue(JSON.stringify(exact))
+
+ await expect(readSessionViewOverridesPreference('host', 'worktree')).resolves.toEqual({
+ overrides: new Map(Object.entries(exact)) as Map,
+ loaded: true
+ })
+
+ vi.mocked(AsyncStorage.getItem).mockResolvedValue(
+ JSON.stringify({ ...exact, 'one-over': 'terminal' })
+ )
+ await expect(readSessionViewOverridesPreference('host', 'worktree')).resolves.toEqual({
+ overrides: new Map(),
+ loaded: false
+ })
+ })
+
+ it('evicts the oldest override when a new user choice exceeds the count cap', async () => {
+ const exact = Object.fromEntries(
+ Array.from({ length: SESSION_VIEW_OVERRIDE_MAX_ENTRIES }, (_, index) => [
+ `tab-${index}`,
+ 'chat'
+ ])
+ )
+ vi.mocked(AsyncStorage.getItem).mockResolvedValue(JSON.stringify(exact))
+
+ await updateSessionViewOverride('host', 'worktree', 'new-tab', 'terminal')
+
+ const stored = JSON.parse(vi.mocked(AsyncStorage.setItem).mock.calls[0]![1]) as Record<
+ string,
+ string
+ >
+ expect(Object.keys(stored)).toHaveLength(SESSION_VIEW_OVERRIDE_MAX_ENTRIES)
+ expect(stored['tab-0']).toBeUndefined()
+ expect(stored['new-tab']).toBe('terminal')
+ })
+
+ it('caps unresolved scoped write barriers and accepts another after one settles', async () => {
+ const blocked = deferred()
+ vi.mocked(AsyncStorage.getItem).mockResolvedValue(null)
+ vi.mocked(AsyncStorage.setItem).mockImplementation(() => blocked.promise)
+ const writes = Array.from({ length: SESSION_VIEW_OVERRIDE_MAX_ACTIVE_BARRIERS }, (_, index) =>
+ updateSessionViewOverride(`host-${index}`, 'worktree', 'tab', 'chat')
+ )
+ await vi.waitFor(() =>
+ expect(AsyncStorage.setItem).toHaveBeenCalledTimes(SESSION_VIEW_OVERRIDE_MAX_ACTIVE_BARRIERS)
+ )
+
+ await expect(updateSessionViewOverride('one-over', 'worktree', 'tab', 'chat')).rejects.toThrow(
+ 'Too many session view override writes are pending'
+ )
+
+ blocked.resolve()
+ await Promise.all(writes)
+ vi.mocked(AsyncStorage.setItem).mockResolvedValue(undefined)
+ await expect(
+ updateSessionViewOverride('recovered', 'worktree', 'tab', 'chat')
+ ).resolves.toBeUndefined()
+ })
})
describe('push notification preference', () => {
@@ -406,6 +484,57 @@ describe('terminal live input disabled handles preference', () => {
JSON.stringify(['pty-2', 'pty-1'])
)
})
+
+ it('accepts the exact handle cap and persists the newest handles at one over', async () => {
+ const exact = new Set(
+ Array.from({ length: MOBILE_STORED_ID_SET_MAX_ENTRIES }, (_, index) => `pty-${index}`)
+ )
+ await saveDisabledTerminalLiveInputHandles('host', 'worktree', exact)
+ expect(JSON.parse(vi.mocked(AsyncStorage.setItem).mock.calls[0]![1])).toHaveLength(
+ MOBILE_STORED_ID_SET_MAX_ENTRIES
+ )
+
+ exact.add('one-over')
+ await saveDisabledTerminalLiveInputHandles('host', 'worktree', exact)
+ const retained = JSON.parse(vi.mocked(AsyncStorage.setItem).mock.calls[1]![1]) as string[]
+ expect(retained).toHaveLength(MOBILE_STORED_ID_SET_MAX_ENTRIES)
+ expect(retained).not.toContain('pty-0')
+ expect(retained).toContain('one-over')
+ })
+
+ it('rejects an oversized handle payload before parsing', async () => {
+ vi.mocked(AsyncStorage.getItem).mockResolvedValue(
+ 'x'.repeat(MOBILE_STORED_ID_SET_MAX_STORAGE_CHARACTERS + 1)
+ )
+
+ await expect(
+ readDisabledTerminalLiveInputHandlesPreference('host', 'worktree')
+ ).resolves.toEqual({ handles: new Set(), loaded: false })
+ })
+})
+
+describe('pinned worktree ids preference', () => {
+ beforeEach(() => {
+ vi.mocked(AsyncStorage.getItem).mockReset()
+ vi.mocked(AsyncStorage.setItem).mockReset()
+ })
+
+ it('round-trips normal pins unchanged', async () => {
+ const pins = new Set(['worktree-2', 'worktree-1'])
+ await savePinnedIds('host', pins)
+ expect(AsyncStorage.setItem).toHaveBeenCalledWith('orca:pins:host', JSON.stringify([...pins]))
+
+ vi.mocked(AsyncStorage.getItem).mockResolvedValue(JSON.stringify([...pins]))
+ await expect(loadPinnedIds('host')).resolves.toEqual(pins)
+ })
+
+ it('bounds oversized durable pin payloads', async () => {
+ vi.mocked(AsyncStorage.getItem).mockResolvedValue(
+ 'x'.repeat(MOBILE_STORED_ID_SET_MAX_STORAGE_CHARACTERS + 1)
+ )
+
+ await expect(loadPinnedIds('host')).resolves.toEqual(new Set())
+ })
})
describe('host sidebar width preference', () => {
diff --git a/mobile/src/storage/preferences.ts b/mobile/src/storage/preferences.ts
index 5173ac5bc8a..d6128d466ab 100644
--- a/mobile/src/storage/preferences.ts
+++ b/mobile/src/storage/preferences.ts
@@ -2,6 +2,9 @@ import AsyncStorage from '@react-native-async-storage/async-storage'
const PINS_PREFIX = 'orca:pins:'
const NOTIF_KEY = 'orca:pushNotificationsEnabled'
+export const MOBILE_STORED_ID_SET_MAX_ENTRIES = 10_000
+export const MOBILE_STORED_ID_SET_MAX_STORAGE_CHARACTERS = 1024 * 1024
+export const MOBILE_STORED_ID_MAX_CHARACTERS = 4_096
export type PushNotificationsPreference = {
readonly value: boolean | null
@@ -101,7 +104,10 @@ export async function readDisabledTerminalLiveInputHandlesPreference(
if (!raw) {
return { handles: new Set(), loaded: true }
}
- return { handles: new Set(stringArray(JSON.parse(raw))), loaded: true }
+ if (raw.length > MOBILE_STORED_ID_SET_MAX_STORAGE_CHARACTERS) {
+ return { handles: new Set(), loaded: false }
+ }
+ return { handles: new Set(retainStoredPreferenceIds(JSON.parse(raw)).ids), loaded: true }
} catch {
return { handles: new Set(), loaded: false }
}
@@ -122,7 +128,7 @@ export async function saveDisabledTerminalLiveInputHandles(
): Promise {
await AsyncStorage.setItem(
terminalLiveInputDisabledKey(hostId, worktreeId),
- JSON.stringify([...handles])
+ retainStoredPreferenceIds(handles).serialized
)
}
@@ -210,24 +216,59 @@ export async function saveTerminalLinkOpenMode(mode: MobileTerminalLinkOpenMode)
await AsyncStorage.setItem(TERMINAL_LINK_OPEN_MODE_KEY, mode)
}
-function stringArray(value: unknown): string[] {
- return Array.isArray(value)
- ? value.filter((item): item is string => typeof item === 'string')
- : []
-}
-
export async function loadPinnedIds(hostId: string): Promise> {
try {
const raw = await AsyncStorage.getItem(PINS_PREFIX + hostId)
if (!raw) {
return new Set()
}
- return new Set(stringArray(JSON.parse(raw)))
+ if (raw.length > MOBILE_STORED_ID_SET_MAX_STORAGE_CHARACTERS) {
+ return new Set()
+ }
+ return new Set(retainStoredPreferenceIds(JSON.parse(raw)).ids)
} catch {
return new Set()
}
}
export async function savePinnedIds(hostId: string, ids: Set): Promise {
- await AsyncStorage.setItem(PINS_PREFIX + hostId, JSON.stringify([...ids]))
+ await AsyncStorage.setItem(PINS_PREFIX + hostId, retainStoredPreferenceIds(ids).serialized)
+}
+
+function retainStoredPreferenceIds(value: unknown): { ids: string[]; serialized: string } {
+ const values = Array.isArray(value)
+ ? value
+ : value && typeof value === 'object' && Symbol.iterator in value
+ ? (value as Iterable)
+ : []
+ const retained = new Map()
+ let entryCharacters = 0
+ for (const candidate of values) {
+ if (
+ typeof candidate !== 'string' ||
+ candidate.length > MOBILE_STORED_ID_MAX_CHARACTERS ||
+ retained.has(candidate)
+ ) {
+ continue
+ }
+ const serialized = JSON.stringify(candidate)
+ retained.set(candidate, serialized)
+ entryCharacters += serialized.length
+ while (
+ retained.size > MOBILE_STORED_ID_SET_MAX_ENTRIES ||
+ 2 + entryCharacters + Math.max(0, retained.size - 1) >
+ MOBILE_STORED_ID_SET_MAX_STORAGE_CHARACTERS
+ ) {
+ const oldestId = retained.keys().next().value
+ if (typeof oldestId !== 'string') {
+ break
+ }
+ entryCharacters -= retained.get(oldestId)?.length ?? 0
+ retained.delete(oldestId)
+ }
+ }
+ return {
+ ids: [...retained.keys()],
+ serialized: `[${[...retained.values()].join(',')}]`
+ }
}
diff --git a/mobile/src/storage/session-view-preferences.ts b/mobile/src/storage/session-view-preferences.ts
index cd9597d6f77..bef1b3b3c92 100644
--- a/mobile/src/storage/session-view-preferences.ts
+++ b/mobile/src/storage/session-view-preferences.ts
@@ -5,6 +5,11 @@ export type MobileSessionView = 'terminal' | 'chat'
const DEFAULT_SESSION_VIEW_KEY = 'orca:defaultSessionView'
const NATIVE_CHAT_TABS_PREFIX = 'orca:nativeChatTabs:'
+export const SESSION_VIEW_OVERRIDE_MAX_ENTRIES = 4_096
+export const SESSION_VIEW_OVERRIDE_MAX_STORAGE_CHARACTERS = 512 * 1024
+export const SESSION_VIEW_OVERRIDE_MAX_TAB_ID_CHARACTERS = 1_024
+export const SESSION_VIEW_OVERRIDE_MAX_ACTIVE_BARRIERS = 64
+const SESSION_VIEW_OVERRIDE_MAX_SCOPE_KEY_CHARACTERS = 4_096
// Why: default stays terminal so native chat remains strictly opt-in.
export const DEFAULT_SESSION_VIEW: MobileSessionView = 'terminal'
@@ -12,8 +17,11 @@ export const DEFAULT_SESSION_VIEW: MobileSessionView = 'terminal'
let defaultViewWriteBarrier: Promise | null = null
const overrideUpdateBarriers = new Map>()
-function sessionViewOverridesKey(hostId: string, worktreeId: string): string {
- return `${NATIVE_CHAT_TABS_PREFIX}${encodeURIComponent(hostId)}:${encodeURIComponent(worktreeId)}`
+function sessionViewOverridesKey(hostId: string, worktreeId: string): string | null {
+ const key = `${NATIVE_CHAT_TABS_PREFIX}${encodeURIComponent(hostId)}:${encodeURIComponent(
+ worktreeId
+ )}`
+ return key.length <= SESSION_VIEW_OVERRIDE_MAX_SCOPE_KEY_CHARACTERS ? key : null
}
function clearDefaultViewWriteBarrier(barrier: Promise): void {
@@ -77,6 +85,9 @@ async function readSessionViewOverridesStorage(
if (!raw) {
return { overrides: new Map(), loaded: true }
}
+ if (raw.length > SESSION_VIEW_OVERRIDE_MAX_STORAGE_CHARACTERS) {
+ return { overrides: new Map(), loaded: false }
+ }
let parsed: unknown
try {
parsed = JSON.parse(raw) as unknown
@@ -86,21 +97,38 @@ async function readSessionViewOverridesStorage(
}
// Legacy format: an array of tab ids that were showing native chat.
if (Array.isArray(parsed)) {
- return {
- overrides: new Map(
- parsed
- .filter((id): id is string => typeof id === 'string')
- .map((id) => [id, 'chat' as const])
- ),
- loaded: true
+ const overrides = new Map()
+ for (const id of parsed) {
+ if (typeof id !== 'string' || id.length > SESSION_VIEW_OVERRIDE_MAX_TAB_ID_CHARACTERS) {
+ continue
+ }
+ if (!overrides.has(id) && overrides.size >= SESSION_VIEW_OVERRIDE_MAX_ENTRIES) {
+ return { overrides: new Map(), loaded: false }
+ }
+ overrides.set(id, 'chat')
}
+ return { overrides, loaded: true }
}
if (parsed && typeof parsed === 'object') {
- const entries = Object.entries(parsed as Record).filter(
- (entry): entry is [string, MobileSessionView] =>
- entry[1] === 'terminal' || entry[1] === 'chat'
- )
- return { overrides: new Map(entries), loaded: true }
+ const overrides = new Map()
+ const record = parsed as Record
+ for (const id in record) {
+ if (!Object.prototype.hasOwnProperty.call(record, id)) {
+ continue
+ }
+ const view = record[id]
+ if (
+ (view !== 'terminal' && view !== 'chat') ||
+ id.length > SESSION_VIEW_OVERRIDE_MAX_TAB_ID_CHARACTERS
+ ) {
+ continue
+ }
+ if (overrides.size >= SESSION_VIEW_OVERRIDE_MAX_ENTRIES) {
+ return { overrides: new Map(), loaded: false }
+ }
+ overrides.set(id, view)
+ }
+ return { overrides, loaded: true }
}
return { overrides: new Map(), loaded: true }
}
@@ -121,6 +149,9 @@ export async function readSessionViewOverridesPreference(
worktreeId: string
): Promise {
const key = sessionViewOverridesKey(hostId, worktreeId)
+ if (!key) {
+ return { overrides: new Map(), loaded: false }
+ }
await overrideUpdateBarriers.get(key)
return readSessionViewOverridesStorage(key)
}
@@ -133,7 +164,17 @@ export async function updateSessionViewOverride(
view: MobileSessionView
): Promise {
const key = sessionViewOverridesKey(hostId, worktreeId)
- const previous = overrideUpdateBarriers.get(key) ?? Promise.resolve()
+ if (!key || tabId.length === 0 || tabId.length > SESSION_VIEW_OVERRIDE_MAX_TAB_ID_CHARACTERS) {
+ throw new Error('Session view override identifier is too large')
+ }
+ const existingBarrier = overrideUpdateBarriers.get(key)
+ if (
+ !existingBarrier &&
+ overrideUpdateBarriers.size >= SESSION_VIEW_OVERRIDE_MAX_ACTIVE_BARRIERS
+ ) {
+ throw new Error('Too many session view override writes are pending')
+ }
+ const previous = existingBarrier ?? Promise.resolve()
const update = previous.then(async () => {
const current = await readSessionViewOverridesStorage(key)
// Why: a transient read failure must not replace valid saved siblings with
@@ -141,8 +182,17 @@ export async function updateSessionViewOverride(
if (!current.loaded) {
throw new Error('Session view overrides could not be read')
}
+ if (
+ !current.overrides.has(tabId) &&
+ current.overrides.size >= SESSION_VIEW_OVERRIDE_MAX_ENTRIES
+ ) {
+ const oldestId = current.overrides.keys().next().value
+ if (typeof oldestId === 'string') {
+ current.overrides.delete(oldestId)
+ }
+ }
current.overrides.set(tabId, view)
- await AsyncStorage.setItem(key, JSON.stringify(Object.fromEntries(current.overrides)))
+ await AsyncStorage.setItem(key, serializeSessionViewOverrides(current.overrides, tabId))
})
const barrier = update.catch(() => undefined)
overrideUpdateBarriers.set(key, barrier)
@@ -154,3 +204,48 @@ export async function updateSessionViewOverride(
}
}
}
+
+function serializeSessionViewOverrides(
+ overrides: Map,
+ protectedId: string
+): string {
+ while (true) {
+ const serialized = trySerializeSessionViewOverrides(overrides)
+ if (serialized !== null) {
+ return serialized
+ }
+ let evictionId: string | null = null
+ for (const id of overrides.keys()) {
+ if (id !== protectedId) {
+ evictionId = id
+ break
+ }
+ }
+ if (evictionId === null) {
+ throw new Error('Session view override storage limit exceeded')
+ }
+ overrides.delete(evictionId)
+ }
+}
+
+function trySerializeSessionViewOverrides(
+ overrides: ReadonlyMap
+): string | null {
+ const entries: string[] = []
+ let characters = 2
+ for (const [id, view] of overrides) {
+ const entry = `${JSON.stringify(id)}:${JSON.stringify(view)}`
+ characters += entry.length + (entries.length > 0 ? 1 : 0)
+ if (characters > SESSION_VIEW_OVERRIDE_MAX_STORAGE_CHARACTERS) {
+ return null
+ }
+ entries.push(entry)
+ }
+ return `{${entries.join(',')}}`
+}
+
+/** Test-only: drop pending module write barriers between cases. */
+export function resetSessionViewPreferencesForTests(): void {
+ defaultViewWriteBarrier = null
+ overrideUpdateBarriers.clear()
+}
diff --git a/mobile/src/tasks/github-pr-file-diff.test.ts b/mobile/src/tasks/github-pr-file-diff.test.ts
index 2a59c4934a1..5174dba5b64 100644
--- a/mobile/src/tasks/github-pr-file-diff.test.ts
+++ b/mobile/src/tasks/github-pr-file-diff.test.ts
@@ -44,6 +44,24 @@ describe('buildGitHubPrFileDiffLines', () => {
])
})
+ it('preserves a terminal lone carriage return like the previous line splitter', () => {
+ expect(buildGitHubPrFileDiffLines('', 'line\r')).toEqual([
+ { key: '0:added:1', kind: 'added', newLineNumber: 1, text: 'line\r' }
+ ])
+ })
+
+ it('treats one empty LF line and CRLF line as equal', () => {
+ expect(buildGitHubPrFileDiffLines('\n', '\r\n')).toEqual([
+ {
+ key: '0:context:1:1',
+ kind: 'context',
+ oldLineNumber: 1,
+ newLineNumber: 1,
+ text: ''
+ }
+ ])
+ })
+
it('keeps all lines for large files without exact diff truncation', () => {
const original = Array.from({ length: 500 }, (_, index) => `old-${index}`).join('\n')
const modified = Array.from({ length: 500 }, (_, index) => `new-${index}`).join('\n')
@@ -71,6 +89,39 @@ describe('buildGitHubPrFileDiffLines', () => {
})
})
+ it('counts newline-dense files without materializing discarded line arrays', () => {
+ const original = '\n'.repeat(200_000)
+
+ const preview = buildGitHubPrFileDiffPreview(original, 'changed', 3)
+
+ expect(preview.totalLineCount).toBe(200_001)
+ expect(preview.lines).toEqual([
+ { key: '0:removed:1', kind: 'removed', oldLineNumber: 1, text: '' },
+ { key: '1:removed:2', kind: 'removed', oldLineNumber: 2, text: '' },
+ { key: '2:removed:3', kind: 'removed', oldLineNumber: 3, text: '' }
+ ])
+ })
+
+ it('preserves common prefix and suffix rows around a streamed middle diff', () => {
+ const originalMiddle = Array.from({ length: 500 }, (_, index) => `old-${index}`)
+ const modifiedMiddle = Array.from({ length: 500 }, (_, index) => `new-${index}`)
+ const original = ['first', ...originalMiddle, 'last'].join('\r\n')
+ const modified = ['first', ...modifiedMiddle, 'last'].join('\r\n')
+
+ const preview = buildGitHubPrFileDiffPreview(original, modified, 1_002)
+
+ expect(preview.totalLineCount).toBe(1_002)
+ expect(preview.lines[0]).toMatchObject({ kind: 'context', text: 'first' })
+ expect(preview.lines[1]).toMatchObject({ kind: 'removed', text: 'old-0' })
+ expect(preview.lines[501]).toMatchObject({ kind: 'added', text: 'new-0' })
+ expect(preview.lines.at(-1)).toMatchObject({
+ kind: 'context',
+ oldLineNumber: 502,
+ newLineNumber: 502,
+ text: 'last'
+ })
+ })
+
it('can compute the total without retaining preview rows', () => {
const modified = Array.from({ length: 20 }, (_, index) => `new-${index}`).join('\n')
diff --git a/mobile/src/tasks/github-pr-file-diff.ts b/mobile/src/tasks/github-pr-file-diff.ts
index 1be684197d4..8165d255437 100644
--- a/mobile/src/tasks/github-pr-file-diff.ts
+++ b/mobile/src/tasks/github-pr-file-diff.ts
@@ -1,3 +1,11 @@
+import {
+ collectGitHubPrFileLineRange,
+ createGitHubPrFileLineSource,
+ findGitHubPrFileCommonLineEdges,
+ visitGitHubPrFileLineRange,
+ type GitHubPrFileLineSource
+} from './github-pr-file-line-scan'
+
export type GitHubPrFileDiffLine = {
key: string
kind: 'context' | 'added' | 'removed'
@@ -18,14 +26,6 @@ type DiffOperation =
const EXACT_DIFF_CELL_LIMIT = 160_000
-function splitContentLines(value: string): string[] {
- if (!value) {
- return []
- }
- const lines = value.split(/\r?\n/)
- return lines.at(-1) === '' ? lines.slice(0, -1) : lines
-}
-
function appendExactLineDiff(
original: string[],
modified: string[],
@@ -79,37 +79,6 @@ function appendExactLineDiff(
}
}
-function appendMiddleDiff(
- original: string[],
- modified: string[],
- appendOperation: (operation: DiffOperation) => void
-): void {
- if (original.length === 0) {
- for (const newLine of modified) {
- appendOperation({ kind: 'added', newLine })
- }
- return
- }
- if (modified.length === 0) {
- for (const oldLine of original) {
- appendOperation({ kind: 'removed', oldLine })
- }
- return
- }
- if (original.length * modified.length <= EXACT_DIFF_CELL_LIMIT) {
- appendExactLineDiff(original, modified, appendOperation)
- return
- }
- // Why: the Tasks diff UI renders a capped preview. Stream fallback rows so a
- // generated PR file does not allocate thousands of discarded row objects.
- for (const oldLine of original) {
- appendOperation({ kind: 'removed', oldLine })
- }
- for (const newLine of modified) {
- appendOperation({ kind: 'added', newLine })
- }
-}
-
export function buildGitHubPrFileDiffLines(
originalContent: string,
modifiedContent: string
@@ -122,42 +91,17 @@ export function buildGitHubPrFileDiffPreview(
modifiedContent: string,
maxLines = Number.POSITIVE_INFINITY
): GitHubPrFileDiffPreview {
- const originalLines = splitContentLines(originalContent)
- const modifiedLines = splitContentLines(modifiedContent)
- let prefixLength = 0
- while (
- prefixLength < originalLines.length &&
- prefixLength < modifiedLines.length &&
- originalLines[prefixLength] === modifiedLines[prefixLength]
- ) {
- prefixLength += 1
- }
-
- let suffixLength = 0
- while (
- suffixLength < originalLines.length - prefixLength &&
- suffixLength < modifiedLines.length - prefixLength &&
- originalLines[originalLines.length - suffixLength - 1] ===
- modifiedLines[modifiedLines.length - suffixLength - 1]
- ) {
- suffixLength += 1
- }
-
- const originalMiddle = originalLines.slice(
- prefixLength,
- suffixLength === 0 ? originalLines.length : originalLines.length - suffixLength
- )
- const modifiedMiddle = modifiedLines.slice(
- prefixLength,
- suffixLength === 0 ? modifiedLines.length : modifiedLines.length - suffixLength
- )
-
+ const original = createGitHubPrFileLineSource(originalContent)
+ const modified = createGitHubPrFileLineSource(modifiedContent)
+ const { prefixLineCount, suffixLineCount } = findGitHubPrFileCommonLineEdges(original, modified)
+ const originalMiddleLineCount = original.lineCount - prefixLineCount - suffixLineCount
+ const modifiedMiddleLineCount = modified.lineCount - prefixLineCount - suffixLineCount
const result: GitHubPrFileDiffLine[] = []
let oldLineNumber = 1
let newLineNumber = 1
let operationIndex = 0
let totalLineCount = 0
- const normalizedMaxLines = Math.max(0, Math.floor(maxLines))
+ const normalizedMaxLines = Number.isNaN(maxLines) ? 0 : Math.max(0, Math.floor(maxLines))
function appendOperation(operation: DiffOperation): void {
const index = operationIndex
operationIndex += 1
@@ -199,15 +143,55 @@ export function buildGitHubPrFileDiffPreview(
newLineNumber += 1
}
- for (let i = 0; i < prefixLength; i += 1) {
- const line = originalLines[i] ?? ''
- appendOperation({ kind: 'context', oldLine: line, newLine: line })
+ function skipOperations(kind: DiffOperation['kind'], count: number): void {
+ operationIndex += count
+ totalLineCount += count
+ if (kind === 'context') {
+ oldLineNumber += count
+ newLineNumber += count
+ } else if (kind === 'removed') {
+ oldLineNumber += count
+ } else {
+ newLineNumber += count
+ }
}
- appendMiddleDiff(originalMiddle, modifiedMiddle, appendOperation)
- for (let i = originalLines.length - suffixLength; i < originalLines.length; i += 1) {
- const line = originalLines[i] ?? ''
- appendOperation({ kind: 'context', oldLine: line, newLine: line })
+
+ function appendLineRange(
+ kind: DiffOperation['kind'],
+ source: GitHubPrFileLineSource,
+ startLine: number,
+ lineCount: number
+ ): void {
+ const retainedLineCount = Math.min(lineCount, Math.max(0, normalizedMaxLines - result.length))
+ visitGitHubPrFileLineRange(source, startLine, retainedLineCount, (line) => {
+ if (kind === 'context') {
+ appendOperation({ kind, oldLine: line, newLine: line })
+ } else if (kind === 'removed') {
+ appendOperation({ kind, oldLine: line })
+ } else {
+ appendOperation({ kind, newLine: line })
+ }
+ })
+ skipOperations(kind, lineCount - retainedLineCount)
}
+ appendLineRange('context', original, 0, prefixLineCount)
+ if (originalMiddleLineCount === 0) {
+ appendLineRange('added', modified, prefixLineCount, modifiedMiddleLineCount)
+ } else if (modifiedMiddleLineCount === 0) {
+ appendLineRange('removed', original, prefixLineCount, originalMiddleLineCount)
+ } else if (originalMiddleLineCount * modifiedMiddleLineCount <= EXACT_DIFF_CELL_LIMIT) {
+ appendExactLineDiff(
+ collectGitHubPrFileLineRange(original, prefixLineCount, originalMiddleLineCount),
+ collectGitHubPrFileLineRange(modified, prefixLineCount, modifiedMiddleLineCount),
+ appendOperation
+ )
+ } else {
+ // Why: large generated files need exact counts without retaining discarded preview rows.
+ appendLineRange('removed', original, prefixLineCount, originalMiddleLineCount)
+ appendLineRange('added', modified, prefixLineCount, modifiedMiddleLineCount)
+ }
+ appendLineRange('context', original, original.lineCount - suffixLineCount, suffixLineCount)
+
return { lines: result, totalLineCount }
}
diff --git a/mobile/src/tasks/github-pr-file-line-scan.ts b/mobile/src/tasks/github-pr-file-line-scan.ts
new file mode 100644
index 00000000000..6031ce4d2f0
--- /dev/null
+++ b/mobile/src/tasks/github-pr-file-line-scan.ts
@@ -0,0 +1,171 @@
+export type GitHubPrFileLineSource = {
+ content: string
+ lineCount: number
+}
+
+export type GitHubPrFileCommonLineEdges = {
+ prefixLineCount: number
+ suffixLineCount: number
+}
+
+type LineBounds = {
+ start: number
+ end: number
+}
+
+type ForwardLineCursor = {
+ offset: number
+ remaining: number
+ source: GitHubPrFileLineSource
+}
+
+type ReverseLineCursor = {
+ boundary: number
+ remaining: number
+ source: GitHubPrFileLineSource
+}
+
+export function createGitHubPrFileLineSource(content: string): GitHubPrFileLineSource {
+ if (content.length === 0) {
+ return { content, lineCount: 0 }
+ }
+ let lineCount = content.endsWith('\n') ? 0 : 1
+ for (let index = 0; index < content.length; index += 1) {
+ if (content.charCodeAt(index) === 10) {
+ lineCount += 1
+ }
+ }
+ return { content, lineCount }
+}
+
+export function findGitHubPrFileCommonLineEdges(
+ original: GitHubPrFileLineSource,
+ modified: GitHubPrFileLineSource
+): GitHubPrFileCommonLineEdges {
+ const comparableLineCount = Math.min(original.lineCount, modified.lineCount)
+ const originalForward = createForwardCursor(original)
+ const modifiedForward = createForwardCursor(modified)
+ let prefixLineCount = 0
+ while (prefixLineCount < comparableLineCount) {
+ const originalLine = takeNextLine(originalForward)!
+ const modifiedLine = takeNextLine(modifiedForward)!
+ if (!lineBoundsEqual(original, originalLine, modified, modifiedLine)) {
+ break
+ }
+ prefixLineCount += 1
+ }
+
+ const originalReverse = createReverseCursor(original)
+ const modifiedReverse = createReverseCursor(modified)
+ const maxSuffixLineCount = comparableLineCount - prefixLineCount
+ let suffixLineCount = 0
+ while (suffixLineCount < maxSuffixLineCount) {
+ const originalLine = takePreviousLine(originalReverse)!
+ const modifiedLine = takePreviousLine(modifiedReverse)!
+ if (!lineBoundsEqual(original, originalLine, modified, modifiedLine)) {
+ break
+ }
+ suffixLineCount += 1
+ }
+ return { prefixLineCount, suffixLineCount }
+}
+
+export function visitGitHubPrFileLineRange(
+ source: GitHubPrFileLineSource,
+ startLine: number,
+ lineCount: number,
+ visit: (line: string) => void
+): void {
+ if (lineCount <= 0) {
+ return
+ }
+ const cursor = createForwardCursor(source)
+ for (let index = 0; index < startLine; index += 1) {
+ takeNextLine(cursor)
+ }
+ for (let index = 0; index < lineCount; index += 1) {
+ const bounds = takeNextLine(cursor)
+ if (!bounds) {
+ return
+ }
+ visit(source.content.slice(bounds.start, bounds.end))
+ }
+}
+
+export function collectGitHubPrFileLineRange(
+ source: GitHubPrFileLineSource,
+ startLine: number,
+ lineCount: number
+): string[] {
+ const lines: string[] = []
+ visitGitHubPrFileLineRange(source, startLine, lineCount, (line) => lines.push(line))
+ return lines
+}
+
+function createForwardCursor(source: GitHubPrFileLineSource): ForwardLineCursor {
+ return { source, offset: 0, remaining: source.lineCount }
+}
+
+function takeNextLine(cursor: ForwardLineCursor): LineBounds | null {
+ if (cursor.remaining === 0) {
+ return null
+ }
+ const { content } = cursor.source
+ const separator = content.indexOf('\n', cursor.offset)
+ const rawEnd = separator === -1 ? content.length : separator
+ const end =
+ separator !== -1 && rawEnd > cursor.offset && content.charCodeAt(rawEnd - 1) === 13
+ ? rawEnd - 1
+ : rawEnd
+ const bounds = { start: cursor.offset, end }
+ cursor.offset = separator === -1 ? content.length : separator + 1
+ cursor.remaining -= 1
+ return bounds
+}
+
+function createReverseCursor(source: GitHubPrFileLineSource): ReverseLineCursor {
+ return {
+ source,
+ boundary: source.content.endsWith('\n') ? source.content.length - 1 : source.content.length,
+ remaining: source.lineCount
+ }
+}
+
+function takePreviousLine(cursor: ReverseLineCursor): LineBounds | null {
+ if (cursor.remaining === 0) {
+ return null
+ }
+ const { content } = cursor.source
+ const separator = cursor.boundary === 0 ? -1 : content.lastIndexOf('\n', cursor.boundary - 1)
+ const start = separator + 1
+ const end =
+ cursor.boundary < content.length &&
+ cursor.boundary > start &&
+ content.charCodeAt(cursor.boundary - 1) === 13
+ ? cursor.boundary - 1
+ : cursor.boundary
+ cursor.boundary = separator === -1 ? 0 : separator
+ cursor.remaining -= 1
+ return { start, end }
+}
+
+function lineBoundsEqual(
+ leftSource: GitHubPrFileLineSource,
+ left: LineBounds,
+ rightSource: GitHubPrFileLineSource,
+ right: LineBounds
+): boolean {
+ const length = left.end - left.start
+ if (length !== right.end - right.start) {
+ return false
+ }
+ for (let offset = 0; offset < length; offset += 1) {
+ if (
+ leftSource.content.charCodeAt(left.start + offset) !==
+ rightSource.content.charCodeAt(right.start + offset)
+ ) {
+ return false
+ }
+ }
+ return true
+}
diff --git a/mobile/src/tasks/mobile-pr-file-content-cache.test.ts b/mobile/src/tasks/mobile-pr-file-content-cache.test.ts
new file mode 100644
index 00000000000..bb32226a98a
--- /dev/null
+++ b/mobile/src/tasks/mobile-pr-file-content-cache.test.ts
@@ -0,0 +1,153 @@
+import { describe, expect, it } from 'vitest'
+import type { GitHubPRFileContents } from '../../../src/shared/types'
+import {
+ MOBILE_PR_FILE_CONTENT_CACHE_MAX_BYTES,
+ MOBILE_PR_FILE_CONTENT_CACHE_MAX_ENTRIES,
+ MobilePrFileContentCache,
+ createMobilePrFileContentKey,
+ createMobilePrFileContentScope,
+ getMobilePrFileContentByteCount,
+ getMobilePrFileContentsForScope
+} from './mobile-pr-file-content-cache'
+import {
+ createMobileItemPrFileContentScope,
+ createMobileProjectPrFileContentScope
+} from './use-mobile-pr-file-content-cache'
+
+function contents(original: string, modified = ''): GitHubPRFileContents {
+ return {
+ original,
+ modified,
+ originalIsBinary: false,
+ modifiedIsBinary: false
+ }
+}
+
+const scopeA = createMobilePrFileContentScope({
+ source: 'item',
+ repoId: 'repo-1',
+ prNumber: 10,
+ headSha: 'head-a',
+ baseSha: 'base-a'
+})
+const scopeB = createMobilePrFileContentScope({
+ source: 'item',
+ repoId: 'repo-1',
+ prNumber: 11,
+ headSha: 'head-b',
+ baseSha: 'base-a'
+})
+
+describe('MobilePrFileContentCache', () => {
+ it('derives route scopes only from complete GitHub PR revisions', () => {
+ expect(
+ createMobileItemPrFileContentScope(
+ { provider: 'github', source: { type: 'pr', repoId: 'repo-1', number: 10 } },
+ { provider: 'github', headSha: 'head-a', baseSha: 'base-a' }
+ )
+ ).toBe(scopeA)
+ expect(
+ createMobileProjectPrFileContentScope(
+ { itemType: 'PULL_REQUEST', content: { number: 10 } },
+ { id: 'repo-1' },
+ { provider: 'github', headSha: 'head-a' }
+ )
+ ).toBeNull()
+ expect(
+ createMobileProjectPrFileContentScope(
+ { itemType: 'PULL_REQUEST', content: { number: 10 } },
+ { id: 'repo-1' },
+ { provider: 'github', headSha: 'head-a', baseSha: 'base-a' },
+ { host: 'github.example', owner: 'orca', repo: 'app' }
+ )
+ ).toBe(
+ createMobilePrFileContentScope({
+ source: 'project',
+ repoId: 'repo-1',
+ prNumber: 10,
+ headSha: 'head-a',
+ baseSha: 'base-a',
+ repository: { host: 'github.example', owner: 'orca', repo: 'app' }
+ })
+ )
+ })
+
+ it('uses the desktop diff budget as a hard byte cap with a smaller mobile entry cap', () => {
+ expect(MOBILE_PR_FILE_CONTENT_CACHE_MAX_ENTRIES).toBeLessThan(64)
+ expect(MOBILE_PR_FILE_CONTENT_CACHE_MAX_BYTES).toBe(24_000_000)
+ })
+
+ it('evicts least-recently-used files at the entry limit', () => {
+ const cache = new MobilePrFileContentCache(2, 100)
+ const a = createMobilePrFileContentKey({ path: 'a.ts' })
+ const b = createMobilePrFileContentKey({ path: 'b.ts' })
+ const c = createMobilePrFileContentKey({ path: 'c.ts' })
+ cache.commitRequest(cache.beginRequest(scopeA, a), contents('a'))
+ cache.commitRequest(cache.beginRequest(scopeA, b), contents('b'))
+ expect(cache.select(scopeA, a).contents).toEqual(contents('a'))
+
+ cache.commitRequest(cache.beginRequest(scopeA, c), contents('c'))
+
+ expect(cache.evidence()).toMatchObject({ entryCount: 2, keysOldestFirst: [a, c] })
+ expect(cache.select(scopeA, b).contents).toBeUndefined()
+ })
+
+ it('evicts older payloads to stay within the byte budget', () => {
+ const cache = new MobilePrFileContentCache(10, 8)
+ const a = createMobilePrFileContentKey({ path: 'a.ts' })
+ const b = createMobilePrFileContentKey({ path: 'b.ts' })
+ cache.commitRequest(cache.beginRequest(scopeA, a), contents('12345'))
+ cache.commitRequest(cache.beginRequest(scopeA, b), contents('67890'))
+
+ expect(cache.evidence()).toMatchObject({
+ entryCount: 1,
+ retainedBytes: 5,
+ keysOldestFirst: [b]
+ })
+ })
+
+ it('measures retained UTF-8 bytes rather than JavaScript code units', () => {
+ expect(getMobilePrFileContentByteCount(contents('a😀', 'é'))).toBe(7)
+ })
+
+ it('counts the retained side when the other side is an oversized sentinel', () => {
+ expect(
+ getMobilePrFileContentByteCount({
+ ...contents('', 'retained'),
+ originalTooLarge: true
+ })
+ ).toBe(8)
+ })
+
+ it('rejects a response from a prior PR context after scope replacement', () => {
+ const cache = new MobilePrFileContentCache(2, 100)
+ const key = createMobilePrFileContentKey({ path: 'file.ts' })
+ const oldRequest = cache.beginRequest(scopeA, key)
+ cache.activateScope(scopeB)
+
+ expect(cache.commitRequest(oldRequest, contents('stale'))).toBe('stale')
+ expect(cache.evidence()).toMatchObject({ scope: scopeB, entryCount: 0, retainedBytes: 0 })
+ })
+
+ it('rejects an older request after the active file selection changes', () => {
+ const cache = new MobilePrFileContentCache(2, 100)
+ const a = createMobilePrFileContentKey({ path: 'a.ts' })
+ const b = createMobilePrFileContentKey({ path: 'b.ts' })
+ const oldRequest = cache.beginRequest(scopeA, a)
+ cache.select(scopeA, b)
+ const currentRequest = cache.beginRequest(scopeA, b)
+
+ expect(cache.commitRequest(oldRequest, contents('stale'))).toBe('stale')
+ expect(cache.commitRequest(currentRequest, contents('current'))).toBe('stored')
+ })
+
+ it('publishes only the active scope and refuses a single over-budget payload', () => {
+ const cache = new MobilePrFileContentCache(2, 4)
+ const key = createMobilePrFileContentKey({ path: 'file.ts' })
+ expect(cache.commitRequest(cache.beginRequest(scopeA, key), contents('12345'))).toBe(
+ 'too-large'
+ )
+ expect(getMobilePrFileContentsForScope(cache.snapshot(), scopeA)).toEqual({})
+ expect(getMobilePrFileContentsForScope(cache.snapshot(), scopeB)).toEqual({})
+ })
+})
diff --git a/mobile/src/tasks/mobile-pr-file-content-cache.ts b/mobile/src/tasks/mobile-pr-file-content-cache.ts
new file mode 100644
index 00000000000..5dcb17a3912
--- /dev/null
+++ b/mobile/src/tasks/mobile-pr-file-content-cache.ts
@@ -0,0 +1,226 @@
+import { MAX_RENDERED_DIFF_COMBINED_CHARACTERS } from '../../../src/shared/large-diff-render-limit'
+import type { GitHubPRFileContents, GitHubRepositoryIdentity } from '../../../src/shared/types'
+
+export const MOBILE_PR_FILE_CONTENT_CACHE_MAX_ENTRIES = 8
+export const MOBILE_PR_FILE_CONTENT_CACHE_MAX_BYTES = MAX_RENDERED_DIFF_COMBINED_CHARACTERS * 4
+
+export type MobilePrFileContentScopeInput = {
+ source: 'item' | 'project'
+ repoId: string
+ prNumber: number
+ headSha: string
+ baseSha: string
+ repository?: GitHubRepositoryIdentity | null
+}
+
+export type MobilePrFileContentKeyInput = {
+ path: string
+ oldPath?: string
+ status?: string
+}
+
+export type MobilePrFileContentRequestToken = {
+ scope: string
+ key: string
+ requestId: number
+}
+
+export type MobilePrFileContentCacheSnapshot = {
+ scope: string | null
+ contentsByKey: Readonly>
+}
+
+export type MobilePrFileContentCacheEvidence = {
+ scope: string | null
+ entryCount: number
+ retainedBytes: number
+ keysOldestFirst: string[]
+}
+
+type CacheEntry = {
+ contents: GitHubPRFileContents
+ byteCount: number
+}
+
+type CacheSelection = {
+ contents: GitHubPRFileContents | undefined
+ scopeChanged: boolean
+}
+
+export class MobilePrFileContentCache {
+ private readonly entries = new Map()
+ private scope: string | null = null
+ private retainedBytes = 0
+ private requestSequence = 0
+ private activeRequest: MobilePrFileContentRequestToken | null = null
+
+ constructor(
+ private readonly maxEntries = MOBILE_PR_FILE_CONTENT_CACHE_MAX_ENTRIES,
+ private readonly maxBytes = MOBILE_PR_FILE_CONTENT_CACHE_MAX_BYTES
+ ) {
+ if (
+ !Number.isInteger(maxEntries) ||
+ maxEntries < 1 ||
+ !Number.isFinite(maxBytes) ||
+ maxBytes < 1
+ ) {
+ throw new Error('Mobile PR file-content cache limits must be positive')
+ }
+ }
+
+ activateScope(scope: string): boolean {
+ if (this.scope === scope) {
+ return false
+ }
+ this.scope = scope
+ this.entries.clear()
+ this.retainedBytes = 0
+ this.activeRequest = null
+ return true
+ }
+
+ clear(): void {
+ this.scope = null
+ this.entries.clear()
+ this.retainedBytes = 0
+ this.activeRequest = null
+ }
+
+ select(scope: string, key: string): CacheSelection {
+ const scopeChanged = this.activateScope(scope)
+ this.activeRequest = null
+ const entry = this.entries.get(key)
+ if (!entry) {
+ return { contents: undefined, scopeChanged }
+ }
+ this.entries.delete(key)
+ this.entries.set(key, entry)
+ return { contents: entry.contents, scopeChanged }
+ }
+
+ beginRequest(scope: string, key: string): MobilePrFileContentRequestToken {
+ this.activateScope(scope)
+ const token = { scope, key, requestId: ++this.requestSequence }
+ this.activeRequest = token
+ return token
+ }
+
+ commitRequest(
+ token: MobilePrFileContentRequestToken,
+ contents: GitHubPRFileContents
+ ): 'stored' | 'stale' | 'too-large' {
+ if (!this.isCurrentRequest(token)) {
+ return 'stale'
+ }
+ this.activeRequest = null
+ const byteCount = getMobilePrFileContentByteCount(contents)
+ if (byteCount > this.maxBytes) {
+ return 'too-large'
+ }
+ const previous = this.entries.get(token.key)
+ this.retainedBytes -= previous?.byteCount ?? 0
+ this.entries.delete(token.key)
+ this.entries.set(token.key, { contents, byteCount })
+ this.retainedBytes += byteCount
+ this.evictOverflow()
+ return 'stored'
+ }
+
+ rejectRequest(token: MobilePrFileContentRequestToken): boolean {
+ if (!this.isCurrentRequest(token)) {
+ return false
+ }
+ this.activeRequest = null
+ return true
+ }
+
+ snapshot(): MobilePrFileContentCacheSnapshot {
+ return {
+ scope: this.scope,
+ contentsByKey: Object.fromEntries(
+ [...this.entries].map(([key, entry]) => [key, entry.contents])
+ )
+ }
+ }
+
+ evidence(): MobilePrFileContentCacheEvidence {
+ return {
+ scope: this.scope,
+ entryCount: this.entries.size,
+ retainedBytes: this.retainedBytes,
+ keysOldestFirst: [...this.entries.keys()]
+ }
+ }
+
+ private isCurrentRequest(token: MobilePrFileContentRequestToken): boolean {
+ return (
+ this.scope === token.scope &&
+ this.activeRequest?.requestId === token.requestId &&
+ this.activeRequest.scope === token.scope &&
+ this.activeRequest.key === token.key
+ )
+ }
+
+ private evictOverflow(): void {
+ while (this.entries.size > this.maxEntries || this.retainedBytes > this.maxBytes) {
+ const oldestKey = this.entries.keys().next().value
+ if (typeof oldestKey !== 'string') {
+ return
+ }
+ const oldest = this.entries.get(oldestKey)
+ this.retainedBytes -= oldest?.byteCount ?? 0
+ this.entries.delete(oldestKey)
+ }
+ }
+}
+
+export function createMobilePrFileContentScope(input: MobilePrFileContentScopeInput): string {
+ return JSON.stringify([
+ input.source,
+ input.repoId,
+ input.prNumber,
+ input.repository?.host?.toLowerCase() ?? '',
+ input.repository?.owner.toLowerCase() ?? '',
+ input.repository?.repo.toLowerCase() ?? '',
+ input.headSha,
+ input.baseSha
+ ])
+}
+
+export function createMobilePrFileContentKey(input: MobilePrFileContentKeyInput): string {
+ return input.path
+}
+
+export function getMobilePrFileContentsForScope(
+ snapshot: MobilePrFileContentCacheSnapshot,
+ scope: string | null
+): Readonly> {
+ return scope !== null && snapshot.scope === scope ? snapshot.contentsByKey : {}
+}
+
+export function getMobilePrFileContentByteCount(contents: GitHubPRFileContents): number {
+ return getUtf8ByteCount(contents.original) + getUtf8ByteCount(contents.modified)
+}
+
+function getUtf8ByteCount(value: string): number {
+ let byteCount = 0
+ for (let index = 0; index < value.length; index += 1) {
+ const code = value.charCodeAt(index)
+ if (code < 0x80) {
+ byteCount += 1
+ } else if (code < 0x800) {
+ byteCount += 2
+ } else if (code >= 0xd800 && code <= 0xdbff && index + 1 < value.length) {
+ const next = value.charCodeAt(index + 1)
+ if (next >= 0xdc00 && next <= 0xdfff) {
+ byteCount += 4
+ index += 1
+ } else {
+ byteCount += 3
+ }
+ } else {
+ byteCount += 3
+ }
+ }
+ return byteCount
+}
diff --git a/mobile/src/tasks/use-mobile-pr-file-content-cache.ts b/mobile/src/tasks/use-mobile-pr-file-content-cache.ts
new file mode 100644
index 00000000000..d364324336b
--- /dev/null
+++ b/mobile/src/tasks/use-mobile-pr-file-content-cache.ts
@@ -0,0 +1,183 @@
+import { useCallback, useEffect, useState } from 'react'
+import type { GitHubPRFileContents, GitHubRepositoryIdentity } from '../../../src/shared/types'
+import {
+ MobilePrFileContentCache,
+ createMobilePrFileContentKey,
+ createMobilePrFileContentScope,
+ getMobilePrFileContentsForScope,
+ type MobilePrFileContentKeyInput
+} from './mobile-pr-file-content-cache'
+
+type MobilePrScopeTaskItem = {
+ provider: string
+ source: unknown
+} | null
+
+type MobilePrScopeDetail = {
+ provider: string
+ headSha?: unknown
+ baseSha?: unknown
+} | null
+
+type MobileProjectPrScopeItem = {
+ itemType: string
+ content: { number?: unknown }
+} | null
+
+type MobileProjectPrScopeRepo = { id?: unknown } | null
+
+type MobilePrFileContentLoad = () => Promise
+type MobilePrFileContentErrorSetter = (message: string) => void
+
+export function createMobileItemPrFileContentScope(
+ item: MobilePrScopeTaskItem,
+ detail: MobilePrScopeDetail
+): string | null {
+ const source =
+ item?.source && typeof item.source === 'object'
+ ? (item.source as { type?: unknown; repoId?: unknown; number?: unknown })
+ : null
+ if (
+ item?.provider !== 'github' ||
+ source?.type !== 'pr' ||
+ typeof source.repoId !== 'string' ||
+ typeof source.number !== 'number' ||
+ detail?.provider !== 'github' ||
+ typeof detail.headSha !== 'string' ||
+ !detail.headSha ||
+ typeof detail.baseSha !== 'string' ||
+ !detail.baseSha
+ ) {
+ return null
+ }
+ return createMobilePrFileContentScope({
+ source: 'item',
+ repoId: source.repoId,
+ prNumber: source.number,
+ headSha: detail.headSha,
+ baseSha: detail.baseSha
+ })
+}
+
+export function createMobileProjectPrFileContentScope(
+ item: MobileProjectPrScopeItem,
+ repo: MobileProjectPrScopeRepo,
+ detail: MobilePrScopeDetail,
+ repository?: GitHubRepositoryIdentity | null
+): string | null {
+ if (
+ item?.itemType !== 'PULL_REQUEST' ||
+ typeof item.content.number !== 'number' ||
+ typeof repo?.id !== 'string' ||
+ detail?.provider !== 'github' ||
+ typeof detail.headSha !== 'string' ||
+ !detail.headSha ||
+ typeof detail.baseSha !== 'string' ||
+ !detail.baseSha
+ ) {
+ return null
+ }
+ return createMobilePrFileContentScope({
+ source: 'project',
+ repoId: repo.id,
+ prNumber: item.content.number,
+ repository,
+ headSha: detail.headSha,
+ baseSha: detail.baseSha
+ })
+}
+
+export function useMobilePrFileContentCache(activeScope: string | null): {
+ clear: () => void
+ contents: Readonly>
+ load: (
+ scope: string,
+ file: MobilePrFileContentKeyInput,
+ loadContents: MobilePrFileContentLoad,
+ setError: MobilePrFileContentErrorSetter
+ ) => Promise
+ loadingPath: string | null
+} {
+ const [cache] = useState(() => new MobilePrFileContentCache())
+ const [snapshot, setSnapshot] = useState(() => cache.snapshot())
+ const [loadingPath, setLoadingPath] = useState(null)
+ const clear = useCallback(() => {
+ cache.clear()
+ setSnapshot(cache.snapshot())
+ setLoadingPath(null)
+ }, [cache])
+
+ useEffect(() => {
+ if (activeScope === null) {
+ clear()
+ } else if (cache.activateScope(activeScope)) {
+ setSnapshot(cache.snapshot())
+ setLoadingPath(null)
+ }
+ }, [activeScope, cache, clear])
+
+ const load = useCallback(
+ async (
+ scope: string,
+ file: MobilePrFileContentKeyInput,
+ loadContents: MobilePrFileContentLoad,
+ setError: MobilePrFileContentErrorSetter
+ ): Promise => {
+ const key = createMobilePrFileContentKey(file)
+ const selection = cache.select(scope, key)
+ if (selection.scopeChanged) {
+ setSnapshot(cache.snapshot())
+ }
+ if (selection.contents) {
+ setLoadingPath(null)
+ return
+ }
+ const token = cache.beginRequest(scope, key)
+ setLoadingPath(file.path)
+ setError('')
+ try {
+ const result = await loadContents()
+ if (!isGitHubPrFileContents(result)) {
+ throw new Error('Invalid file contents response')
+ }
+ const commit = cache.commitRequest(token, result)
+ if (commit === 'stale') {
+ return
+ }
+ if (commit === 'too-large') {
+ setError('File too large for mobile preview.')
+ } else {
+ setSnapshot(cache.snapshot())
+ }
+ setLoadingPath((current) => (current === file.path ? null : current))
+ } catch (error) {
+ if (!cache.rejectRequest(token)) {
+ return
+ }
+ setError(error instanceof Error ? error.message : 'Failed to load file contents')
+ setLoadingPath((current) => (current === file.path ? null : current))
+ }
+ },
+ [cache]
+ )
+
+ return {
+ clear,
+ contents: getMobilePrFileContentsForScope(snapshot, activeScope),
+ load,
+ loadingPath
+ }
+}
+
+function isGitHubPrFileContents(value: unknown): value is GitHubPRFileContents {
+ if (!value || typeof value !== 'object') {
+ return false
+ }
+ const contents = value as Partial
+ return (
+ typeof contents.original === 'string' &&
+ typeof contents.modified === 'string' &&
+ typeof contents.originalIsBinary === 'boolean' &&
+ typeof contents.modifiedIsBinary === 'boolean'
+ )
+}
diff --git a/mobile/src/terminal/custom-accessory-key-store.test.ts b/mobile/src/terminal/custom-accessory-key-store.test.ts
new file mode 100644
index 00000000000..f479f366d47
--- /dev/null
+++ b/mobile/src/terminal/custom-accessory-key-store.test.ts
@@ -0,0 +1,68 @@
+import AsyncStorage from '@react-native-async-storage/async-storage'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import {
+ CUSTOM_ACCESSORY_KEY_MAX_BYTES_CHARACTERS,
+ CUSTOM_ACCESSORY_KEYS_MAX_ENTRIES,
+ CUSTOM_ACCESSORY_KEYS_MAX_STORAGE_CHARACTERS,
+ loadCustomKeys,
+ saveCustomKeys,
+ type CustomKey
+} from './custom-accessory-key-store'
+
+vi.mock('@react-native-async-storage/async-storage', () => ({
+ default: {
+ getItem: vi.fn(),
+ setItem: vi.fn()
+ }
+}))
+
+function customKey(index: number, bytes = 'echo ok'): CustomKey {
+ return { id: `key-${index}`, label: `Key ${index}`, bytes, enter: false }
+}
+
+describe('custom accessory key store', () => {
+ beforeEach(() => {
+ vi.mocked(AsyncStorage.getItem).mockReset()
+ vi.mocked(AsyncStorage.setItem).mockReset().mockResolvedValue(undefined)
+ })
+
+ it('round-trips normal custom keys unchanged', async () => {
+ const keys = [customKey(1), customKey(2)]
+ vi.mocked(AsyncStorage.getItem).mockResolvedValue(JSON.stringify(keys))
+
+ await expect(loadCustomKeys()).resolves.toEqual(keys)
+ await expect(saveCustomKeys(keys)).resolves.toEqual(keys)
+ expect(AsyncStorage.setItem).toHaveBeenCalledWith(
+ 'orca:custom-accessory-keys',
+ JSON.stringify(keys)
+ )
+ })
+
+ it('accepts the exact key count and evicts oldest at one over', async () => {
+ const exact = Array.from({ length: CUSTOM_ACCESSORY_KEYS_MAX_ENTRIES }, (_, index) =>
+ customKey(index)
+ )
+ await expect(saveCustomKeys(exact)).resolves.toEqual(exact)
+
+ const oneOver = [...exact, customKey(CUSTOM_ACCESSORY_KEYS_MAX_ENTRIES)]
+ const retained = await saveCustomKeys(oneOver)
+ expect(retained).toHaveLength(CUSTOM_ACCESSORY_KEYS_MAX_ENTRIES)
+ expect(retained[0]?.id).toBe('key-1')
+ expect(retained.at(-1)?.id).toBe(`key-${CUSTOM_ACCESSORY_KEYS_MAX_ENTRIES}`)
+ })
+
+ it('accepts the exact macro length and drops one character over', async () => {
+ const exact = customKey(1, 'x'.repeat(CUSTOM_ACCESSORY_KEY_MAX_BYTES_CHARACTERS))
+ const oversized = customKey(2, 'x'.repeat(CUSTOM_ACCESSORY_KEY_MAX_BYTES_CHARACTERS + 1))
+
+ await expect(saveCustomKeys([exact, oversized])).resolves.toEqual([exact])
+ })
+
+ it('rejects durable JSON over the storage character budget before parsing', async () => {
+ vi.mocked(AsyncStorage.getItem).mockResolvedValue(
+ 'x'.repeat(CUSTOM_ACCESSORY_KEYS_MAX_STORAGE_CHARACTERS + 1)
+ )
+
+ await expect(loadCustomKeys()).resolves.toEqual([])
+ })
+})
diff --git a/mobile/src/terminal/custom-accessory-key-store.ts b/mobile/src/terminal/custom-accessory-key-store.ts
new file mode 100644
index 00000000000..e7bde17f950
--- /dev/null
+++ b/mobile/src/terminal/custom-accessory-key-store.ts
@@ -0,0 +1,81 @@
+import AsyncStorage from '@react-native-async-storage/async-storage'
+
+export const CUSTOM_ACCESSORY_KEYS_STORAGE_KEY = 'orca:custom-accessory-keys'
+export const CUSTOM_ACCESSORY_KEYS_MAX_ENTRIES = 128
+export const CUSTOM_ACCESSORY_KEYS_MAX_STORAGE_CHARACTERS = 512 * 1024
+export const CUSTOM_ACCESSORY_KEY_MAX_ID_CHARACTERS = 256
+export const CUSTOM_ACCESSORY_KEY_MAX_LABEL_CHARACTERS = 128
+export const CUSTOM_ACCESSORY_KEY_MAX_BYTES_CHARACTERS = 16 * 1024
+
+export type CustomKey = {
+ id: string
+ label: string
+ bytes: string
+ enter: boolean
+}
+
+export async function loadCustomKeys(): Promise {
+ try {
+ const raw = await AsyncStorage.getItem(CUSTOM_ACCESSORY_KEYS_STORAGE_KEY)
+ if (!raw || raw.length > CUSTOM_ACCESSORY_KEYS_MAX_STORAGE_CHARACTERS) {
+ return []
+ }
+ return retainCustomKeys(JSON.parse(raw) as unknown).keys
+ } catch {
+ return []
+ }
+}
+
+export async function saveCustomKeys(keys: CustomKey[]): Promise {
+ const retained = retainCustomKeys(keys)
+ await AsyncStorage.setItem(CUSTOM_ACCESSORY_KEYS_STORAGE_KEY, retained.serialized)
+ return retained.keys
+}
+
+function retainCustomKeys(value: unknown): { keys: CustomKey[]; serialized: string } {
+ if (!Array.isArray(value)) {
+ return { keys: [], serialized: '[]' }
+ }
+ const retained: Array<{ key: CustomKey; serialized: string }> = []
+ let storageCharacters = 2
+ for (let index = value.length - 1; index >= 0; index -= 1) {
+ if (retained.length >= CUSTOM_ACCESSORY_KEYS_MAX_ENTRIES) {
+ break
+ }
+ const key = normalizeCustomKey(value[index])
+ if (!key) {
+ continue
+ }
+ const serialized = JSON.stringify(key)
+ const nextCharacters = storageCharacters + serialized.length + (retained.length > 0 ? 1 : 0)
+ if (nextCharacters > CUSTOM_ACCESSORY_KEYS_MAX_STORAGE_CHARACTERS) {
+ continue
+ }
+ retained.push({ key, serialized })
+ storageCharacters = nextCharacters
+ }
+ retained.reverse()
+ return {
+ keys: retained.map(({ key }) => key),
+ serialized: `[${retained.map(({ serialized }) => serialized).join(',')}]`
+ }
+}
+
+function normalizeCustomKey(value: unknown): CustomKey | null {
+ if (!value || typeof value !== 'object') {
+ return null
+ }
+ const key = value as Partial
+ if (
+ typeof key.id !== 'string' ||
+ key.id.length > CUSTOM_ACCESSORY_KEY_MAX_ID_CHARACTERS ||
+ typeof key.label !== 'string' ||
+ key.label.length > CUSTOM_ACCESSORY_KEY_MAX_LABEL_CHARACTERS ||
+ typeof key.bytes !== 'string' ||
+ key.bytes.length > CUSTOM_ACCESSORY_KEY_MAX_BYTES_CHARACTERS ||
+ typeof key.enter !== 'boolean'
+ ) {
+ return null
+ }
+ return { id: key.id, label: key.label, bytes: key.bytes, enter: key.enter }
+}
diff --git a/mobile/src/terminal/terminal-accessory-layout.test.ts b/mobile/src/terminal/terminal-accessory-layout.test.ts
index 1a4135494ea..692267214be 100644
--- a/mobile/src/terminal/terminal-accessory-layout.test.ts
+++ b/mobile/src/terminal/terminal-accessory-layout.test.ts
@@ -1,6 +1,8 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
+ TERMINAL_ACCESSORY_LAYOUT_MAX_IDS,
+ TERMINAL_ACCESSORY_LAYOUT_MAX_STORAGE_CHARACTERS,
TERMINAL_ACCESSORY_LAYOUT_STORAGE_KEY,
createTerminalAccessoryLayoutPreference,
getDefaultTerminalAccessoryBuiltInIds,
@@ -83,6 +85,61 @@ describe('terminal accessory layout', () => {
)
})
+ it('accepts the exact storage limit and skips parsing one character more', async () => {
+ const preference = {
+ version: 2,
+ orderedBuiltInIds: getDefaultTerminalAccessoryBuiltInIds(),
+ visibleBuiltInIds: ['escape']
+ }
+ const serialized = JSON.stringify(preference)
+ asyncStorageMock.getItem.mockResolvedValueOnce(
+ serialized + ' '.repeat(TERMINAL_ACCESSORY_LAYOUT_MAX_STORAGE_CHARACTERS - serialized.length)
+ )
+ await expect(loadTerminalAccessoryLayout()).resolves.toMatchObject({
+ visibleBuiltInIds: ['escape']
+ })
+
+ const parse = vi.spyOn(JSON, 'parse')
+ asyncStorageMock.getItem.mockResolvedValueOnce(
+ 'x'.repeat(TERMINAL_ACCESSORY_LAYOUT_MAX_STORAGE_CHARACTERS + 1)
+ )
+ await expect(loadTerminalAccessoryLayout()).resolves.toEqual(
+ createTerminalAccessoryLayoutPreference(getDefaultTerminalAccessoryLayout())
+ )
+ expect(parse).not.toHaveBeenCalled()
+ parse.mockRestore()
+ })
+
+ it('accepts the exact stored id count and falls back on one more', () => {
+ const exact = Array.from({ length: TERMINAL_ACCESSORY_LAYOUT_MAX_IDS }, (_, index) =>
+ index === 0 ? 'tab' : 'escape'
+ )
+ expect(
+ normalizeTerminalAccessoryLayoutPreference(
+ { version: 2, orderedBuiltInIds: exact, visibleBuiltInIds: ['escape'] },
+ ['escape', 'tab']
+ )
+ ).toEqual({
+ version: 2,
+ orderedBuiltInIds: ['tab', 'escape'],
+ visibleBuiltInIds: ['escape']
+ })
+ expect(
+ normalizeTerminalAccessoryLayoutPreference(
+ {
+ version: 2,
+ orderedBuiltInIds: [...exact, 'tab'],
+ visibleBuiltInIds: ['escape']
+ },
+ ['escape', 'tab']
+ )
+ ).toEqual({
+ version: 2,
+ orderedBuiltInIds: ['escape', 'tab'],
+ visibleBuiltInIds: ['escape', 'tab']
+ })
+ })
+
it('preserves a custom v2 order and its visible subset', () => {
const reversed = [...getDefaultTerminalAccessoryBuiltInIds()].toReversed()
diff --git a/mobile/src/terminal/terminal-accessory-layout.ts b/mobile/src/terminal/terminal-accessory-layout.ts
index cee7a74173e..7dc3dc70e00 100644
--- a/mobile/src/terminal/terminal-accessory-layout.ts
+++ b/mobile/src/terminal/terminal-accessory-layout.ts
@@ -3,6 +3,9 @@ import AsyncStorage from '@react-native-async-storage/async-storage'
import { TERMINAL_ACCESSORY_KEYS, type TerminalAccessoryKey } from './terminal-accessory-keys'
export const TERMINAL_ACCESSORY_LAYOUT_STORAGE_KEY = 'orca:terminal-accessory-layout'
+export const TERMINAL_ACCESSORY_LAYOUT_MAX_STORAGE_CHARACTERS = 64 * 1024
+export const TERMINAL_ACCESSORY_LAYOUT_MAX_IDS = 256
+export const TERMINAL_ACCESSORY_LAYOUT_MAX_ID_CHARACTERS = 256
export type TerminalAccessoryLayout = {
orderedBuiltInIds: string[]
@@ -26,17 +29,28 @@ function defaultPreference(ids = builtInIds()): TerminalAccessoryLayoutPreferenc
}
function stringArray(value: unknown): string[] | null {
- if (!Array.isArray(value)) {
+ if (!Array.isArray(value) || value.length > TERMINAL_ACCESSORY_LAYOUT_MAX_IDS) {
return null
}
- return value.every((item): item is string => typeof item === 'string') ? value : null
+ return value.every(
+ (item): item is string =>
+ typeof item === 'string' && item.length <= TERMINAL_ACCESSORY_LAYOUT_MAX_ID_CHARACTERS
+ )
+ ? value
+ : null
}
function dedupeKnownIds(ids: string[], builtInSet: Set): string[] {
const seen = new Set()
const out: string[] = []
- for (const id of ids) {
- if (!builtInSet.has(id) || seen.has(id)) {
+ const count = Math.min(ids.length, TERMINAL_ACCESSORY_LAYOUT_MAX_IDS)
+ for (let index = 0; index < count; index++) {
+ const id = ids[index]!
+ if (
+ id.length > TERMINAL_ACCESSORY_LAYOUT_MAX_ID_CHARACTERS ||
+ !builtInSet.has(id) ||
+ seen.has(id)
+ ) {
continue
}
seen.add(id)
@@ -218,6 +232,9 @@ export async function loadTerminalAccessoryLayout(): Promise TERMINAL_ACCESSORY_LAYOUT_MAX_STORAGE_CHARACTERS) {
+ return defaultPreference()
+ }
return normalizeTerminalAccessoryLayoutPreference(JSON.parse(raw))
} catch {
return defaultPreference()
@@ -226,5 +243,9 @@ export async function loadTerminalAccessoryLayout(): Promise {
const preference = createTerminalAccessoryLayoutPreference(layout)
- await AsyncStorage.setItem(TERMINAL_ACCESSORY_LAYOUT_STORAGE_KEY, JSON.stringify(preference))
+ const serialized = JSON.stringify(preference)
+ if (serialized.length > TERMINAL_ACCESSORY_LAYOUT_MAX_STORAGE_CHARACTERS) {
+ throw new Error('terminal accessory layout exceeds storage limit')
+ }
+ await AsyncStorage.setItem(TERMINAL_ACCESSORY_LAYOUT_STORAGE_KEY, serialized)
}
diff --git a/mobile/src/terminal/terminal-webview-default-theme.ts b/mobile/src/terminal/terminal-webview-default-theme.ts
new file mode 100644
index 00000000000..0769f4283ed
--- /dev/null
+++ b/mobile/src/terminal/terminal-webview-default-theme.ts
@@ -0,0 +1,27 @@
+import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-types'
+import { colors } from '../theme/mobile-theme'
+
+export const DEFAULT_TERMINAL_WEBVIEW_THEME: RuntimeMobileTerminalTheme['theme'] = {
+ background: colors.terminalBg,
+ foreground: '#c0caf5',
+ cursor: '#c0caf5',
+ cursorAccent: colors.terminalBg,
+ selectionBackground: '#33467c',
+ selectionForeground: '#c0caf5',
+ black: '#15161e',
+ red: '#f7768e',
+ green: '#9ece6a',
+ yellow: '#e0af68',
+ blue: '#7aa2f7',
+ magenta: '#bb9af7',
+ cyan: '#7dcfff',
+ white: '#a9b1d6',
+ brightBlack: '#414868',
+ brightRed: '#f7768e',
+ brightGreen: '#9ece6a',
+ brightYellow: '#e0af68',
+ brightBlue: '#7aa2f7',
+ brightMagenta: '#bb9af7',
+ brightCyan: '#7dcfff',
+ brightWhite: '#c0caf5'
+}
diff --git a/mobile/src/terminal/terminal-webview-html.ts b/mobile/src/terminal/terminal-webview-html.ts
index a4d9c350c47..9d66610dd0e 100644
--- a/mobile/src/terminal/terminal-webview-html.ts
+++ b/mobile/src/terminal/terminal-webview-html.ts
@@ -1,7 +1,7 @@
-// xterm.js WebView document + default Tokyonight theme; extracted from TerminalWebView.tsx for the max-lines budget.
-import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-types'
+// xterm.js WebView document; extracted from TerminalWebView.tsx for the max-lines budget.
import { colors } from '../theme/mobile-theme'
import { TERMINAL_TEXT_SCALES } from '../storage/preferences'
+import { DEFAULT_TERMINAL_WEBVIEW_THEME } from './terminal-webview-default-theme'
import { TERMINAL_PATH_TAP_JS } from './terminal-path-tap-injected'
import { XTERM_ENGINE_CSS, XTERM_ENGINE_JS } from './terminal-webview-engine.generated'
import { TERMINAL_REFLOW_JS } from './terminal-webview-reflow-injected'
@@ -12,30 +12,9 @@ import { TERMINAL_QUERY_REPLY_JS } from './terminal-webview-query-reply-injected
import { URL_TAP_WEBVIEW_JS } from './terminal-webview-url-tap'
import { TERMINAL_WEBGL_RECOVERY_JS } from './terminal-webview-webgl-recovery-injected'
-const DEFAULT_TERMINAL_THEME: RuntimeMobileTerminalTheme['theme'] = {
- background: colors.terminalBg,
- foreground: '#c0caf5',
- cursor: '#c0caf5',
- cursorAccent: colors.terminalBg,
- selectionBackground: '#33467c',
- selectionForeground: '#c0caf5',
- black: '#15161e',
- red: '#f7768e',
- green: '#9ece6a',
- yellow: '#e0af68',
- blue: '#7aa2f7',
- magenta: '#bb9af7',
- cyan: '#7dcfff',
- white: '#a9b1d6',
- brightBlack: '#414868',
- brightRed: '#f7768e',
- brightGreen: '#9ece6a',
- brightYellow: '#e0af68',
- brightBlue: '#7aa2f7',
- brightMagenta: '#bb9af7',
- brightCyan: '#7dcfff',
- brightWhite: '#c0caf5'
-}
+export const TERMINAL_WEBVIEW_WRITE_QUEUE_MAX_UNITS = 1_000_000
+export const TERMINAL_WEBVIEW_WRITE_QUEUE_MAX_ENTRIES = 4_096
+export const TERMINAL_WEBVIEW_AFTER_DRAIN_MAX_CALLBACKS = 256
// Why: TUI escape codes assume the desktop's cols/rows, so init xterm at those dims and fit the phone via a measured CSS scale() instead of resizing.
export const XTERM_HTML = `
@@ -213,10 +192,11 @@ window.onerror = function(msg) {
var scrollIndicator = document.getElementById('scroll-indicator');
var scrollThumb = document.getElementById('scroll-thumb');
var scrollIndicatorHideTimer = null;
- var writeQueue = [];
+ var writeQueue = [], writeQueueUnits = 0;
var writeQueueHead = 0;
- var writesDraining = false;
- var afterDrainCallbacks = [];
+ var writesDraining = false, writeBacklogFailed = false, afterDrainCallbacks = [];
+ var WRITE_QUEUE_MAX_UNITS = ${TERMINAL_WEBVIEW_WRITE_QUEUE_MAX_UNITS}, WRITE_QUEUE_MAX_ENTRIES = ${TERMINAL_WEBVIEW_WRITE_QUEUE_MAX_ENTRIES};
+ var AFTER_DRAIN_MAX_CALLBACKS = ${TERMINAL_WEBVIEW_AFTER_DRAIN_MAX_CALLBACKS};
var termObserverDisposables = [];
var ready = false;
// Why: init() flips ready false on every re-init (live width reflow included)
@@ -287,9 +267,10 @@ window.onerror = function(msg) {
var normalScrollFrameId = null;
var initRows = 24;
var terminalGeneration = 0;
- var defaultTheme = ${JSON.stringify(DEFAULT_TERMINAL_THEME)};
+ var defaultTheme = ${JSON.stringify(DEFAULT_TERMINAL_WEBVIEW_THEME)};
var terminalThemeInput = null;
var terminalTheme = defaultTheme;
+ var terminalMinimumContrastRatio = 3;
var webglAddon = null;
var webglRecoveryTimer = null;
var activeAltScreenSnapshot = false;
@@ -556,9 +537,22 @@ ${TERMINAL_WEBVIEW_THEME_JS}
}
}
- function resetWriteQueue() {
- writeQueue = [];
- writeQueueHead = 0;
+ function resetWriteQueue() { writeQueue = []; writeQueueHead = 0; writeQueueUnits = 0; }
+
+ function failWriteBacklog() {
+ if (writeBacklogFailed) return;
+ writeBacklogFailed = true; terminalGeneration++; ready = false; writesDraining = false;
+ resetWriteQueue(); afterDrainCallbacks = [];
+ reportEngineError('terminal write backlog exceeded safe limit', null, true);
+ }
+
+ function reserveWriteQueueEntry(units) {
+ if (writeBacklogFailed) return false;
+ var pendingEntries = writeQueue.length - writeQueueHead;
+ if (pendingEntries >= WRITE_QUEUE_MAX_ENTRIES || writeQueueUnits + units > WRITE_QUEUE_MAX_UNITS) {
+ failWriteBacklog(); return false;
+ }
+ writeQueueUnits += units; return true;
}
function isStatusDotPresentationSelector(value) {
@@ -589,13 +583,12 @@ ${TERMINAL_WEBVIEW_THEME_JS}
return normalized;
}
- function enqueueWrite(data) {
- writeQueue.push(normalizeStatusDotPresentation(data));
+ function enqueueWrite(data) { var normalized = normalizeStatusDotPresentation(data);
+ if (!reserveWriteQueueEntry(normalized.length)) return false;
+ writeQueue.push(normalized); return true;
}
- function enqueueWriteBoundary(callback) {
- writeQueue.push(callback);
- }
+ function enqueueWriteBoundary(callback) { if (!reserveWriteQueueEntry(0)) return false; writeQueue.push(callback); return true; }
function nextQueuedWrite() {
if (writeQueueHead >= writeQueue.length) {
@@ -604,6 +597,7 @@ ${TERMINAL_WEBVIEW_THEME_JS}
}
var next = writeQueue[writeQueueHead];
writeQueueHead++;
+ if (typeof next === 'string') writeQueueUnits = Math.max(0, writeQueueUnits - next.length);
// Why: high-throughput terminals can enqueue faster than xterm parses;
// compact consumed slots so drain work stays O(1) without retaining old chunks.
if (writeQueueHead > 128 && writeQueueHead * 2 > writeQueue.length) {
@@ -659,8 +653,8 @@ ${TERMINAL_WEBVIEW_THEME_JS}
}
function afterWritesDrained(callback) {
- afterDrainCallbacks.push(callback);
- pumpWrites(terminalGeneration);
+ if (afterDrainCallbacks.length >= AFTER_DRAIN_MAX_CALLBACKS) { failWriteBacklog(); return; }
+ afterDrainCallbacks.push(callback); pumpWrites(terminalGeneration);
}
${TERMINAL_WEBGL_RECOVERY_JS}
@@ -681,6 +675,7 @@ ${TERMINAL_WEBGL_RECOVERY_JS}
webglAddon = null;
ready = false;
resetWriteQueue();
+ writeBacklogFailed = false;
statusDotPendingSelector = false;
writesDraining = false;
afterDrainCallbacks = [];
@@ -714,6 +709,7 @@ ${TERMINAL_WEBGL_RECOVERY_JS}
cols: cols || 80,
rows: rows || 24,
theme: terminalTheme,
+ minimumContrastRatio: terminalMinimumContrastRatio,
fontFamily: terminalFontFamily,
fontSize: fontPxForScale(currentTextScale),
fontWeight: '300',
@@ -766,7 +762,7 @@ ${TERMINAL_WEBGL_RECOVERY_JS}
function write(data) {
updateMouseModeFromData(data);
- enqueueWrite(data);
+ if (!enqueueWrite(data)) return;
pumpWrites(terminalGeneration);
// Why: first live data chunk after init may widen the buffer past
// what the post-replay applyFitScale measured. Re-fit once after this
@@ -930,6 +926,7 @@ ${TERMINAL_WEBGL_RECOVERY_JS}
} else if (msg.type === 'clear') {
terminalGeneration++;
resetWriteQueue(); resumeTerminalDataReplyAuthority(); // Why: clear drops the replay boundary.
+ writeBacklogFailed = false;
statusDotPendingSelector = false;
afterDrainCallbacks = [];
writesDraining = false;
diff --git a/mobile/src/terminal/terminal-webview-pending-messages.test.ts b/mobile/src/terminal/terminal-webview-pending-messages.test.ts
new file mode 100644
index 00000000000..d7bcc5b6c29
--- /dev/null
+++ b/mobile/src/terminal/terminal-webview-pending-messages.test.ts
@@ -0,0 +1,42 @@
+import { describe, expect, it } from 'vitest'
+import type { TerminalWebViewCommand } from './terminal-webview-messages'
+import {
+ createTerminalWebViewPendingMessages,
+ MAX_PENDING_WEB_MESSAGES
+} from './terminal-webview-pending-messages'
+
+function flushPending(queue: ReturnType) {
+ const delivered: TerminalWebViewCommand[] = []
+ queue.flush((message) => delivered.push(message))
+ return delivered
+}
+
+describe('terminal WebView pending messages', () => {
+ it('retains only the latest snapshot when init is superseded before readiness', () => {
+ const queue = createTerminalWebViewPendingMessages()
+ queue.queue({ type: 'write', data: 'current-document-tail' })
+ queue.queue({ type: 'init', cols: 80, rows: 24, initialData: 'old snapshot' })
+ queue.queue({ type: 'write', data: 'covered by the replacement snapshot' })
+ queue.queue({ type: 'init', cols: 100, rows: 30, initialData: 'new snapshot' })
+
+ expect(flushPending(queue)).toEqual([
+ { type: 'write', data: 'current-document-tail' },
+ { type: 'init', cols: 100, rows: 30, initialData: 'new snapshot' }
+ ])
+ })
+
+ it('caps tiny control-message floods while preserving the newest state', () => {
+ const queue = createTerminalWebViewPendingMessages()
+ for (let index = 0; index < MAX_PENDING_WEB_MESSAGES + 100; index += 1) {
+ queue.queue({ type: 'resize', cols: index + 1, rows: 24 })
+ }
+
+ const delivered = flushPending(queue)
+ expect(delivered).toHaveLength(MAX_PENDING_WEB_MESSAGES)
+ expect(delivered.at(-1)).toEqual({
+ type: 'resize',
+ cols: MAX_PENDING_WEB_MESSAGES + 100,
+ rows: 24
+ })
+ })
+})
diff --git a/mobile/src/terminal/terminal-webview-pending-messages.ts b/mobile/src/terminal/terminal-webview-pending-messages.ts
index 5aa49e2e94f..1c3423b5cc8 100644
--- a/mobile/src/terminal/terminal-webview-pending-messages.ts
+++ b/mobile/src/terminal/terminal-webview-pending-messages.ts
@@ -2,6 +2,7 @@ import type { TerminalWebViewCommand } from './terminal-webview-messages'
const MAX_PENDING_WEB_WRITE_BYTES = 1_000_000
const MAX_PENDING_WEB_WRITE_MESSAGES = 4096
+export const MAX_PENDING_WEB_MESSAGES = 8192
export function createTerminalWebViewPendingMessages() {
let pending: TerminalWebViewCommand[] = []
@@ -18,9 +19,42 @@ export function createTerminalWebViewPendingMessages() {
resetCounters()
}
+ const removeAt = (index: number) => {
+ const [removed] = pending.splice(index, 1)
+ if (removed?.type === 'write') {
+ pendingWriteBytes = Math.max(0, pendingWriteBytes - removed.data.length)
+ pendingWriteCount = Math.max(0, pendingWriteCount - 1)
+ }
+ }
+
+ const supersedePendingInit = (msg: Extract) => {
+ const existingIndex = pending.findIndex((candidate) => candidate.type === 'init')
+ if (existingIndex === -1) {
+ return false
+ }
+ while (pending.length > existingIndex) {
+ removeAt(pending.length - 1)
+ }
+ pending.push(msg)
+ return true
+ }
+
+ const trimMessageCount = () => {
+ while (pending.length > MAX_PENDING_WEB_MESSAGES) {
+ const controlIndex = pending.findIndex(
+ (candidate) => candidate.type !== 'write' && candidate.type !== 'init'
+ )
+ removeAt(Math.max(controlIndex, 0))
+ }
+ }
+
const queue = (msg: TerminalWebViewCommand) => {
+ if (msg.type === 'init' && supersedePendingInit(msg)) {
+ return
+ }
pending.push(msg)
if (msg.type !== 'write') {
+ trimMessageCount()
return
}
@@ -35,12 +69,9 @@ export function createTerminalWebViewPendingMessages() {
resetCounters()
return
}
- const [dropped] = pending.splice(dropIndex, 1)
- if (dropped?.type === 'write') {
- pendingWriteBytes = Math.max(0, pendingWriteBytes - dropped.data.length)
- pendingWriteCount = Math.max(0, pendingWriteCount - 1)
- }
+ removeAt(dropIndex)
}
+ trimMessageCount()
}
const flush = (send: (msg: TerminalWebViewCommand) => void) => {
diff --git a/mobile/src/terminal/terminal-webview-scroll-routing.test.ts b/mobile/src/terminal/terminal-webview-scroll-routing.test.ts
index 391ccd3a840..ddd1f57cb8e 100644
--- a/mobile/src/terminal/terminal-webview-scroll-routing.test.ts
+++ b/mobile/src/terminal/terminal-webview-scroll-routing.test.ts
@@ -117,6 +117,16 @@ describe('TerminalWebView scroll routing', () => {
expect(source).not.toContain('writeQueue.shift()')
})
+ it('fails closed when the in-WebView parser backlog reaches its exact bounds', () => {
+ expect(source).toContain('export const TERMINAL_WEBVIEW_WRITE_QUEUE_MAX_UNITS = 1_000_000')
+ expect(source).toContain('export const TERMINAL_WEBVIEW_WRITE_QUEUE_MAX_ENTRIES = 4_096')
+ expect(source).toContain('export const TERMINAL_WEBVIEW_AFTER_DRAIN_MAX_CALLBACKS = 256')
+ expect(source).toContain('writeQueueUnits + units > WRITE_QUEUE_MAX_UNITS')
+ expect(source).toContain('pendingEntries >= WRITE_QUEUE_MAX_ENTRIES')
+ expect(source).toContain('afterDrainCallbacks.length >= AFTER_DRAIN_MAX_CALLBACKS')
+ expect(source).toContain("reportEngineError('terminal write backlog exceeded safe limit'")
+ })
+
it('bounds native-side pending WebView writes while preserving control messages', () => {
expect(source).toContain('const MAX_PENDING_WEB_WRITE_BYTES = 1_000_000')
expect(source).toContain('const MAX_PENDING_WEB_WRITE_MESSAGES = 4096')
diff --git a/mobile/src/terminal/terminal-webview-text-zoom.test.ts b/mobile/src/terminal/terminal-webview-text-zoom.test.ts
index 09aeb82d3f7..1f6ba4fac01 100644
--- a/mobile/src/terminal/terminal-webview-text-zoom.test.ts
+++ b/mobile/src/terminal/terminal-webview-text-zoom.test.ts
@@ -91,7 +91,8 @@ describe('TerminalWebView text zoom', () => {
expect(terminalHtmlSource).toContain(
'data.replace(CLAUDE_STATUS_DOT_PATTERN, CLAUDE_STATUS_DOT + TEXT_PRESENTATION_SELECTOR)'
)
- expect(terminalHtmlSource).toContain('writeQueue.push(normalizeStatusDotPresentation(data))')
+ expect(terminalHtmlSource).toContain('var normalized = normalizeStatusDotPresentation(data)')
+ expect(terminalHtmlSource).toContain('writeQueue.push(normalized)')
})
it('normalizes Claude status dots idempotently across write chunks', () => {
diff --git a/mobile/src/terminal/terminal-webview-theme-injected.test.ts b/mobile/src/terminal/terminal-webview-theme-injected.test.ts
new file mode 100644
index 00000000000..92e4127b2fc
--- /dev/null
+++ b/mobile/src/terminal/terminal-webview-theme-injected.test.ts
@@ -0,0 +1,79 @@
+import { Script } from 'node:vm'
+import { parse } from 'acorn'
+import { describe, expect, it } from 'vitest'
+import { TERMINAL_WEBVIEW_THEME_JS } from './terminal-webview-theme-injected'
+
+const DARK_FLOOR = 3
+const LIGHT_FLOOR = 4.5
+
+// Eval the injected theme JS in a bare context so the declared helpers become
+// callable properties on it (mirrors terminal-webview-engine.test.ts).
+function loadThemeInjected(extra: Record = {}): Record {
+ const context: Record = {
+ defaultTheme: { background: '#1a1b26', foreground: '#c0caf5' },
+ ...extra
+ }
+ new Script(TERMINAL_WEBVIEW_THEME_JS).runInNewContext(context)
+ return context
+}
+
+describe('mobile terminal-webview contrast floor gate', () => {
+ it('parses at the Chrome 74 syntax floor', () => {
+ expect(() => parse(TERMINAL_WEBVIEW_THEME_JS, { ecmaVersion: 2019 })).not.toThrow()
+ })
+
+ it('picks the dark floor for dark composed backgrounds', () => {
+ const { resolveTerminalContrastFloor } = loadThemeInjected() as {
+ resolveTerminalContrastFloor: (bg: unknown) => number
+ }
+ for (const bg of ['#1a1b26', '#1e242a', '#282828', '#000000', 'black']) {
+ expect(resolveTerminalContrastFloor(bg)).toBe(DARK_FLOOR)
+ }
+ })
+
+ it('picks the light floor for light composed backgrounds', () => {
+ const { resolveTerminalContrastFloor } = loadThemeInjected() as {
+ resolveTerminalContrastFloor: (bg: unknown) => number
+ }
+ for (const bg of ['#ffffff', '#fbf1c7', 'white', 'rgb(240 240 240)']) {
+ expect(resolveTerminalContrastFloor(bg)).toBe(LIGHT_FLOOR)
+ }
+ })
+
+ it('composites transparency over the dark app surface before deciding', () => {
+ const { resolveTerminalContrastFloor } = loadThemeInjected() as {
+ resolveTerminalContrastFloor: (bg: unknown) => number
+ }
+ // Fully transparent → app surface (dark) → dark floor.
+ expect(resolveTerminalContrastFloor('transparent')).toBe(DARK_FLOOR)
+ // Faint white over the dark surface stays dark; opaque-enough white flips light.
+ expect(resolveTerminalContrastFloor('rgba(255,255,255,0.15)')).toBe(DARK_FLOOR)
+ expect(resolveTerminalContrastFloor('rgba(255,255,255,0.9)')).toBe(LIGHT_FLOOR)
+ })
+
+ it('defaults unparseable backgrounds to the dark floor so output never stays invisible', () => {
+ const { resolveTerminalContrastFloor } = loadThemeInjected() as {
+ resolveTerminalContrastFloor: (bg: unknown) => number
+ }
+ for (const bg of [undefined, null, '', 'not-a-color', '#12', 42]) {
+ expect(resolveTerminalContrastFloor(bg)).toBe(DARK_FLOOR)
+ }
+ })
+
+ it('writes the resolved floor onto a live terminal when the theme changes', () => {
+ const term = { options: { theme: undefined as unknown, minimumContrastRatio: 1 } }
+ const context = loadThemeInjected({
+ term,
+ document: {
+ documentElement: { style: { background: '' } },
+ body: { style: { background: '' } }
+ }
+ }) as Record & { applyTerminalTheme: (input: unknown) => void }
+
+ context.applyTerminalTheme({ theme: { background: '#ffffff' } })
+ expect(term.options.minimumContrastRatio).toBe(LIGHT_FLOOR)
+
+ context.applyTerminalTheme({ theme: { background: '#1e242a' } })
+ expect(term.options.minimumContrastRatio).toBe(DARK_FLOOR)
+ })
+})
diff --git a/mobile/src/terminal/terminal-webview-theme-injected.ts b/mobile/src/terminal/terminal-webview-theme-injected.ts
index 1f798fe337e..c2d9beb4786 100644
--- a/mobile/src/terminal/terminal-webview-theme-injected.ts
+++ b/mobile/src/terminal/terminal-webview-theme-injected.ts
@@ -1,7 +1,85 @@
import { colors } from '../theme/mobile-theme'
// Theme normalization and page-surface painting injected into the WebView IIFE.
+// Mirrors the desktop minimumContrastRatio gate (src/renderer/src/lib/terminal-contrast-correction.ts,
+// #7934/#10104): a dark composed background gets a mild floor of 3 to rescue near-background body text
+// (e.g. Antigravity's #262b30 on #1e242a) without over-brightening vibrant ANSI colors; a light
+// background keeps the WCAG-AA 4.5 floor. Gate on the composed background luminance, not app mode,
+// because either theme slot can hold either kind of theme.
export const TERMINAL_WEBVIEW_THEME_JS = `
+ var DARK_BG_MIN_CONTRAST = 3;
+ var LIGHT_BG_MIN_CONTRAST = 4.5;
+ // Dark app surface a transparent terminal background composites over (matches desktop APP_SURFACE_COLORS.dark).
+ var CONTRAST_APP_SURFACE = { r: 10, g: 10, b: 10 };
+
+ function parseTerminalBackgroundRgba(value) {
+ if (typeof value !== 'string') return null;
+ var v = value.trim().toLowerCase();
+ if (!v) return null;
+ if (v === 'black') return { r: 0, g: 0, b: 0, a: 1 };
+ if (v === 'white') return { r: 255, g: 255, b: 255, a: 1 };
+ if (v === 'transparent') return { r: 0, g: 0, b: 0, a: 0 };
+ var hex = v.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/);
+ if (hex) {
+ var h = hex[1];
+ var ch;
+ if (h.length === 3 || h.length === 4) {
+ ch = h.split('').map(function (p) { return parseInt(p + p, 16); });
+ } else {
+ ch = [];
+ for (var i = 0; i < h.length; i += 2) ch.push(parseInt(h.slice(i, i + 2), 16));
+ }
+ return { r: ch[0], g: ch[1], b: ch[2], a: ch[3] === undefined ? 1 : ch[3] / 255 };
+ }
+ var rgb = v.match(/^rgba?\\(([^)]+)\\)$/);
+ if (!rgb) return null;
+ var parts = rgb[1].indexOf(',') >= 0 ? rgb[1].split(',') : rgb[1].split(/[\\s/]+/);
+ parts = parts.map(function (p) { return p.trim(); }).filter(function (p) { return p.length > 0; });
+ if (parts.length < 3) return null;
+ var channel = function (p) {
+ var n = p.charAt(p.length - 1) === '%' ? (parseFloat(p) / 100) * 255 : parseFloat(p);
+ return isFinite(n) ? Math.min(255, Math.max(0, Math.round(n))) : null;
+ };
+ var r = channel(parts[0]), g = channel(parts[1]), b = channel(parts[2]);
+ if (r === null || g === null || b === null) return null;
+ var a = 1;
+ if (parts[3] !== undefined) {
+ var raw = parts[3].charAt(parts[3].length - 1) === '%' ? parseFloat(parts[3]) / 100 : parseFloat(parts[3]);
+ a = isFinite(raw) ? Math.min(1, Math.max(0, raw)) : 1;
+ }
+ return { r: r, g: g, b: b, a: a };
+ }
+
+ function terminalRelativeLuminance(rgb) {
+ var lin = function (c) {
+ var n = c / 255;
+ return n <= 0.03928 ? n / 12.92 : Math.pow((n + 0.055) / 1.055, 2.4);
+ };
+ return 0.2126 * lin(rgb.r) + 0.7152 * lin(rgb.g) + 0.0722 * lin(rgb.b);
+ }
+
+ function terminalContrastRatio(a, b) {
+ var la = terminalRelativeLuminance(a), lb = terminalRelativeLuminance(b);
+ return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
+ }
+
+ // Pick the xterm minimumContrastRatio floor from the composed terminal background.
+ // Unparseable input defaults to the dark floor so agent output never stays invisible.
+ function resolveTerminalContrastFloor(background) {
+ var color = parseTerminalBackgroundRgba(background);
+ if (!color) return DARK_BG_MIN_CONTRAST;
+ var composited = color.a < 1
+ ? {
+ r: Math.round(color.r * color.a + CONTRAST_APP_SURFACE.r * (1 - color.a)),
+ g: Math.round(color.g * color.a + CONTRAST_APP_SURFACE.g * (1 - color.a)),
+ b: Math.round(color.b * color.a + CONTRAST_APP_SURFACE.b * (1 - color.a))
+ }
+ : color;
+ var isLight = terminalContrastRatio({ r: 0, g: 0, b: 0 }, composited) >=
+ terminalContrastRatio({ r: 255, g: 255, b: 255 }, composited);
+ return isLight ? LIGHT_BG_MIN_CONTRAST : DARK_BG_MIN_CONTRAST;
+ }
+
function normalizeTerminalTheme(input) {
var source = input && typeof input === 'object' && input.theme && typeof input.theme === 'object'
? input.theme
@@ -22,6 +100,10 @@ export const TERMINAL_WEBVIEW_THEME_JS = `
var background = terminalTheme.background || '${colors.terminalBg}';
document.documentElement.style.background = background;
document.body.style.background = background;
- if (term) term.options.theme = terminalTheme;
+ terminalMinimumContrastRatio = resolveTerminalContrastFloor(background);
+ if (term) {
+ term.options.theme = terminalTheme;
+ term.options.minimumContrastRatio = terminalMinimumContrastRatio;
+ }
}
`
diff --git a/mobile/src/terminal/terminal-write-coalescer.test.ts b/mobile/src/terminal/terminal-write-coalescer.test.ts
index 1279bbeb7d6..3562e7db0f4 100644
--- a/mobile/src/terminal/terminal-write-coalescer.test.ts
+++ b/mobile/src/terminal/terminal-write-coalescer.test.ts
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import {
createTerminalWriteCoalescer,
TERMINAL_WRITE_FLUSH_WINDOW_MS,
+ TERMINAL_WRITE_MAX_PENDING_CHUNKS,
TERMINAL_WRITE_MAX_PENDING_UNITS
} from './terminal-write-coalescer'
@@ -128,6 +129,20 @@ describe('terminal write coalescer', () => {
expect(sink.delivered).toHaveLength(2)
})
+ it('flushes tiny chunks before their object count can grow unbounded', () => {
+ vi.useFakeTimers()
+ const sink = createDeliverySink()
+ const coalescer = createTerminalWriteCoalescer(sink.deliver)
+
+ coalescer.write('leading')
+ for (let index = 0; index < TERMINAL_WRITE_MAX_PENDING_CHUNKS; index += 1) {
+ coalescer.write('x')
+ }
+
+ expect(sink.delivered).toEqual(['leading', 'x'.repeat(TERMINAL_WRITE_MAX_PENDING_CHUNKS)])
+ expect(vi.getTimerCount()).toBe(0)
+ })
+
it('treats write("") as a no-op: no delivery, no buffer append, no timer', () => {
vi.useFakeTimers()
const sink = createDeliverySink()
diff --git a/mobile/src/terminal/terminal-write-coalescer.ts b/mobile/src/terminal/terminal-write-coalescer.ts
index b27f6c37fb2..1553253864a 100644
--- a/mobile/src/terminal/terminal-write-coalescer.ts
+++ b/mobile/src/terminal/terminal-write-coalescer.ts
@@ -5,6 +5,7 @@ export const TERMINAL_WRITE_FLUSH_WINDOW_MS = 48
// Why: defense-in-depth only — server ack flow control bounds inflow; this cap keeps
// an upstream flow-control bug from growing the buffer unboundedly. UTF-16 code units.
export const TERMINAL_WRITE_MAX_PENDING_UNITS = 512 * 1024
+export const TERMINAL_WRITE_MAX_PENDING_CHUNKS = 4_096
export function createTerminalWriteCoalescer(deliver: (data: string) => void) {
let pendingChunks: string[] = []
@@ -46,7 +47,10 @@ export function createTerminalWriteCoalescer(deliver: (data: string) => void) {
}
pendingChunks.push(data)
pendingUnits += data.length
- if (pendingUnits > TERMINAL_WRITE_MAX_PENDING_UNITS) {
+ if (
+ pendingUnits > TERMINAL_WRITE_MAX_PENDING_UNITS ||
+ pendingChunks.length >= TERMINAL_WRITE_MAX_PENDING_CHUNKS
+ ) {
flushNow()
return
}
diff --git a/mobile/src/transport/bounded-process-line-reader.test.ts b/mobile/src/transport/bounded-process-line-reader.test.ts
new file mode 100644
index 00000000000..3fd0083e134
--- /dev/null
+++ b/mobile/src/transport/bounded-process-line-reader.test.ts
@@ -0,0 +1,46 @@
+import { once } from 'node:events'
+import { PassThrough } from 'node:stream'
+import { describe, expect, it } from 'vitest'
+import {
+ appendProcessOutputTail,
+ attachBoundedProcessLineReader,
+ PROCESS_LINE_MAX_BYTES
+} from '../../scripts/bounded-process-line-reader.mjs'
+
+describe('attachBoundedProcessLineReader', () => {
+ it('preserves ordinary LF, CRLF, and split UTF-8 lines', () => {
+ const stream = new PassThrough()
+ const lines: string[] = []
+ attachBoundedProcessLineReader(stream, (line) => lines.push(line))
+
+ const unicode = Buffer.from('café\n')
+ stream.write('first\r\nsecond\r')
+ stream.write('\n')
+ stream.write(unicode.subarray(0, -2))
+ stream.end(unicode.subarray(-2))
+
+ expect(lines).toEqual(['first', 'second', 'café'])
+ })
+
+ it('retains only the configured prefix of an unterminated line', async () => {
+ const stream = new PassThrough()
+ const lines: string[] = []
+ attachBoundedProcessLineReader(stream, (line) => lines.push(line), 4)
+
+ const ended = once(stream, 'end')
+ stream.end('abcdefgh')
+ await ended
+
+ expect(lines).toEqual(['abcd… [line truncated]'])
+ })
+
+ it('uses a bounded default for process output lines', () => {
+ expect(PROCESS_LINE_MAX_BYTES).toBe(64 * 1024)
+ })
+
+ it('keeps exact output below the tail cap and newest output above it', () => {
+ expect(appendProcessOutputTail('first\n', 'second', 32)).toBe('first\nsecond\n')
+ expect(appendProcessOutputTail('first\n', 'second', 8)).toBe('\nsecond\n')
+ expect(appendProcessOutputTail('first\n', 'second', 0)).toBe('')
+ })
+})
diff --git a/mobile/src/transport/bounded-response-body.test.ts b/mobile/src/transport/bounded-response-body.test.ts
new file mode 100644
index 00000000000..183c1dedc61
--- /dev/null
+++ b/mobile/src/transport/bounded-response-body.test.ts
@@ -0,0 +1,34 @@
+import { describe, expect, it } from 'vitest'
+import { responseBodyIncludesWithinLimit } from '../../scripts/bounded-response-body.mjs'
+
+function responseWithChunks(chunks: string[], contentLength?: number): Response {
+ const encoder = new TextEncoder()
+ return new Response(
+ new ReadableStream({
+ start(controller) {
+ for (const chunk of chunks) {
+ controller.enqueue(encoder.encode(chunk))
+ }
+ controller.close()
+ }
+ }),
+ { headers: contentLength === undefined ? {} : { 'content-length': String(contentLength) } }
+ )
+}
+
+describe('responseBodyIncludesWithinLimit', () => {
+ it('matches a marker split across admitted chunks', async () => {
+ const response = responseWithChunks(['packager-', 'status:running'])
+ await expect(
+ responseBodyIncludesWithinLimit(response, 'packager-status:running', 64)
+ ).resolves.toBe(true)
+ })
+
+ it('rejects streamed and declared bodies above the byte limit', async () => {
+ const streamed = responseWithChunks(['1234', '5'])
+ const declared = responseWithChunks(['ok'], 5)
+
+ await expect(responseBodyIncludesWithinLimit(streamed, 'missing', 4)).resolves.toBe(false)
+ await expect(responseBodyIncludesWithinLimit(declared, 'ok', 4)).resolves.toBe(false)
+ })
+})
diff --git a/mobile/src/transport/browser-screencast-protocol.ts b/mobile/src/transport/browser-screencast-protocol.ts
index 01ee4353f6c..7cde80ec547 100644
--- a/mobile/src/transport/browser-screencast-protocol.ts
+++ b/mobile/src/transport/browser-screencast-protocol.ts
@@ -1,3 +1,5 @@
+import { parseMobileJsonTextWithinLimits } from './mobile-json-text-admission'
+
const BROWSER_SCREENCAST_KIND = 0x62
const BROWSER_SCREENCAST_VERSION = 1
const HEADER_BYTES = 16
@@ -51,7 +53,7 @@ function byteToFormat(value: number): BrowserScreencastFormat | null {
function decodeJson(bytes: Uint8Array): unknown {
try {
- return JSON.parse(new TextDecoder().decode(bytes)) as unknown
+ return parseMobileJsonTextWithinLimits(new TextDecoder().decode(bytes))
} catch {
return null
}
diff --git a/mobile/src/transport/connection-log-buffer.test.ts b/mobile/src/transport/connection-log-buffer.test.ts
index e7ce662534b..1f7d8762d4f 100644
--- a/mobile/src/transport/connection-log-buffer.test.ts
+++ b/mobile/src/transport/connection-log-buffer.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
-import { createConnectionLogStore } from './connection-log-buffer'
+import { CONNECTION_LOG_HOST_ID_MAX_BYTES, createConnectionLogStore } from './connection-log-buffer'
import type { ConnectionLogEntry } from './types'
function entry(id: number): ConnectionLogEntry {
@@ -53,4 +53,81 @@ describe('connection log buffer', () => {
store.append('host-a', entry(2))
expect(onA).toHaveBeenCalledTimes(1)
})
+
+ it('evicts the least-recently-written inactive host past the host cap', () => {
+ const store = createConnectionLogStore(3, 2)
+ store.append('host-a', entry(1))
+ store.append('host-b', entry(2))
+ store.append('host-a', entry(3))
+ store.append('host-c', entry(4))
+
+ expect(store.get('host-a').map((e) => e.id)).toEqual(['log-1', 'log-3'])
+ expect(store.get('host-b')).toEqual([])
+ expect(store.get('host-c').map((e) => e.id)).toEqual(['log-4'])
+ })
+
+ it('drops removed-host entries and refreshes an active snapshot', () => {
+ const store = createConnectionLogStore()
+ const listener = vi.fn()
+ store.append('host-a', entry(1))
+ store.subscribe('host-a', listener)
+
+ store.delete('host-a')
+
+ expect(store.get('host-a')).toEqual([])
+ expect(listener).toHaveBeenCalledOnce()
+ })
+
+ it('accepts the exact host-id limit and rejects one more byte', () => {
+ const store = createConnectionLogStore()
+ const exactHost = 'h'.repeat(CONNECTION_LOG_HOST_ID_MAX_BYTES)
+
+ store.append(exactHost, entry(1))
+ store.append(`${exactHost}h`, entry(2))
+
+ expect(store.get(exactHost).map((value) => value.id)).toEqual(['log-1'])
+ expect(store.get(`${exactHost}h`)).toEqual([])
+ })
+
+ it('accepts an exact entry-byte budget and rejects one more byte', () => {
+ const store = createConnectionLogStore(10, 10, {
+ maxEntryBytes: 260,
+ maxHostBytes: 1024,
+ maxStoreBytes: 2048
+ })
+ const exact: ConnectionLogEntry = { id: '', ts: 1, level: 'info', message: '' }
+
+ store.append('host-a', exact)
+ store.append('host-a', { ...exact, message: 'x' })
+
+ expect(store.get('host-a')).toEqual([exact])
+ })
+
+ it('keeps newest entries within the per-host byte budget', () => {
+ const store = createConnectionLogStore(10, 10, {
+ maxEntryBytes: 1024,
+ maxHostBytes: 700,
+ maxStoreBytes: 4096
+ })
+
+ store.append('host-a', entry(1))
+ store.append('host-a', entry(2))
+ store.append('host-a', entry(3))
+
+ expect(store.get('host-a').map((value) => value.id)).toEqual(['log-2', 'log-3'])
+ })
+
+ it('evicts inactive hosts to stay within the aggregate byte budget', () => {
+ const store = createConnectionLogStore(10, 10, {
+ maxEntryBytes: 1024,
+ maxHostBytes: 1024,
+ maxStoreBytes: 700
+ })
+
+ store.append('host-a', entry(1))
+ store.append('host-b', entry(2))
+
+ expect(store.get('host-a')).toEqual([])
+ expect(store.get('host-b').map((value) => value.id)).toEqual(['log-2'])
+ })
})
diff --git a/mobile/src/transport/connection-log-buffer.ts b/mobile/src/transport/connection-log-buffer.ts
index 1b223ad8e1f..b7005efc602 100644
--- a/mobile/src/transport/connection-log-buffer.ts
+++ b/mobile/src/transport/connection-log-buffer.ts
@@ -1,4 +1,5 @@
import type { ConnectionLogEntry } from './types'
+import { measureUtf8ByteLength } from '../../../src/shared/utf8-byte-limits'
// Why: the rpc-client's onLog entries were only wired during pairing; for
// long-lived host connections everything went to console.log, invisible to
@@ -8,35 +9,159 @@ import type { ConnectionLogEntry } from './types'
// swaps (forceReconnect) and provider remounts (hot reload); bounded so an
// all-night reconnect loop can't grow memory unbounded.
const MAX_ENTRIES_PER_HOST = 200
+const MAX_RETAINED_HOSTS = 128
+export const CONNECTION_LOG_HOST_ID_MAX_BYTES = 4 * 1024
+export const CONNECTION_LOG_ENTRY_MAX_BYTES = 64 * 1024
+export const CONNECTION_LOG_HOST_MAX_RETAINED_BYTES = 256 * 1024
+export const CONNECTION_LOG_STORE_MAX_RETAINED_BYTES = 8 * 1024 * 1024
+const CONNECTION_LOG_MAX_LISTENERS_PER_HOST = 16
+
+type RetainedConnectionLogEntry = {
+ entry: ConnectionLogEntry
+ bytes: number
+}
+
+type RetainedHostLog = {
+ entries: RetainedConnectionLogEntry[]
+ bytes: number
+}
+
+type ConnectionLogByteLimits = {
+ maxEntryBytes: number
+ maxHostBytes: number
+ maxStoreBytes: number
+}
+
+function measureBoundedString(value: unknown, maxBytes: number): number | null {
+ if (value === undefined) {
+ return 0
+ }
+ if (typeof value !== 'string') {
+ return null
+ }
+ const measurement = measureUtf8ByteLength(value, { stopAfterBytes: maxBytes })
+ return measurement.exceededLimit ? null : measurement.byteLength
+}
+
+function measureConnectionLogEntry(entry: ConnectionLogEntry, maxBytes: number): number | null {
+ let bytes = 256
+ for (const value of [entry.id, entry.level, entry.message, entry.detail]) {
+ const valueBytes = measureBoundedString(value, maxBytes - bytes)
+ if (valueBytes === null) {
+ return null
+ }
+ bytes += valueBytes
+ }
+ return bytes <= maxBytes ? bytes : null
+}
export type ConnectionLogStore = {
append: (hostId: string, entry: ConnectionLogEntry) => void
get: (hostId: string) => readonly ConnectionLogEntry[]
subscribe: (hostId: string, listener: () => void) => () => void
+ delete: (hostId: string) => void
}
export function createConnectionLogStore(
- maxEntriesPerHost: number = MAX_ENTRIES_PER_HOST
+ maxEntriesPerHost: number = MAX_ENTRIES_PER_HOST,
+ maxRetainedHosts: number = MAX_RETAINED_HOSTS,
+ byteLimits: Partial = {}
): ConnectionLogStore {
- const entriesByHost = new Map()
+ const maxEntryBytes = Math.min(
+ byteLimits.maxEntryBytes ?? CONNECTION_LOG_ENTRY_MAX_BYTES,
+ CONNECTION_LOG_ENTRY_MAX_BYTES
+ )
+ const maxHostBytes = Math.min(
+ byteLimits.maxHostBytes ?? CONNECTION_LOG_HOST_MAX_RETAINED_BYTES,
+ CONNECTION_LOG_HOST_MAX_RETAINED_BYTES
+ )
+ const maxStoreBytes = Math.min(
+ byteLimits.maxStoreBytes ?? CONNECTION_LOG_STORE_MAX_RETAINED_BYTES,
+ CONNECTION_LOG_STORE_MAX_RETAINED_BYTES
+ )
+ const entriesByHost = new Map()
const listenersByHost = new Map void>>()
// Why: useSyncExternalStore compares snapshots by reference — getSnapshot
// must return the SAME array until the data actually changes, or React
// loops re-rendering. Cache per host; invalidate on append.
const snapshotByHost = new Map()
const EMPTY: readonly ConnectionLogEntry[] = []
+ let retainedStoreBytes = 0
+
+ const deleteHost = (hostId: string): boolean => {
+ const host = entriesByHost.get(hostId)
+ if (!host) {
+ return false
+ }
+ entriesByHost.delete(hostId)
+ retainedStoreBytes -= host.bytes
+ snapshotByHost.delete(hostId)
+ return true
+ }
+
+ const evictOldestUnobservedHost = (exceptHostId?: string): boolean => {
+ for (const hostId of entriesByHost.keys()) {
+ if (hostId !== exceptHostId && !listenersByHost.has(hostId)) {
+ return deleteHost(hostId)
+ }
+ }
+ return false
+ }
return {
append(hostId, entry) {
- let entries = entriesByHost.get(hostId)
- if (!entries) {
- entries = []
- entriesByHost.set(hostId, entries)
+ const hostIdBytes = measureBoundedString(hostId, CONNECTION_LOG_HOST_ID_MAX_BYTES)
+ const entryBytes = measureConnectionLogEntry(entry, maxEntryBytes)
+ if (hostIdBytes === null || entryBytes === null || entryBytes > maxHostBytes) {
+ return
}
- entries.push(entry)
- if (entries.length > maxEntriesPerHost) {
- entries.splice(0, entries.length - maxEntriesPerHost)
+ let host = entriesByHost.get(hostId)
+ if (!host) {
+ while (entriesByHost.size >= maxRetainedHosts) {
+ if (!evictOldestUnobservedHost()) {
+ return
+ }
+ }
+ const retainedHostKeyBytes = hostIdBytes + 128
+ while (retainedStoreBytes + retainedHostKeyBytes + entryBytes > maxStoreBytes) {
+ if (!evictOldestUnobservedHost()) {
+ return
+ }
+ }
+ host = { entries: [], bytes: retainedHostKeyBytes }
+ entriesByHost.set(hostId, host)
+ retainedStoreBytes += retainedHostKeyBytes
+ } else {
+ entriesByHost.delete(hostId)
+ entriesByHost.set(hostId, host)
}
+ while (
+ host.entries.length >= maxEntriesPerHost ||
+ host.bytes + entryBytes > maxHostBytes ||
+ retainedStoreBytes + entryBytes > maxStoreBytes
+ ) {
+ const oldest = host.entries.shift()
+ if (oldest) {
+ host.bytes -= oldest.bytes
+ retainedStoreBytes -= oldest.bytes
+ continue
+ }
+ if (!evictOldestUnobservedHost(hostId)) {
+ break
+ }
+ }
+ if (
+ host.bytes + entryBytes > maxHostBytes ||
+ retainedStoreBytes + entryBytes > maxStoreBytes
+ ) {
+ if (host.entries.length === 0) {
+ deleteHost(hostId)
+ }
+ return
+ }
+ host.entries.push({ entry, bytes: entryBytes })
+ host.bytes += entryBytes
+ retainedStoreBytes += entryBytes
snapshotByHost.delete(hostId)
const listeners = listenersByHost.get(hostId)
if (listeners) {
@@ -51,21 +176,30 @@ export function createConnectionLogStore(
if (cached) {
return cached
}
- const entries = entriesByHost.get(hostId)
- if (!entries || entries.length === 0) {
+ const host = entriesByHost.get(hostId)
+ if (!host || host.entries.length === 0) {
return EMPTY
}
- const snapshot = Object.freeze([...entries])
+ const snapshot = Object.freeze(host.entries.map((retained) => retained.entry))
snapshotByHost.set(hostId, snapshot)
return snapshot
},
subscribe(hostId, listener) {
+ if (measureBoundedString(hostId, CONNECTION_LOG_HOST_ID_MAX_BYTES) === null) {
+ return () => {}
+ }
let listeners = listenersByHost.get(hostId)
if (!listeners) {
+ if (listenersByHost.size >= maxRetainedHosts) {
+ return () => {}
+ }
listeners = new Set()
listenersByHost.set(hostId, listeners)
}
+ if (listeners.size >= CONNECTION_LOG_MAX_LISTENERS_PER_HOST) {
+ return () => {}
+ }
listeners.add(listener)
return () => {
const set = listenersByHost.get(hostId)
@@ -77,6 +211,16 @@ export function createConnectionLogStore(
listenersByHost.delete(hostId)
}
}
+ },
+
+ delete(hostId) {
+ deleteHost(hostId)
+ const listeners = listenersByHost.get(hostId)
+ if (listeners) {
+ for (const listener of listeners) {
+ listener()
+ }
+ }
}
}
}
diff --git a/mobile/src/transport/e2ee.ts b/mobile/src/transport/e2ee.ts
index 2732b3d615f..d36cb577341 100644
--- a/mobile/src/transport/e2ee.ts
+++ b/mobile/src/transport/e2ee.ts
@@ -4,6 +4,10 @@
// stream frames use the raw byte bundle.
import nacl from 'tweetnacl'
import * as ExpoCrypto from 'expo-crypto'
+import { MOBILE_INBOUND_MAX_FRAME_BYTES } from './mobile-inbound-frame-queue'
+
+export const MOBILE_E2EE_MAX_FRAME_BASE64_CHARACTERS =
+ Math.ceil(MOBILE_INBOUND_MAX_FRAME_BYTES / 3) * 4
// Why: Hermes (React Native's JS engine) lacks crypto.getRandomValues,
// which tweetnacl requires. expo-crypto provides a native secure RNG
@@ -39,8 +43,14 @@ function uint8ToBase64(bytes: Uint8Array): string {
return btoa(binary)
}
-function base64ToUint8(b64: string): Uint8Array {
+function base64ToUint8(b64: string, maxDecodedBytes: number): Uint8Array {
+ if (b64.length > Math.ceil(maxDecodedBytes / 3) * 4) {
+ throw new Error('Base64 payload exceeds safe size')
+ }
const binary = atob(b64)
+ if (binary.length > maxDecodedBytes) {
+ throw new Error('Base64 payload exceeds safe size')
+ }
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i)
@@ -49,7 +59,7 @@ function base64ToUint8(b64: string): Uint8Array {
}
export function publicKeyFromBase64(b64: string): Uint8Array {
- const key = base64ToUint8(b64)
+ const key = base64ToUint8(b64, 32)
if (key.length !== 32) {
throw new Error(
`Invalid public key: expected 32 bytes, got ${key.length} from "${b64.slice(0, 20)}..."`
@@ -68,7 +78,10 @@ export function encrypt(plaintext: string, sharedKey: Uint8Array): string {
}
export function decrypt(encrypted: string, sharedKey: Uint8Array): string | null {
- const bundle = base64ToUint8(encrypted)
+ if (encrypted.length > MOBILE_E2EE_MAX_FRAME_BASE64_CHARACTERS) {
+ throw new Error('Base64 payload exceeds safe size')
+ }
+ const bundle = base64ToUint8(encrypted, MOBILE_INBOUND_MAX_FRAME_BYTES)
const plaintext = decryptBytes(bundle, sharedKey)
return plaintext ? new TextDecoder().decode(plaintext) : null
}
diff --git a/mobile/src/transport/host-client-open-registry.test.ts b/mobile/src/transport/host-client-open-registry.test.ts
new file mode 100644
index 00000000000..f70cef41f95
--- /dev/null
+++ b/mobile/src/transport/host-client-open-registry.test.ts
@@ -0,0 +1,20 @@
+import { describe, expect, it } from 'vitest'
+import { HostClientOpenRegistry } from './host-client-open-registry'
+
+describe('HostClientOpenRegistry', () => {
+ it('cancels replaced and oldest tickets while bounding pending hosts', () => {
+ const registry = new HostClientOpenRegistry(2)
+ const first = registry.register('host-a', new Promise(() => {}))
+ const replacement = registry.register('host-a', new Promise(() => {}))
+ const second = registry.register('host-b', new Promise(() => {}))
+ const third = registry.register('host-c', new Promise(() => {}))
+
+ expect(first.cancelled).toBe(true)
+ expect(replacement.cancelled).toBe(true)
+ expect(second.cancelled).toBe(false)
+ expect(third.cancelled).toBe(false)
+ expect(registry.getActivePromise('host-a')).toBeNull()
+ expect(registry.getActivePromise('host-b')).toBe(second.promise)
+ expect(registry.getActivePromise('host-c')).toBe(third.promise)
+ })
+})
diff --git a/mobile/src/transport/host-client-open-registry.ts b/mobile/src/transport/host-client-open-registry.ts
index a47ca5a231a..ac9c6a16c96 100644
--- a/mobile/src/transport/host-client-open-registry.ts
+++ b/mobile/src/transport/host-client-open-registry.ts
@@ -3,15 +3,35 @@ export type HostClientOpenTicket = {
promise: Promise
}
+export const HOST_CLIENT_OPEN_MAX_PENDING = 64
+
export class HostClientOpenRegistry {
private readonly pending = new Map()
+ constructor(private readonly maxPending = HOST_CLIENT_OPEN_MAX_PENDING) {}
+
getActivePromise(hostId: string): Promise | null {
const ticket = this.pending.get(hostId)
return ticket && !ticket.cancelled ? ticket.promise : null
}
register(hostId: string, promise: Promise): HostClientOpenTicket {
+ const prior = this.pending.get(hostId)
+ if (prior) {
+ prior.cancelled = true
+ this.pending.delete(hostId)
+ }
+ while (this.pending.size >= Math.max(1, this.maxPending)) {
+ const oldestHostId = this.pending.keys().next().value as string | undefined
+ if (oldestHostId === undefined) {
+ break
+ }
+ const oldest = this.pending.get(oldestHostId)
+ if (oldest) {
+ oldest.cancelled = true
+ }
+ this.pending.delete(oldestHostId)
+ }
const ticket = { cancelled: false, promise }
this.pending.set(hostId, ticket)
return ticket
diff --git a/mobile/src/transport/host-credential-cleanup.test.ts b/mobile/src/transport/host-credential-cleanup.test.ts
index bd483a3280f..2ba0664522d 100644
--- a/mobile/src/transport/host-credential-cleanup.test.ts
+++ b/mobile/src/transport/host-credential-cleanup.test.ts
@@ -1,6 +1,8 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
+ HOST_CREDENTIAL_CLEANUP_MAX_INFLIGHT_DELETES,
+ HOST_CREDENTIAL_CLEANUP_MAX_PENDING_IDS,
loadPendingHostCredentialCleanup,
loadPendingHostCredentialCleanupIds,
resetHostCredentialCleanupForTests,
@@ -270,4 +272,41 @@ describe('host credential cleanup', () => {
await expect(loadPendingHostCredentialCleanupIds()).resolves.toEqual([])
})
+
+ it('accepts the exact pending-id cap and treats one over as unreadable', async () => {
+ storedPendingIds = Array.from(
+ { length: HOST_CREDENTIAL_CLEANUP_MAX_PENDING_IDS },
+ (_, index) => `host-${index}`
+ )
+
+ await expect(loadPendingHostCredentialCleanup()).resolves.toEqual({
+ ids: storedPendingIds,
+ storageUnreadable: false
+ })
+
+ storedPendingIds = [...storedPendingIds, 'one-over']
+ await expect(loadPendingHostCredentialCleanup()).resolves.toEqual({
+ ids: [],
+ storageUnreadable: true
+ })
+ })
+
+ it('caps simultaneous native deletes while preserving excess durable intents', async () => {
+ vi.useFakeTimers()
+ const deleteCredential = vi.fn(() => new Promise(() => undefined))
+ const hostCount = HOST_CREDENTIAL_CLEANUP_MAX_INFLIGHT_DELETES + 1
+
+ await Promise.all(
+ Array.from({ length: hostCount }, (_, index) =>
+ scheduleHostCredentialCleanup(`host-${index}`, deleteCredential, 3_000)
+ )
+ )
+ await flushMicrotasks()
+
+ expect(deleteCredential).toHaveBeenCalledTimes(HOST_CREDENTIAL_CLEANUP_MAX_INFLIGHT_DELETES)
+ expect(storedPendingIds).toHaveLength(hostCount)
+
+ await vi.advanceTimersByTimeAsync(3_000)
+ await expect(loadPendingHostCredentialCleanupIds()).resolves.toHaveLength(hostCount)
+ })
})
diff --git a/mobile/src/transport/host-credential-cleanup.ts b/mobile/src/transport/host-credential-cleanup.ts
index d64e37a98dc..c50c63bca33 100644
--- a/mobile/src/transport/host-credential-cleanup.ts
+++ b/mobile/src/transport/host-credential-cleanup.ts
@@ -1,7 +1,12 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
+import { parseMobileJsonTextWithinLimits } from './mobile-json-text-admission'
const PENDING_STORAGE_KEY = 'orca:pending-host-credential-cleanups'
const CLEANUP_CONFIRM_TIMEOUT_MS = 3_000
+export const HOST_CREDENTIAL_CLEANUP_MAX_PENDING_IDS = 256
+export const HOST_CREDENTIAL_CLEANUP_MAX_STORAGE_CHARACTERS = 256 * 1024
+export const HOST_CREDENTIAL_CLEANUP_MAX_HOST_ID_CHARACTERS = 4_096
+export const HOST_CREDENTIAL_CLEANUP_MAX_INFLIGHT_DELETES = 16
type DeleteHostCredential = (hostId: string) => Promise
type CleanupAttemptResult = 'cleared' | 'pending'
@@ -33,7 +38,11 @@ function notifyPendingListeners(): void {
}
function markUnrecordedPending(hostId: string): void {
- if (unrecordedPendingIds.has(hostId)) {
+ if (
+ unrecordedPendingIds.has(hostId) ||
+ !isTrackableHostId(hostId) ||
+ unrecordedPendingIds.size >= HOST_CREDENTIAL_CLEANUP_MAX_PENDING_IDS
+ ) {
return
}
unrecordedPendingIds.add(hostId)
@@ -47,17 +56,55 @@ function clearUnrecordedPending(hostId: string): void {
}
function parsePendingIds(raw: string): string[] | null {
+ if (raw.length > HOST_CREDENTIAL_CLEANUP_MAX_STORAGE_CHARACTERS) {
+ return null
+ }
try {
- const parsed = JSON.parse(raw) as unknown
+ const parsed = parseMobileJsonTextWithinLimits(raw)
if (!Array.isArray(parsed)) {
return null
}
- return [...new Set(parsed.filter((value): value is string => typeof value === 'string'))]
+ const ids: string[] = []
+ const seen = new Set()
+ for (const value of parsed) {
+ if (typeof value !== 'string') {
+ continue
+ }
+ if (!isTrackableHostId(value)) {
+ return null
+ }
+ if (!seen.has(value)) {
+ if (ids.length >= HOST_CREDENTIAL_CLEANUP_MAX_PENDING_IDS) {
+ return null
+ }
+ seen.add(value)
+ ids.push(value)
+ }
+ }
+ return ids
} catch {
return null
}
}
+function isTrackableHostId(hostId: string): boolean {
+ return hostId.length > 0 && hostId.length <= HOST_CREDENTIAL_CLEANUP_MAX_HOST_ID_CHARACTERS
+}
+
+function serializePendingIds(ids: string[]): string {
+ if (
+ ids.length > HOST_CREDENTIAL_CLEANUP_MAX_PENDING_IDS ||
+ ids.some((id) => !isTrackableHostId(id))
+ ) {
+ throw new Error('pending host credential cleanup limit exceeded')
+ }
+ const serialized = JSON.stringify(ids)
+ if (serialized.length > HOST_CREDENTIAL_CLEANUP_MAX_STORAGE_CHARACTERS) {
+ throw new Error('pending host credential cleanup storage limit exceeded')
+ }
+ return serialized
+}
+
function sameIdList(a: string[], b: string[]): boolean {
return a.length === b.length && a.every((id, index) => id === b[index])
}
@@ -103,7 +150,7 @@ async function mutatePendingIds(update: (ids: string[]) => string[]): Promise {})
@@ -142,6 +189,9 @@ function startOrJoinDelete(hostId: string, deleteCredential: DeleteHostCredentia
if (existing) {
return existing
}
+ if (inflightDeletes.size >= HOST_CREDENTIAL_CLEANUP_MAX_INFLIGHT_DELETES) {
+ return Promise.reject(new Error('host credential cleanup concurrency limit reached'))
+ }
const cleanup = Promise.resolve()
.then(() => deleteCredential(hostId))
.finally(() => {
@@ -154,6 +204,9 @@ function startOrJoinDelete(hostId: string, deleteCredential: DeleteHostCredentia
}
async function recordCleanupIntent(hostId: string): Promise {
+ if (!isTrackableHostId(hostId)) {
+ return false
+ }
try {
await addPendingId(hostId)
return true
@@ -234,10 +287,10 @@ export async function retryPendingHostCredentialCleanups(
deleteCredential: DeleteHostCredential
): Promise<{ clearedCount: number; remainingIds: string[]; storageUnreadable: boolean }> {
const pending = await loadPendingCleanupState()
- const outcomes = await Promise.all(
- // Why: these ids are already durable (or a session-scoped fallback). Re-adding
- // intent can race a late success and recreate a ghost row after deletion.
- pending.ids.map((id) => confirmNativeCleanup(id, deleteCredential, CLEANUP_CONFIRM_TIMEOUT_MS))
+ const outcomes = await retryCleanupIds(
+ pending.ids,
+ deleteCredential,
+ HOST_CREDENTIAL_CLEANUP_MAX_INFLIGHT_DELETES
)
const remaining = await loadPendingCleanupState()
return {
@@ -247,6 +300,29 @@ export async function retryPendingHostCredentialCleanups(
}
}
+async function retryCleanupIds(
+ ids: string[],
+ deleteCredential: DeleteHostCredential,
+ concurrency: number
+): Promise {
+ const outcomes: CleanupAttemptResult[] = []
+ let nextIndex = 0
+ const worker = async (): Promise => {
+ while (nextIndex < ids.length) {
+ const index = nextIndex++
+ // Why: these ids already have recovery intent; re-adding can race a late
+ // success and recreate a ghost row after deletion.
+ outcomes[index] = await confirmNativeCleanup(
+ ids[index]!,
+ deleteCredential,
+ CLEANUP_CONFIRM_TIMEOUT_MS
+ )
+ }
+ }
+ await Promise.all(Array.from({ length: Math.min(concurrency, ids.length) }, () => worker()))
+ return outcomes
+}
+
/** Test-only: drop module listeners/in-flight state between cases. */
export function resetHostCredentialCleanupForTests(): void {
inflightDeletes.clear()
diff --git a/mobile/src/transport/host-removal-lifecycle.test.ts b/mobile/src/transport/host-removal-lifecycle.test.ts
index e398062b270..d19b73673e8 100644
--- a/mobile/src/transport/host-removal-lifecycle.test.ts
+++ b/mobile/src/transport/host-removal-lifecycle.test.ts
@@ -1,16 +1,23 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
-const removeHostMock = vi.hoisted(() => vi.fn())
+const { removeHostMock, deleteConnectionLogMock } = vi.hoisted(() => ({
+ removeHostMock: vi.fn(),
+ deleteConnectionLogMock: vi.fn()
+}))
vi.mock('./host-store', () => ({
removeHost: (hostId: string) => removeHostMock(hostId)
}))
+vi.mock('./connection-log-buffer', () => ({
+ connectionLogStore: { delete: deleteConnectionLogMock }
+}))
import { removeHostAndCloseClient } from './host-removal-lifecycle'
describe('host removal lifecycle', () => {
beforeEach(() => {
removeHostMock.mockReset()
+ deleteConnectionLogMock.mockReset()
})
it('closes the client only after metadata removal commits', async () => {
@@ -28,6 +35,7 @@ describe('host removal lifecycle', () => {
await removal
expect(closeHostClient).toHaveBeenCalledWith('host-1')
+ expect(deleteConnectionLogMock).toHaveBeenCalledWith('host-1')
})
it('keeps the client open when metadata removal fails', async () => {
@@ -38,5 +46,18 @@ describe('host removal lifecycle', () => {
'storage unavailable'
)
expect(closeHostClient).not.toHaveBeenCalled()
+ expect(deleteConnectionLogMock).not.toHaveBeenCalled()
+ })
+
+ it('forgets removed-host logs even when client teardown throws', async () => {
+ removeHostMock.mockResolvedValue(undefined)
+ const closeHostClient = vi.fn(() => {
+ throw new Error('close failed')
+ })
+
+ await expect(removeHostAndCloseClient('host-1', closeHostClient)).rejects.toThrow(
+ 'close failed'
+ )
+ expect(deleteConnectionLogMock).toHaveBeenCalledWith('host-1')
})
})
diff --git a/mobile/src/transport/host-removal-lifecycle.ts b/mobile/src/transport/host-removal-lifecycle.ts
index ba599614804..8f3148744f9 100644
--- a/mobile/src/transport/host-removal-lifecycle.ts
+++ b/mobile/src/transport/host-removal-lifecycle.ts
@@ -1,4 +1,5 @@
import { removeHost } from './host-store'
+import { connectionLogStore } from './connection-log-buffer'
export async function removeHostAndCloseClient(
hostId: string,
@@ -7,5 +8,9 @@ export async function removeHostAndCloseClient(
// Why: closing before the metadata commit can strand a still-paired host on
// storage failure; closing immediately after success prevents socket leaks.
await removeHost(hostId)
- closeHostClient(hostId)
+ try {
+ closeHostClient(hostId)
+ } finally {
+ connectionLogStore.delete(hostId)
+ }
}
diff --git a/mobile/src/transport/host-store.test.ts b/mobile/src/transport/host-store.test.ts
index 918e6e92f93..a9eb268d349 100644
--- a/mobile/src/transport/host-store.test.ts
+++ b/mobile/src/transport/host-store.test.ts
@@ -34,6 +34,8 @@ vi.mock('./host-credential-cleanup', () => ({
}))
import {
+ HOST_TOKEN_CACHE_MAX_ENTRIES,
+ HOST_TOKEN_CACHE_MAX_ENTRY_CODE_UNITS,
loadHosts,
MobileRelayUpgradeHostRemovedError,
removeHost,
@@ -44,7 +46,18 @@ import {
updateHostNameAndEndpoint,
updateLastConnected
} from './host-store'
+import {
+ STORED_HOSTS_MAX_ENTRIES,
+ STORED_HOSTS_MAX_STORAGE_CHARACTERS
+} from './mobile-host-list-storage'
import { resetMobileRelayHostOverlayStoreForTests } from './mobile-relay-host-overlay-store'
+import {
+ MOBILE_HOST_ID_MAX_CHARACTERS,
+ MOBILE_HOST_NAME_MAX_CHARACTERS,
+ PAIRING_DEVICE_TOKEN_MAX_CHARACTERS,
+ PAIRING_ENDPOINT_MAX_CHARACTERS,
+ PAIRING_PUBLIC_KEY_MAX_CHARACTERS
+} from './types'
const HOSTS_STORAGE_KEY = 'orca:hosts'
const OVERLAY_STORAGE_KEY = 'orca:mobile-relay:host-overlays:v2'
@@ -222,6 +235,101 @@ describe('host-store list mutations', () => {
expect(hosts.some(({ id }) => id === 'removed-by-old-build')).toBe(false)
})
+ it('bounds cached keychain tokens without changing loaded hosts', async () => {
+ const storedHosts = Array.from({ length: HOST_TOKEN_CACHE_MAX_ENTRIES + 1 }, (_, index) => ({
+ ...HOST_ONE,
+ id: `host-${index}`,
+ publicKeyB64: `key-${index}`
+ }))
+ storedHostsRaw = JSON.stringify(storedHosts)
+ secureStoreMock.getItemAsync.mockImplementation(async (key: string) => `token-${key}`)
+
+ await expect(loadHosts()).resolves.toHaveLength(storedHosts.length)
+ expect(secureStoreMock.getItemAsync).toHaveBeenCalledTimes(storedHosts.length)
+
+ await expect(loadHosts()).resolves.toHaveLength(storedHosts.length)
+ expect(secureStoreMock.getItemAsync).toHaveBeenCalledTimes(storedHosts.length + 1)
+ })
+
+ it('does not retain an oversized token in the in-memory cache', async () => {
+ storedHostsRaw = JSON.stringify([HOST_ONE])
+ secureStoreMock.getItemAsync.mockResolvedValue(
+ 'x'.repeat(HOST_TOKEN_CACHE_MAX_ENTRY_CODE_UNITS)
+ )
+
+ await expect(loadHosts()).resolves.toHaveLength(1)
+ await expect(loadHosts()).resolves.toHaveLength(1)
+
+ expect(secureStoreMock.getItemAsync).toHaveBeenCalledTimes(2)
+ })
+
+ it('accepts the exact host count in order and rejects one more', async () => {
+ const exact = Array.from({ length: STORED_HOSTS_MAX_ENTRIES }, (_, index) => ({
+ ...HOST_ONE,
+ id: `host-${index}`,
+ publicKeyB64: `key-${index}`
+ }))
+ storedHostsRaw = JSON.stringify(exact)
+ secureStoreMock.getItemAsync.mockImplementation(async (key: string) => `token-${key}`)
+
+ await expect(loadHosts()).resolves.toMatchObject(
+ exact.map((host) => ({ id: host.id, publicKeyB64: host.publicKeyB64 }))
+ )
+
+ storedHostsRaw = JSON.stringify([
+ ...exact,
+ { ...HOST_ONE, id: 'host-overflow', publicKeyB64: 'key-overflow' }
+ ])
+ await expect(loadHosts()).resolves.toEqual([])
+ })
+
+ it('does not parse an oversized host-list payload', async () => {
+ storedHostsRaw = {
+ length: STORED_HOSTS_MAX_STORAGE_CHARACTERS + 1
+ } as unknown as string
+ const parse = vi.spyOn(JSON, 'parse')
+
+ await expect(loadHosts()).resolves.toEqual([])
+ expect(parse).not.toHaveBeenCalled()
+ expect(secureStoreMock.getItemAsync).not.toHaveBeenCalled()
+ parse.mockRestore()
+ })
+
+ it('accepts exact host field limits and rejects one character more', async () => {
+ const exact = {
+ id: 'i'.repeat(MOBILE_HOST_ID_MAX_CHARACTERS),
+ name: 'n'.repeat(MOBILE_HOST_NAME_MAX_CHARACTERS),
+ endpoint: 'e'.repeat(PAIRING_ENDPOINT_MAX_CHARACTERS),
+ deviceToken: 't'.repeat(PAIRING_DEVICE_TOKEN_MAX_CHARACTERS),
+ publicKeyB64: 'p'.repeat(PAIRING_PUBLIC_KEY_MAX_CHARACTERS),
+ lastConnected: 1
+ }
+
+ await expect(saveHost(exact)).resolves.toBeUndefined()
+ await expect(saveHost({ ...exact, name: `${exact.name}n` })).rejects.toThrow()
+ })
+
+ it('refuses a write that would exceed the stored host count', async () => {
+ storedHostsRaw = JSON.stringify(
+ Array.from({ length: STORED_HOSTS_MAX_ENTRIES }, (_, index) => ({
+ ...HOST_ONE,
+ id: `host-${index}`,
+ publicKeyB64: `key-${index}`
+ }))
+ )
+
+ await expect(
+ saveHost({
+ ...HOST_ONE,
+ id: 'host-overflow',
+ publicKeyB64: 'key-overflow',
+ deviceToken: 'token'
+ })
+ ).rejects.toThrow(/storage limit/)
+ expect(asyncStorageMock.setItem).not.toHaveBeenCalledWith(HOSTS_STORAGE_KEY, expect.any(String))
+ expect(secureStoreMock.setItemAsync).not.toHaveBeenCalled()
+ })
+
it('refuses to resurrect a removed host during relay upgrade publication', async () => {
storedHostsRaw = JSON.stringify([HOST_TWO])
diff --git a/mobile/src/transport/host-store.ts b/mobile/src/transport/host-store.ts
index 61ed6742d12..7cffbe2d260 100644
--- a/mobile/src/transport/host-store.ts
+++ b/mobile/src/transport/host-store.ts
@@ -3,7 +3,7 @@ import * as SecureStore from 'expo-secure-store'
import { Platform } from 'react-native'
import {
HostProfileSchema,
- StoredHostProfileSchema,
+ PAIRING_DEVICE_TOKEN_MAX_CHARACTERS,
type HostProfile,
type StoredHostProfile
} from './types'
@@ -21,6 +21,10 @@ import {
import { deleteMobileRelayCredentialBundle } from './mobile-relay-credential-bundle'
import { deleteMobileRelayDirectUpgradeJournal } from './mobile-relay-direct-upgrade-journal'
import { scheduleOrphanedMobileRelayCleanup } from './mobile-relay-orphan-cleanup'
+import {
+ parseMobileStoredHostList,
+ serializeMobileStoredHostList
+} from './mobile-host-list-storage'
const STORAGE_KEY = 'orca:hosts'
// Why: SecureStore keys must match [A-Za-z0-9._-] (colons rejected), so use dots as the separator.
@@ -43,10 +47,11 @@ function webTokenKey(hostId: string): string {
async function readDeviceToken(hostId: string): Promise {
// Why: Expo SecureStore has no working web backend; fall back to AsyncStorage only on web so native still uses the keychain.
- if (Platform.OS === 'web') {
- return AsyncStorage.getItem(webTokenKey(hostId))
- }
- return SecureStore.getItemAsync(tokenKey(hostId), KEYCHAIN_OPTIONS)
+ const token =
+ Platform.OS === 'web'
+ ? await AsyncStorage.getItem(webTokenKey(hostId))
+ : await SecureStore.getItemAsync(tokenKey(hostId), KEYCHAIN_OPTIONS)
+ return token !== null && token.length <= PAIRING_DEVICE_TOKEN_MAX_CHARACTERS ? token : null
}
async function writeDeviceToken(hostId: string, token: string): Promise {
@@ -72,31 +77,30 @@ async function deleteHostCredentials(hostId: string): Promise {
}
// Why: Keychain reads are slow (50-200ms) and loadHosts() runs on every screen mount; cache per-hostId in memory, invalidate on save/remove.
+export const HOST_TOKEN_CACHE_MAX_ENTRIES = 64
+export const HOST_TOKEN_CACHE_MAX_ENTRY_CODE_UNITS = 64 * 1024
const tokenCache = new Map()
let inflightLoad: Promise | null = null
// Why: serialize RMW of the shared hosts JSON; without a queue concurrent writers drop writes (resurrect a removed host, drop a rename).
let hostListMutation: Promise = Promise.resolve()
-function parseStoredHosts(raw: string | null): StoredHostProfile[] | null {
- if (!raw) {
- return []
+function rememberHostToken(hostId: string, token: string, allowEviction: boolean): void {
+ if (hostId.length + token.length > HOST_TOKEN_CACHE_MAX_ENTRY_CODE_UNITS) {
+ tokenCache.delete(hostId)
+ return
}
- try {
- const parsed = JSON.parse(raw) as unknown
- if (!Array.isArray(parsed)) {
- return null
+ if (tokenCache.has(hostId)) {
+ tokenCache.delete(hostId)
+ } else if (tokenCache.size >= HOST_TOKEN_CACHE_MAX_ENTRIES) {
+ if (!allowEviction) {
+ return
+ }
+ const oldestHostId = tokenCache.keys().next().value
+ if (oldestHostId !== undefined) {
+ tokenCache.delete(oldestHostId)
}
- return parsed.flatMap((item) => {
- // Why: pre-v0.0.3 records stored deviceToken in AsyncStorage; drop them (users re-pair) rather than carry a migration shim.
- if (item && typeof item === 'object' && 'deviceToken' in item) {
- return []
- }
- const result = StoredHostProfileSchema.safeParse(item)
- return result.success ? [result.data] : []
- })
- } catch {
- return null
}
+ tokenCache.set(hostId, token)
}
export async function loadHosts(): Promise {
@@ -114,7 +118,7 @@ export async function loadHosts(): Promise {
async function doLoadHosts(): Promise {
const raw = await AsyncStorage.getItem(STORAGE_KEY)
- const storedHosts = parseStoredHosts(raw)
+ const storedHosts = parseMobileStoredHostList(raw)
if (!storedHosts) {
return []
}
@@ -143,7 +147,7 @@ async function doLoadHosts(): Promise {
continue
}
token = fetched
- tokenCache.set(stored.id, token)
+ rememberHostToken(stored.id, token, false)
}
const overlay = overlays.get(stored.id)
out.push({
@@ -176,7 +180,7 @@ export async function resolvePairingHostIdentity(
async function readStoredHostsForMutation(): Promise {
try {
- const parsed = parseStoredHosts(await AsyncStorage.getItem(STORAGE_KEY))
+ const parsed = parseMobileStoredHostList(await AsyncStorage.getItem(STORAGE_KEY))
if (!parsed) {
// Why: refuse to RMW over unreadable payload — treating it as [] would wipe the durable host list on the next write.
throw new Error('host list storage unreadable')
@@ -196,7 +200,7 @@ async function mutateStoredHosts(
const mutation = hostListMutation.then(async () => {
const current = await readStoredHostsForMutation()
const next = update(current)
- await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(next))
+ await AsyncStorage.setItem(STORAGE_KEY, serializeMobileStoredHostList(next))
})
hostListMutation = mutation.catch(() => {})
return mutation
@@ -249,7 +253,7 @@ async function persistHost(host: HostProfile, requireExisting: boolean): Promise
})
// Why: write metadata before the keychain token so a crash leaves recoverable orphaned metadata, not an orphaned token that persists forever.
await writeDeviceToken(stored.id, validated.deviceToken)
- tokenCache.set(stored.id, validated.deviceToken)
+ rememberHostToken(stored.id, validated.deviceToken, true)
if (validated.endpoints) {
await saveMobileRelayHostOverlay({
v: 2,
diff --git a/mobile/src/transport/mobile-direct-rpc-outbound.test.ts b/mobile/src/transport/mobile-direct-rpc-outbound.test.ts
new file mode 100644
index 00000000000..ab69ca0a55c
--- /dev/null
+++ b/mobile/src/transport/mobile-direct-rpc-outbound.test.ts
@@ -0,0 +1,177 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { createMobileDirectRpcOutbound } from './mobile-direct-rpc-outbound'
+import { createMobileOutboundMemoryBudget } from './mobile-outbound-memory-budget'
+import { MOBILE_OUTBOUND_SOCKET_RETIRE_TIMEOUT_MS } from './mobile-outbound-socket-ledger'
+
+vi.mock('./e2ee', () => ({
+ encrypt: (plaintext: string) => `encrypted:${plaintext}`
+}))
+
+function socket() {
+ return {
+ OPEN: 1,
+ bufferedAmount: 0,
+ readyState: 1,
+ send: vi.fn()
+ }
+}
+
+describe('mobile direct RPC outbound queue', () => {
+ beforeEach(() => vi.useFakeTimers())
+ afterEach(() => vi.useRealTimers())
+
+ it('parks in FIFO order while another socket owns aggregate native buffer credit', () => {
+ const budget = createMobileOutboundMemoryBudget({
+ maxBufferedBytes: 100,
+ maxQueuedBytes: 1_000
+ })
+ const firstSocket = socket()
+ const secondSocket = socket()
+ firstSocket.bufferedAmount = 60
+ secondSocket.bufferedAmount = 41
+ const first = createMobileDirectRpcOutbound({
+ socket: firstSocket,
+ isActive: () => true,
+ onOverflow: vi.fn(),
+ memoryBudget: budget
+ })
+ const second = createMobileDirectRpcOutbound({
+ socket: secondSocket,
+ isActive: () => true,
+ onOverflow: vi.fn(),
+ memoryBudget: budget
+ })
+
+ expect(first.enqueue('one', new Uint8Array(32), 'rpc-1')).toBe(true)
+ expect(first.enqueue('two', new Uint8Array(32), 'rpc-2')).toBe(true)
+ expect(firstSocket.send).not.toHaveBeenCalled()
+ expect(budget.evidence().queuedClaimCount).toBe(2)
+
+ secondSocket.bufferedAmount = 0
+ firstSocket.bufferedAmount = 0
+ vi.advanceTimersByTime(25)
+
+ expect(firstSocket.send.mock.calls.map(([frame]) => frame)).toEqual(['encrypted:one'])
+ first.acknowledge('rpc-1')
+ vi.advanceTimersByTime(25)
+ expect(firstSocket.send.mock.calls.map(([frame]) => frame)).toEqual([
+ 'encrypted:one',
+ 'encrypted:two'
+ ])
+ first.acknowledge('rpc-2')
+ expect(budget.evidence()).toMatchObject({ queuedBytes: 0, queuedClaimCount: 0 })
+ first.dispose()
+ second.dispose()
+ expect(budget.evidence().bufferedSourceCount).toBe(2)
+ first.socketClosed()
+ second.socketClosed()
+ expect(budget.evidence().bufferedSourceCount).toBe(0)
+ })
+
+ it('fails the offending connection when aggregate JavaScript admission is exhausted', () => {
+ const budget = createMobileOutboundMemoryBudget({
+ maxBufferedBytes: 1,
+ maxQueuedBytes: 10
+ })
+ const targetSocket = socket()
+ targetSocket.bufferedAmount = 2
+ const onOverflow = vi.fn()
+ const outbound = createMobileDirectRpcOutbound({
+ socket: targetSocket,
+ isActive: () => true,
+ onOverflow,
+ memoryBudget: budget
+ })
+
+ expect(outbound.enqueue('payload', new Uint8Array(32))).toBe(false)
+
+ expect(onOverflow).toHaveBeenCalledOnce()
+ expect(targetSocket.send).not.toHaveBeenCalled()
+ expect(budget.evidence()).toMatchObject({
+ bufferedSourceCount: 1,
+ queuedBytes: 0,
+ queuedClaimCount: 0
+ })
+ outbound.dispose()
+ expect(budget.evidence().bufferedSourceCount).toBe(1)
+ outbound.socketClosed()
+ expect(budget.evidence().bufferedSourceCount).toBe(0)
+ })
+
+ it('uses matching RPC responses as backpressure when bufferedAmount is unavailable', () => {
+ const budget = createMobileOutboundMemoryBudget({
+ maxBufferedBytes: 100,
+ maxQueuedBytes: 1_000
+ })
+ const targetSocket = socket()
+ targetSocket.bufferedAmount = Number.NaN
+ const outbound = createMobileDirectRpcOutbound({
+ socket: targetSocket,
+ isActive: () => true,
+ onOverflow: vi.fn(),
+ memoryBudget: budget
+ })
+
+ expect(outbound.enqueue('one', new Uint8Array(32), 'rpc-1')).toBe(true)
+ expect(outbound.enqueue('two', new Uint8Array(32), 'rpc-2')).toBe(true)
+ expect(targetSocket.send.mock.calls.map(([frame]) => frame)).toEqual(['encrypted:one'])
+
+ outbound.acknowledge('rpc-1')
+ vi.advanceTimersByTime(25)
+ expect(targetSocket.send.mock.calls.map(([frame]) => frame)).toEqual([
+ 'encrypted:one',
+ 'encrypted:two'
+ ])
+ expect(budget.evidence()).toMatchObject({ inFlightBytes: 60, inFlightClaimCount: 1 })
+
+ outbound.socketClosed()
+ expect(budget.evidence()).toMatchObject({ bufferedBytes: 0, bufferedSourceCount: 0 })
+ })
+
+ it('reports synchronous native send failure instead of marking a stream as sent', () => {
+ const budget = createMobileOutboundMemoryBudget({ maxBufferedBytes: 100 })
+ const targetSocket = socket()
+ targetSocket.send.mockImplementation(() => {
+ throw new Error('native send failed')
+ })
+ const onOverflow = vi.fn()
+ const outbound = createMobileDirectRpcOutbound({
+ socket: targetSocket,
+ isActive: () => true,
+ onOverflow,
+ memoryBudget: budget
+ })
+
+ expect(outbound.enqueue('one', new Uint8Array(32), 'rpc-1')).toBe(false)
+ expect(onOverflow).toHaveBeenCalledOnce()
+ expect(budget.evidence()).toMatchObject({ inFlightBytes: 0, inFlightClaimCount: 0 })
+ outbound.socketClosed()
+ })
+
+ it('allows another socket after a retired socket never reports close', () => {
+ const budget = createMobileOutboundMemoryBudget({
+ maxBufferedBytes: 100,
+ maxBufferedSources: 1
+ })
+ const first = createMobileDirectRpcOutbound({
+ socket: socket(),
+ isActive: () => true,
+ onOverflow: vi.fn(),
+ memoryBudget: budget
+ })
+
+ first.dispose()
+ expect(budget.canRegisterBufferedAmount()).toBe(false)
+ vi.advanceTimersByTime(MOBILE_OUTBOUND_SOCKET_RETIRE_TIMEOUT_MS)
+ expect(budget.canRegisterBufferedAmount()).toBe(true)
+
+ const second = createMobileDirectRpcOutbound({
+ socket: socket(),
+ isActive: () => true,
+ onOverflow: vi.fn(),
+ memoryBudget: budget
+ })
+ second.socketClosed()
+ expect(budget.evidence().bufferedSourceCount).toBe(0)
+ })
+})
diff --git a/mobile/src/transport/mobile-direct-rpc-outbound.ts b/mobile/src/transport/mobile-direct-rpc-outbound.ts
new file mode 100644
index 00000000000..8a78c30d836
--- /dev/null
+++ b/mobile/src/transport/mobile-direct-rpc-outbound.ts
@@ -0,0 +1,126 @@
+import { createWsOutboundBackpressureQueue } from '../../../src/shared/ws-outbound-backpressure-queue'
+import { getUtf8ByteLength } from '../../../src/shared/utf8-byte-limits'
+import { encrypt } from './e2ee'
+import {
+ processMobileOutboundMemoryBudget,
+ MOBILE_OUTBOUND_MAX_FRAME_BYTES,
+ type MobileOutboundMemoryBudget
+} from './mobile-outbound-memory-budget'
+import { createMobileOutboundSocketLedger } from './mobile-outbound-socket-ledger'
+
+type DirectRpcOutboundFrame = {
+ acknowledgementKey?: string
+ key: Uint8Array
+ plaintext: string
+}
+
+type DirectRpcOutboundSocket = {
+ OPEN: number
+ bufferedAmount: number
+ readyState: number
+ send(frame: string): void
+}
+
+export type MobileDirectRpcOutbound = {
+ acknowledge(key: string): void
+ acknowledgeAuthentication(): void
+ dispose(): void
+ enqueue(plaintext: string, key: Uint8Array, acknowledgementKey?: string): boolean
+ socketClosed(): void
+}
+
+const AUTHENTICATION_ACKNOWLEDGEMENT_KEY = 'mobile-direct-authentication'
+
+export function createMobileDirectRpcOutbound(args: {
+ socket: DirectRpcOutboundSocket
+ isActive: () => boolean
+ onOverflow: () => void
+ memoryBudget?: MobileOutboundMemoryBudget
+}): MobileDirectRpcOutbound {
+ const memoryBudget = args.memoryBudget ?? processMobileOutboundMemoryBudget
+ let stopped = false
+ let disposed = false
+ let overflowed = false
+ const socketLedger = createMobileOutboundSocketLedger({
+ memoryBudget,
+ readBufferedAmount: () => args.socket.bufferedAmount
+ })
+ const failOverflow = (): void => {
+ if (overflowed) {
+ return
+ }
+ overflowed = true
+ stopped = true
+ args.onOverflow()
+ }
+ const queue = createWsOutboundBackpressureQueue({
+ send: (frame) => {
+ const bytes = encryptedTextFrameBytes(frame.plaintext)
+ const cancelClaim = socketLedger.claimSentBytes(bytes, frame.acknowledgementKey)
+ if (!cancelClaim) {
+ failOverflow()
+ return
+ }
+ try {
+ args.socket.send(encrypt(frame.plaintext, frame.key))
+ } catch {
+ cancelClaim()
+ failOverflow()
+ }
+ },
+ byteLengthOf: (frame) => encryptedTextFrameBytes(frame.plaintext),
+ getBufferedAmount: () => args.socket.bufferedAmount,
+ isWritable: () => args.isActive() && args.socket.readyState === args.socket.OPEN && !stopped,
+ canSend: (bytes) => socketLedger.canSend(bytes),
+ claimQueuedBytes: (bytes) => memoryBudget.claimQueuedBytes(bytes),
+ maxFrameBytes: MOBILE_OUTBOUND_MAX_FRAME_BYTES,
+ onOverflow: failOverflow
+ })
+
+ return {
+ acknowledge: (key) => socketLedger.acknowledge(key),
+ acknowledgeAuthentication: () => socketLedger.acknowledge(AUTHENTICATION_ACKNOWLEDGEMENT_KEY),
+ dispose(): void {
+ if (disposed) {
+ return
+ }
+ disposed = true
+ stopped = true
+ queue.dispose()
+ socketLedger.retire()
+ },
+ enqueue(plaintext, key, acknowledgementKey): boolean {
+ if (stopped || !args.isActive() || args.socket.readyState !== args.socket.OPEN) {
+ return false
+ }
+ const accepted = queue.enqueue({
+ plaintext,
+ key,
+ acknowledgementKey:
+ acknowledgementKey ??
+ (isAuthenticationRequest(plaintext) ? AUTHENTICATION_ACKNOWLEDGEMENT_KEY : undefined)
+ })
+ return accepted && !overflowed
+ },
+ socketClosed(): void {
+ stopped = true
+ queue.dispose()
+ socketLedger.socketClosed()
+ }
+ }
+}
+
+function encryptedTextFrameBytes(plaintext: string): number {
+ const encryptedBytes = getUtf8ByteLength(plaintext) + 40
+ const wireBytes = Math.ceil(encryptedBytes / 3) * 4
+ return Math.max(wireBytes, plaintext.length * 2)
+}
+
+function isAuthenticationRequest(plaintext: string): boolean {
+ try {
+ const value = JSON.parse(plaintext) as { type?: unknown }
+ return value.type === 'e2ee_auth'
+ } catch {
+ return false
+ }
+}
diff --git a/mobile/src/transport/mobile-direct-rpc-sender.ts b/mobile/src/transport/mobile-direct-rpc-sender.ts
new file mode 100644
index 00000000000..d2e9c4ca672
--- /dev/null
+++ b/mobile/src/transport/mobile-direct-rpc-sender.ts
@@ -0,0 +1,51 @@
+import type { MobileDirectRpcOutbound } from './mobile-direct-rpc-outbound'
+import { stringifyMobileOutboundJson } from './mobile-outbound-json'
+import type { ConnectionState } from './types'
+
+export function createMobileDirectRpcSender(args: {
+ getOutbound: () => MobileDirectRpcOutbound | null
+ getSharedKey: () => Uint8Array | null
+ getSocket: () => WebSocket | null
+ getState: () => ConnectionState
+ onSocketDesync: (socket: WebSocket) => void
+}): (request: unknown) => boolean {
+ return (request): boolean => {
+ const socket = args.getSocket()
+ const sharedKey = args.getSharedKey()
+ const outbound = args.getOutbound()
+ if (socket && socket.readyState === WebSocket.OPEN && sharedKey && outbound) {
+ try {
+ return outbound.enqueue(
+ stringifyMobileOutboundJson(request),
+ sharedKey,
+ requestAcknowledgementKey(request)
+ )
+ } catch (error) {
+ console.warn('[net] outbound request rejected', error)
+ return false
+ }
+ }
+ const state = args.getState()
+ console.log('[net] sendEncrypted FAILED — channel not ready', {
+ hasWs: !!socket,
+ readyState: socket?.readyState,
+ hasKey: !!sharedKey,
+ state
+ })
+ if (state === 'connected' && socket && socket.readyState !== WebSocket.OPEN) {
+ console.log('[net] sendEncrypted detected ws desync — forcing reconnect', {
+ readyState: socket.readyState
+ })
+ args.onSocketDesync(socket)
+ }
+ return false
+ }
+}
+
+function requestAcknowledgementKey(request: unknown): string | undefined {
+ if (!request || typeof request !== 'object') {
+ return undefined
+ }
+ const id = (request as { id?: unknown }).id
+ return typeof id === 'string' ? id : undefined
+}
diff --git a/mobile/src/transport/mobile-e2ee-legacy-fixtures.test.ts b/mobile/src/transport/mobile-e2ee-legacy-fixtures.test.ts
index 7e0628960e1..aea6f3d7a55 100644
--- a/mobile/src/transport/mobile-e2ee-legacy-fixtures.test.ts
+++ b/mobile/src/transport/mobile-e2ee-legacy-fixtures.test.ts
@@ -6,7 +6,13 @@ vi.mock('expo-crypto', () => ({
getRandomBytes: (length: number) => new Uint8Array(length).fill(9)
}))
-import { decrypt, decryptBytes, deriveSharedKey } from './e2ee'
+import {
+ MOBILE_E2EE_MAX_FRAME_BASE64_CHARACTERS,
+ decrypt,
+ decryptBytes,
+ deriveSharedKey,
+ publicKeyFromBase64
+} from './e2ee'
describe('mobile legacy E2EE fixtures', () => {
it('matches the captured desktop key and text/binary frames', () => {
@@ -19,6 +25,17 @@ describe('mobile legacy E2EE fixtures', () => {
expect(decrypt(fixture.authFrameB64, shared)).toBe(fixture.authPlaintext)
expect(decryptBytes(fromHex(fixture.binaryFrameHex), shared)).toEqual(fixture.binaryPlaintext)
})
+
+ it('rejects oversized base64 before calling the decoder', () => {
+ const decode = vi.spyOn(globalThis, 'atob')
+ const oversizedFrame = {
+ length: MOBILE_E2EE_MAX_FRAME_BASE64_CHARACTERS + 1
+ } as unknown as string
+
+ expect(() => publicKeyFromBase64('A'.repeat(45))).toThrow(/safe size/)
+ expect(() => decrypt(oversizedFrame, new Uint8Array(32))).toThrow(/safe size/)
+ expect(decode).not.toHaveBeenCalled()
+ })
})
function hex(bytes: Uint8Array): string {
diff --git a/mobile/src/transport/mobile-e2ee-v2-client-session.test.ts b/mobile/src/transport/mobile-e2ee-v2-client-session.test.ts
index 6cff0ef3523..3cccc28260c 100644
--- a/mobile/src/transport/mobile-e2ee-v2-client-session.test.ts
+++ b/mobile/src/transport/mobile-e2ee-v2-client-session.test.ts
@@ -12,8 +12,12 @@ vi.mock('expo-crypto', () => ({
}))
import { deriveSharedKey } from './e2ee'
-import { MobileE2EEV2ClientSession } from './mobile-e2ee-v2-client-session'
+import {
+ MOBILE_E2EE_V2_MAX_TEXT_FRAME_BASE64_CHARACTERS,
+ MobileE2EEV2ClientSession
+} from './mobile-e2ee-v2-client-session'
import { deriveMobileE2EEV2KeySchedule } from './mobile-e2ee-v2-key-schedule'
+import { MOBILE_INBOUND_MAX_FRAME_BYTES } from './mobile-inbound-frame-queue'
const desktop = nacl.box.keyPair.fromSecretKey(new Uint8Array(32).fill(1))
const client = nacl.box.keyPair.fromSecretKey(new Uint8Array(32).fill(2))
@@ -82,4 +86,19 @@ describe('mobile E2EE v2 client session', () => {
expect(session.openText(encoded)).toBe('authenticated')
expect(session.openText(encoded)).toBeNull()
})
+
+ it('rejects oversized text and binary frames before materialization', () => {
+ const { session } = setup()
+ const decode = vi.spyOn(globalThis, 'atob')
+ const oversizedText = {
+ length: MOBILE_E2EE_V2_MAX_TEXT_FRAME_BASE64_CHARACTERS + 1
+ } as unknown as string
+ const oversizedBinary = {
+ byteLength: MOBILE_INBOUND_MAX_FRAME_BYTES + 1
+ } as unknown as Uint8Array
+
+ expect(session.openText(oversizedText)).toBeNull()
+ expect(session.openBinary(oversizedBinary)).toBeNull()
+ expect(decode).not.toHaveBeenCalled()
+ })
})
diff --git a/mobile/src/transport/mobile-e2ee-v2-client-session.ts b/mobile/src/transport/mobile-e2ee-v2-client-session.ts
index 1cce2946860..80246607a8c 100644
--- a/mobile/src/transport/mobile-e2ee-v2-client-session.ts
+++ b/mobile/src/transport/mobile-e2ee-v2-client-session.ts
@@ -11,6 +11,10 @@ import {
} from '../../../src/shared/mobile-e2ee-v2-framing'
import { deriveSharedKey, generateKeyPair, publicKeyFromBase64, publicKeyToBase64 } from './e2ee'
import { deriveMobileE2EEV2KeySchedule } from './mobile-e2ee-v2-key-schedule'
+import { MOBILE_INBOUND_MAX_FRAME_BYTES } from './mobile-inbound-frame-queue'
+
+export const MOBILE_E2EE_V2_MAX_TEXT_FRAME_BASE64_CHARACTERS =
+ Math.ceil(MOBILE_INBOUND_MAX_FRAME_BYTES / 3) * 4
export class MobileE2EEV2ClientSession {
readonly hello: MobileE2EEV2Hello
@@ -91,6 +95,9 @@ export class MobileE2EEV2ClientSession {
}
openBinary(frame: Uint8Array): Uint8Array | null {
+ if (frame.byteLength > MOBILE_INBOUND_MAX_FRAME_BYTES) {
+ return null
+ }
return this.open(frame, 'binary')
}
@@ -146,8 +153,14 @@ function encodeBase64(bytes: Uint8Array): string {
}
function decodeCanonicalBase64(value: string): Uint8Array | null {
+ if (value.length > MOBILE_E2EE_V2_MAX_TEXT_FRAME_BASE64_CHARACTERS) {
+ return null
+ }
try {
const binary = atob(value)
+ if (binary.length > MOBILE_INBOUND_MAX_FRAME_BYTES) {
+ return null
+ }
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0))
return encodeBase64(bytes) === value ? bytes : null
} catch {
diff --git a/mobile/src/transport/mobile-e2ee-v2-physical-channel.test.ts b/mobile/src/transport/mobile-e2ee-v2-physical-channel.test.ts
index a7aae9e6d0f..064b117e168 100644
--- a/mobile/src/transport/mobile-e2ee-v2-physical-channel.test.ts
+++ b/mobile/src/transport/mobile-e2ee-v2-physical-channel.test.ts
@@ -22,11 +22,16 @@ import {
MobileE2EEV2PhysicalChannel,
type MobileE2EEV2Socket
} from './mobile-e2ee-v2-physical-channel'
+import { createMobileOutboundMemoryBudget } from './mobile-outbound-memory-budget'
+import { MOBILE_OUTBOUND_MAX_JSON_BYTES } from './mobile-outbound-json'
const desktop = nacl.box.keyPair.fromSecretKey(new Uint8Array(32).fill(1))
const client = nacl.box.keyPair.fromSecretKey(new Uint8Array(32).fill(2))
-function setup(decodeBinary: (raw: unknown) => Promise) {
+function setup(
+ decodeBinary: (raw: unknown) => Promise,
+ deviceToken = 'valid-token'
+) {
const session = MobileE2EEV2ClientSession.create({
desktopPublicKeyB64: Buffer.from(desktop.publicKey).toString('base64'),
transport: 'relay',
@@ -44,15 +49,17 @@ function setup(decodeBinary: (raw: unknown) => Promise) {
const events: string[] = []
const onAuthenticated = vi.fn(() => events.push('authenticated'))
const onError = vi.fn()
+ const outboundMemoryBudget = createMobileOutboundMemoryBudget()
const channel = new MobileE2EEV2PhysicalChannel({
session,
socket,
- deviceToken: 'valid-token',
+ deviceToken,
decodeBinary,
onAuthenticated,
onText: (plaintext) => events.push(`text:${plaintext}`),
onBinary: (plaintext) => events.push(`binary:${plaintext[0]}`),
- onError
+ onError,
+ outboundMemoryBudget
})
channel.start()
@@ -72,7 +79,18 @@ function setup(decodeBinary: (raw: unknown) => Promise) {
clientNonce: handshake.clientNonce,
desktopNonce: handshake.desktopNonce
})
- return { channel, session, socket, sent, events, onAuthenticated, onError, ready, schedule }
+ return {
+ channel,
+ session,
+ socket,
+ sent,
+ events,
+ onAuthenticated,
+ onError,
+ outboundMemoryBudget,
+ ready,
+ schedule
+ }
}
function serverFrame(
@@ -122,6 +140,17 @@ describe('mobile E2EE v2 physical channel', () => {
expect(ctx.onError).not.toHaveBeenCalled()
})
+ it('rejects ready JSON above the nesting cap before object materialization', async () => {
+ const ctx = setup(async () => null)
+
+ await ctx.channel.handleMessage(`${'['.repeat(129)}0${']'.repeat(129)}`)
+
+ expect(ctx.onError).toHaveBeenCalledWith(
+ expect.objectContaining({ message: 'Invalid E2EE v2 ready JSON' })
+ )
+ expect(ctx.sent).toHaveLength(1)
+ })
+
it('classifies the encrypted desktop device-token rejection as global auth failure', async () => {
const ctx = setup(async () => null)
await ctx.channel.handleMessage(JSON.stringify(ctx.ready))
@@ -140,6 +169,18 @@ describe('mobile E2EE v2 physical channel', () => {
expect(ctx.onAuthenticated).not.toHaveBeenCalled()
})
+ it('rejects oversized authentication JSON before sealing or sending it', async () => {
+ const ctx = setup(async () => null, 'x'.repeat(MOBILE_OUTBOUND_MAX_JSON_BYTES))
+
+ await ctx.channel.handleMessage(JSON.stringify(ctx.ready))
+
+ expect(ctx.sent).toHaveLength(1)
+ expect(ctx.onError).toHaveBeenCalledWith(
+ expect.objectContaining({ message: expect.stringContaining('Mobile outbound JSON exceeds') })
+ )
+ ctx.channel.socketClosed()
+ })
+
it('serializes delayed binary conversion before a later text counter', async () => {
let releaseBinary!: (bytes: Uint8Array) => void
const pendingBinary = new Promise((resolve) => (releaseBinary = resolve))
@@ -160,6 +201,27 @@ describe('mobile E2EE v2 physical channel', () => {
expect(ctx.onError).not.toHaveBeenCalled()
})
+ it('fails closed when delayed inbound conversion lets frames exceed the backlog bound', async () => {
+ let releaseBinary!: (bytes: Uint8Array) => void
+ const pendingBinary = new Promise((resolve) => (releaseBinary = resolve))
+ const ctx = setup(async () => pendingBinary)
+ await authenticate(ctx)
+ const binary = serverFrame(new Uint8Array([7]), 'binary', 1n, ctx.schedule)
+ const pending = Array.from({ length: 65 }, () =>
+ ctx.channel.handleMessage({ delayedBlob: true })
+ )
+
+ await Promise.resolve()
+ try {
+ expect(ctx.onError).toHaveBeenCalledOnce()
+ expect(ctx.onError.mock.calls[0]![0].message).toBe('E2EE v2 inbound buffer overflow')
+ } finally {
+ ctx.channel.dispose()
+ releaseBinary(binary)
+ await Promise.all(pending)
+ }
+ })
+
it('queues outbound text and binary in one counter order', async () => {
const ctx = setup(async () => null)
await authenticate(ctx)
@@ -194,15 +256,61 @@ describe('mobile E2EE v2 physical channel', () => {
).toEqual(new Uint8Array([2]))
})
+ it('releases sent-frame reservations only after the matching response arrives', async () => {
+ const ctx = setup(async () => null)
+ await authenticate(ctx)
+ expect(ctx.outboundMemoryBudget.evidence().inFlightBytes).toBe(0)
+
+ expect(ctx.channel.sendText(JSON.stringify({ id: 'rpc-1', method: 'status.get' }))).toBe(true)
+ expect(ctx.outboundMemoryBudget.evidence().inFlightClaimCount).toBe(1)
+
+ const response = serverFrame(
+ new TextEncoder().encode(JSON.stringify({ id: 'rpc-1', ok: true, result: {} })),
+ 'text',
+ 1n,
+ ctx.schedule
+ )
+ await ctx.channel.handleMessage(Buffer.from(response).toString('base64'))
+
+ expect(ctx.outboundMemoryBudget.evidence()).toMatchObject({
+ inFlightBytes: 0,
+ inFlightClaimCount: 0
+ })
+ ctx.channel.socketClosed()
+ })
+
+ it('reports synchronous native send failure to relay callers', async () => {
+ const ctx = setup(async () => null)
+ await authenticate(ctx)
+ ctx.socket.send = () => {
+ throw new Error('native send failed')
+ }
+
+ expect(ctx.channel.sendText(JSON.stringify({ id: 'rpc-1' }))).toBe(false)
+ expect(ctx.onError).toHaveBeenCalledWith(
+ expect.objectContaining({ message: 'E2EE v2 outbound buffer overflow' })
+ )
+ expect(ctx.outboundMemoryBudget.evidence()).toMatchObject({
+ inFlightBytes: 0,
+ inFlightClaimCount: 0
+ })
+ ctx.channel.socketClosed()
+ })
+
it('bounds the unified outbound queue and reports a wedged link', async () => {
const ctx = setup(async () => null)
await authenticate(ctx)
ctx.socket.bufferedAmount = 9 * 1024 * 1024
const megabyte = new Uint8Array(1024 * 1024)
for (let index = 0; index < 65; index++) {
- expect(ctx.channel.sendBinary(megabyte)).toBe(true)
+ ctx.channel.sendBinary(megabyte)
}
expect(ctx.onError).toHaveBeenCalledOnce()
expect(ctx.onError.mock.calls[0]![0].message).toBe('E2EE v2 outbound buffer overflow')
+ expect(ctx.outboundMemoryBudget.evidence().bufferedSourceCount).toBe(1)
+ ctx.channel.dispose()
+ expect(ctx.outboundMemoryBudget.evidence().bufferedSourceCount).toBe(1)
+ ctx.channel.socketClosed()
+ expect(ctx.outboundMemoryBudget.evidence().bufferedSourceCount).toBe(0)
})
})
diff --git a/mobile/src/transport/mobile-e2ee-v2-physical-channel.ts b/mobile/src/transport/mobile-e2ee-v2-physical-channel.ts
index ec3b8088915..54ac0e398d0 100644
--- a/mobile/src/transport/mobile-e2ee-v2-physical-channel.ts
+++ b/mobile/src/transport/mobile-e2ee-v2-physical-channel.ts
@@ -2,10 +2,28 @@ import {
createWsOutboundBackpressureQueue,
type WsOutboundBackpressureQueue
} from '../../../src/shared/ws-outbound-backpressure-queue'
+import { getUtf8ByteLength } from '../../../src/shared/utf8-byte-limits'
import type { MobileE2EEV2ClientSession } from './mobile-e2ee-v2-client-session'
+import {
+ createMobileInboundFrameQueue,
+ MOBILE_INBOUND_MAX_FRAME_BYTES,
+ type MobileInboundFrameQueue
+} from './mobile-inbound-frame-queue'
+import {
+ processMobileOutboundMemoryBudget,
+ MOBILE_OUTBOUND_MAX_FRAME_BYTES,
+ type MobileOutboundMemoryBudget
+} from './mobile-outbound-memory-budget'
+import { stringifyMobileOutboundJson } from './mobile-outbound-json'
+import { createMobileOutboundSocketLedger } from './mobile-outbound-socket-ledger'
+import { parseMobileJsonTextWithinLimits } from './mobile-json-text-admission'
type ChannelState = 'awaiting-ready' | 'awaiting-authenticated' | 'ready'
-type OutboundItem = { kind: 'text'; plaintext: string } | { kind: 'binary'; plaintext: Uint8Array }
+type OutboundItem =
+ | { kind: 'text'; plaintext: string; acknowledgementKey?: string }
+ | { kind: 'binary'; plaintext: Uint8Array }
+const E2EE_V2_FRAME_OVERHEAD_BYTES = 82
+const AUTHENTICATION_ACKNOWLEDGEMENT_KEY = 'mobile-e2ee-v2-authentication'
export class MobileE2EEAuthenticationError extends Error {
constructor() {
@@ -23,8 +41,10 @@ export type MobileE2EEV2Socket = {
export class MobileE2EEV2PhysicalChannel {
private state: ChannelState = 'awaiting-ready'
private generation = 0
- private inboundChain: Promise = Promise.resolve()
+ private readonly inboundQueue: MobileInboundFrameQueue
private readonly outboundQueue: WsOutboundBackpressureQueue
+ private readonly outboundSocketLedger
+ private outboundOverflowed = false
constructor(
private readonly args: {
@@ -36,25 +56,50 @@ export class MobileE2EEV2PhysicalChannel {
onText: (plaintext: string) => void
onBinary: (plaintext: Uint8Array) => void
onError: (error: Error) => void
+ outboundMemoryBudget?: MobileOutboundMemoryBudget
}
) {
+ this.inboundQueue = createMobileInboundFrameQueue({
+ process: (raw) => this.processMessage(raw, this.generation),
+ onError: args.onError,
+ overflowMessage: 'E2EE v2 inbound buffer overflow',
+ frameTooLargeMessage: 'E2EE v2 inbound frame too large'
+ })
+ const outboundMemoryBudget = args.outboundMemoryBudget ?? processMobileOutboundMemoryBudget
+ this.outboundSocketLedger = createMobileOutboundSocketLedger({
+ memoryBudget: outboundMemoryBudget,
+ readBufferedAmount: () => args.socket.bufferedAmount
+ })
this.outboundQueue = createWsOutboundBackpressureQueue({
// Why: encryption happens only when an admitted item reaches the wire,
// so a bounded-queue rejection cannot burn an ordered v2 counter.
send: (item) => {
- args.socket.send(
- item.kind === 'text'
- ? args.session.sealText(item.plaintext)
- : args.session.sealBinary(item.plaintext)
+ const cancelClaim = this.outboundSocketLedger.claimSentBytes(
+ outboundItemRetainedBytes(item),
+ item.kind === 'text' ? item.acknowledgementKey : undefined
)
+ if (!cancelClaim) {
+ this.failOutboundOverflow()
+ return
+ }
+ try {
+ args.socket.send(
+ item.kind === 'text'
+ ? args.session.sealText(item.plaintext)
+ : args.session.sealBinary(item.plaintext)
+ )
+ } catch {
+ cancelClaim()
+ this.failOutboundOverflow()
+ }
},
- byteLengthOf: (item) =>
- (item.kind === 'text'
- ? new TextEncoder().encode(item.plaintext).length
- : item.plaintext.length) + 82,
+ byteLengthOf: outboundItemRetainedBytes,
getBufferedAmount: () => args.socket.bufferedAmount,
isWritable: () => args.socket.readyState === args.socket.OPEN,
- onOverflow: () => args.onError(new Error('E2EE v2 outbound buffer overflow'))
+ canSend: (bytes) => this.outboundSocketLedger.canSend(bytes),
+ claimQueuedBytes: (bytes) => outboundMemoryBudget.claimQueuedBytes(bytes),
+ maxFrameBytes: MOBILE_OUTBOUND_MAX_FRAME_BYTES,
+ onOverflow: () => this.failOutboundOverflow()
})
}
@@ -63,19 +108,15 @@ export class MobileE2EEV2PhysicalChannel {
}
handleMessage(raw: unknown): Promise {
- const generation = this.generation
- this.inboundChain = this.inboundChain
- .then(() => this.processMessage(raw, generation))
- .catch((error: unknown) => {
- if (generation === this.generation) {
- this.args.onError(error instanceof Error ? error : new Error(String(error)))
- }
- })
- return this.inboundChain
+ return this.inboundQueue.enqueue(raw)
}
sendText(plaintext: string): boolean {
- return this.enqueueReady({ kind: 'text', plaintext })
+ return this.enqueueReady({
+ kind: 'text',
+ plaintext,
+ acknowledgementKey: outboundTextAcknowledgementKey(plaintext)
+ })
}
sendBinary(plaintext: Uint8Array): boolean {
@@ -84,7 +125,14 @@ export class MobileE2EEV2PhysicalChannel {
dispose(): void {
this.generation++
+ this.inboundQueue.dispose()
this.outboundQueue.dispose()
+ this.outboundSocketLedger.retire()
+ }
+
+ socketClosed(): void {
+ this.dispose()
+ this.outboundSocketLedger.socketClosed()
}
private async processMessage(raw: unknown, generation: number): Promise {
@@ -104,6 +152,7 @@ export class MobileE2EEV2PhysicalChannel {
return
}
if (this.state === 'awaiting-authenticated') {
+ this.outboundSocketLedger.acknowledge(AUTHENTICATION_ACKNOWLEDGEMENT_KEY)
if (typeof plaintext === 'string' && isAuthenticationRejection(plaintext)) {
throw new MobileE2EEAuthenticationError()
}
@@ -113,6 +162,10 @@ export class MobileE2EEV2PhysicalChannel {
this.state = 'ready'
this.args.onAuthenticated()
} else if (typeof plaintext === 'string') {
+ const acknowledgementKey = outboundTextAcknowledgementKey(plaintext)
+ if (acknowledgementKey) {
+ this.outboundSocketLedger.acknowledge(acknowledgementKey)
+ }
this.args.onText(plaintext)
} else {
this.args.onBinary(plaintext)
@@ -125,7 +178,7 @@ export class MobileE2EEV2PhysicalChannel {
}
let ready: unknown
try {
- ready = JSON.parse(raw)
+ ready = parseMobileJsonTextWithinLimits(raw)
} catch {
throw new Error('Invalid E2EE v2 ready JSON')
}
@@ -135,7 +188,8 @@ export class MobileE2EEV2PhysicalChannel {
this.state = 'awaiting-authenticated'
this.outboundQueue.enqueue({
kind: 'text',
- plaintext: JSON.stringify({
+ acknowledgementKey: AUTHENTICATION_ACKNOWLEDGEMENT_KEY,
+ plaintext: stringifyMobileOutboundJson({
type: 'e2ee_auth',
v: 2,
transcriptHashB64: this.args.session.transcriptHashB64,
@@ -149,12 +203,15 @@ export class MobileE2EEV2PhysicalChannel {
if (!bytes || generation !== this.generation) {
return null
}
+ if (bytes.byteLength > MOBILE_INBOUND_MAX_FRAME_BYTES) {
+ throw new Error('E2EE v2 inbound frame too large')
+ }
return this.args.session.openBinary(bytes)
}
private isAuthenticated(plaintext: string): boolean {
try {
- const message = JSON.parse(plaintext) as Record
+ const message = parseMobileJsonTextWithinLimits>(plaintext)
return (
Object.keys(message).sort().join(',') === 'transcriptHashB64,type,v' &&
message.type === 'e2ee_authenticated' &&
@@ -170,16 +227,41 @@ export class MobileE2EEV2PhysicalChannel {
if (this.state !== 'ready') {
return false
}
- this.outboundQueue.enqueue(item)
- return true
+ return this.outboundQueue.enqueue(item) && !this.outboundOverflowed
+ }
+
+ private failOutboundOverflow(): void {
+ if (this.outboundOverflowed) {
+ return
+ }
+ this.outboundOverflowed = true
+ this.args.onError(new Error('E2EE v2 outbound buffer overflow'))
}
}
function isAuthenticationRejection(plaintext: string): boolean {
try {
- const message = JSON.parse(plaintext) as Record
+ const message = parseMobileJsonTextWithinLimits>(plaintext)
return message.type === 'e2ee_error'
} catch {
return false
}
}
+
+function outboundItemRetainedBytes(item: OutboundItem): number {
+ if (item.kind === 'binary') {
+ return item.plaintext.byteLength + E2EE_V2_FRAME_OVERHEAD_BYTES
+ }
+ const encryptedBytes = getUtf8ByteLength(item.plaintext) + E2EE_V2_FRAME_OVERHEAD_BYTES
+ const wireBytes = Math.ceil(encryptedBytes / 3) * 4
+ return Math.max(wireBytes, item.plaintext.length * 2)
+}
+
+function outboundTextAcknowledgementKey(plaintext: string): string | undefined {
+ try {
+ const value = parseMobileJsonTextWithinLimits<{ id?: unknown }>(plaintext)
+ return typeof value.id === 'string' ? value.id : undefined
+ } catch {
+ return undefined
+ }
+}
diff --git a/mobile/src/transport/mobile-host-list-storage.ts b/mobile/src/transport/mobile-host-list-storage.ts
new file mode 100644
index 00000000000..76ccd6fddd2
--- /dev/null
+++ b/mobile/src/transport/mobile-host-list-storage.ts
@@ -0,0 +1,43 @@
+import { StoredHostProfileSchema, type StoredHostProfile } from './types'
+import { parseMobileJsonTextWithinLimits } from './mobile-json-text-admission'
+
+export const STORED_HOSTS_MAX_ENTRIES = 256
+export const STORED_HOSTS_MAX_STORAGE_CHARACTERS = 8 * 1024 * 1024
+
+export function parseMobileStoredHostList(raw: string | null): StoredHostProfile[] | null {
+ if (!raw) {
+ return []
+ }
+ if (raw.length > STORED_HOSTS_MAX_STORAGE_CHARACTERS) {
+ return null
+ }
+ try {
+ const parsed = parseMobileJsonTextWithinLimits(raw)
+ if (!Array.isArray(parsed) || parsed.length > STORED_HOSTS_MAX_ENTRIES) {
+ return null
+ }
+ return parsed.flatMap((item) => {
+ // Why: pre-v0.0.3 records stored deviceToken in AsyncStorage; dropping
+ // them keeps bearer material out of the metadata migration path.
+ if (item && typeof item === 'object' && 'deviceToken' in item) {
+ return []
+ }
+ const result = StoredHostProfileSchema.safeParse(item)
+ return result.success ? [result.data] : []
+ })
+ } catch {
+ return null
+ }
+}
+
+export function serializeMobileStoredHostList(hosts: StoredHostProfile[]): string {
+ if (hosts.length > STORED_HOSTS_MAX_ENTRIES) {
+ throw new Error('host list storage limit exceeded')
+ }
+ const validated = hosts.map((host) => StoredHostProfileSchema.parse(host))
+ const serialized = JSON.stringify(validated)
+ if (serialized.length > STORED_HOSTS_MAX_STORAGE_CHARACTERS) {
+ throw new Error('host list storage limit exceeded')
+ }
+ return serialized
+}
diff --git a/mobile/src/transport/mobile-inbound-frame-queue-memory.test.ts b/mobile/src/transport/mobile-inbound-frame-queue-memory.test.ts
new file mode 100644
index 00000000000..fe722ae8359
--- /dev/null
+++ b/mobile/src/transport/mobile-inbound-frame-queue-memory.test.ts
@@ -0,0 +1,154 @@
+import { describe, expect, it, vi } from 'vitest'
+import { createMobileInboundFrameQueue } from './mobile-inbound-frame-queue'
+import { createMobileInboundMemoryLedger } from './mobile-inbound-memory-ledger'
+
+function expectEmpty(ledger: ReturnType): void {
+ expect(ledger.evidence()).toMatchObject({ claimCount: 0, retainedBytes: 0 })
+}
+
+describe('mobile inbound frame queue aggregate memory', () => {
+ it('releases a claim after synchronous processing succeeds', async () => {
+ const ledger = createMobileInboundMemoryLedger(10)
+ const queue = createMobileInboundFrameQueue({
+ process: vi.fn(),
+ onError: vi.fn(),
+ overflowMessage: 'overflow',
+ frameTooLargeMessage: 'too large',
+ memoryLedger: ledger
+ })
+
+ await queue.enqueue(new Uint8Array(3))
+
+ expectEmpty(ledger)
+ })
+
+ it('releases a claim after synchronous processing throws', async () => {
+ const ledger = createMobileInboundMemoryLedger(10)
+ const onError = vi.fn()
+ const queue = createMobileInboundFrameQueue({
+ process: () => {
+ throw new Error('processing failed')
+ },
+ onError,
+ overflowMessage: 'overflow',
+ frameTooLargeMessage: 'too large',
+ memoryLedger: ledger
+ })
+
+ await queue.enqueue(new Uint8Array(3))
+
+ expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'processing failed' }))
+ expectEmpty(ledger)
+ })
+
+ it('rejects a frame when another physical queue owns the remaining process credit', async () => {
+ const ledger = createMobileInboundMemoryLedger(5)
+ let releaseFirst!: () => void
+ const first = createMobileInboundFrameQueue({
+ process: () => new Promise((resolve) => (releaseFirst = resolve)),
+ onError: vi.fn(),
+ overflowMessage: 'first overflow',
+ frameTooLargeMessage: 'too large',
+ memoryLedger: ledger
+ })
+ const secondProcess = vi.fn()
+ const secondError = vi.fn()
+ const second = createMobileInboundFrameQueue({
+ process: secondProcess,
+ onError: secondError,
+ overflowMessage: 'aggregate overflow',
+ frameTooLargeMessage: 'too large',
+ memoryLedger: ledger
+ })
+
+ const firstPending = first.enqueue(new Uint8Array(3))
+ await second.enqueue(new Uint8Array(3))
+
+ expect(secondProcess).not.toHaveBeenCalled()
+ expect(secondError).toHaveBeenCalledWith(
+ expect.objectContaining({ message: 'aggregate overflow' })
+ )
+ expect(ledger.evidence()).toMatchObject({ claimCount: 1, retainedBytes: 3 })
+ releaseFirst()
+ await firstPending
+ expectEmpty(ledger)
+ })
+
+ it('releases current and queued claims after processing rejects', async () => {
+ const ledger = createMobileInboundMemoryLedger(10)
+ let rejectCurrent!: (error: Error) => void
+ const onError = vi.fn()
+ const queue = createMobileInboundFrameQueue({
+ process: () => new Promise((_resolve, reject) => (rejectCurrent = reject)),
+ onError,
+ overflowMessage: 'overflow',
+ frameTooLargeMessage: 'too large',
+ memoryLedger: ledger
+ })
+ const first = queue.enqueue(new Uint8Array(2))
+ const second = queue.enqueue(new Uint8Array(3))
+ expect(ledger.evidence()).toMatchObject({ claimCount: 2, retainedBytes: 5 })
+
+ rejectCurrent(new Error('processing failed'))
+ await Promise.all([first, second])
+
+ expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'processing failed' }))
+ expectEmpty(ledger)
+ })
+
+ it('releases queued claims on dispose and the active claim when processing settles', async () => {
+ const ledger = createMobileInboundMemoryLedger(10)
+ let releaseCurrent!: () => void
+ const queue = createMobileInboundFrameQueue({
+ process: () => new Promise