mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 00:03:20 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/tcc-local-build-bundle-id
# Conflicts: # src/main/ipc/notifications.ts
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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.
|
||||
|
||||
<img src="docs/assets/wechat-qr.jpg" alt="WeChat QR code for the Orca community" width="160" />
|
||||
|
||||
|
||||
+365
-19
@@ -1387,7 +1387,10 @@
|
||||
"surfaces": [
|
||||
"remote agent launch and explicit resume",
|
||||
"multi-client remote runtime sessions",
|
||||
"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"
|
||||
],
|
||||
@@ -1410,21 +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, and durable terminal retirement. The real repro runs two independent clients against one headless remote Orca runtime over the encrypted pairing path and a real daemon-backed PTY. SSH coverage is contract/fault-injection 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 test:repro:remote-agent-session"
|
||||
"pnpm exec vitest run --config config/vitest.config.ts tests/e2e/remote-terminal-tab-retirement.unit.test.ts",
|
||||
"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",
|
||||
@@ -1434,9 +1455,71 @@
|
||||
"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"
|
||||
"src/renderer/src/runtime/web-session-tabs-sync.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": [
|
||||
@@ -1485,9 +1568,60 @@
|
||||
"only an exact structured-create handoff retires its provisional tab",
|
||||
"an absent host tab retires its exact provisional handoff only after a causally post-create snapshot while unrelated tabs remain"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "tests/e2e/remote-terminal-tab-retirement.unit.test.ts",
|
||||
"assertions": [
|
||||
"a durable host exit removes the terminal from two independent viewer mirrors instead of publishing a handle-less phantom",
|
||||
"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",
|
||||
@@ -1498,13 +1632,40 @@
|
||||
"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 build-backed headless remote Orca harness passed over encrypted WebSocket pairing with two 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",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts tests/e2e/remote-terminal-tab-retirement.unit.test.ts",
|
||||
"result": "failed",
|
||||
"durationSeconds": 3.11,
|
||||
"summary": "The exact cross-boundary oracle failed on pre-#9687 commit 2a32c5c9a because the retired publication still contained the pinned persisted terminal surface."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-22",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts tests/e2e/remote-terminal-tab-retirement.unit.test.ts",
|
||||
"result": "failed",
|
||||
"durationSeconds": 3.48,
|
||||
"summary": "The exact strengthened oracle failed on PR #9053 head d3a1d3047 because its stale-headless pruning retained the pinned persisted terminal surface."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-22",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts tests/e2e/remote-terminal-tab-retirement.unit.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 3.18,
|
||||
"summary": "The same strengthened oracle passed on main@4fce2de49."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
@@ -1517,25 +1678,166 @@
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "partial",
|
||||
"evidence": "The motivating remote-client duplicate-resume and exited-surface repros are encoded in deterministic lower-layer tests and the real remote harness; saved CI red/green 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 live Linux/Windows and SSH/WSL provider evidence before claiming full platform/provider coverage."
|
||||
"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 real remote-server harness currently runs on macOS and uses a local daemon-backed execution owner; Linux and Windows runs remain uncollected.",
|
||||
"SSH and relay failure ordering is deterministic contract coverage, not a live SSH-host journey; WSL has no provider-specific run.",
|
||||
"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, 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",
|
||||
@@ -4567,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"
|
||||
],
|
||||
@@ -4584,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"
|
||||
],
|
||||
@@ -4608,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"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -4645,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": {
|
||||
@@ -4661,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.",
|
||||
@@ -4670,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."
|
||||
},
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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 <input.exe> <output.exe.blockmap>')
|
||||
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})`)
|
||||
@@ -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('')
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 7.6m">
|
||||
<title>downloads: 7.6m</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 8.0m">
|
||||
<title>downloads: 8.0m</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
@@ -15,7 +15,7 @@
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
|
||||
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
|
||||
<text x="37" y="14">downloads</text>
|
||||
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">7.6m</text>
|
||||
<text x="93.5" y="14">7.6m</text>
|
||||
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">8.0m</text>
|
||||
<text x="93.5" y="14">8.0m</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 943 B After Width: | Height: | Size: 943 B |
Binary file not shown.
|
Before Width: | Height: | Size: 388 KiB After Width: | Height: | Size: 399 KiB |
@@ -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 群。
|
||||
|
||||
<img src="../assets/wechat-qr.jpg" alt="Orca 社区微信群二维码" width="160" />
|
||||
|
||||
|
||||
+1
-1
@@ -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": {
|
||||
|
||||
@@ -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<Map<string, { x: number; width: number }>>(new Map())
|
||||
const [markdownDocs, setMarkdownDocs] = useState<Map<string, MarkdownDocState>>(new Map())
|
||||
const markdownDocsRef = useRef<Map<string, MarkdownDocState>>(new Map())
|
||||
const markdownDocLifecycleRef = useRef(new MobileSessionMarkdownDocLifecycle())
|
||||
const [fileDocs, setFileDocs] = useState<Map<string, FileDocState>>(new Map())
|
||||
const fileDocLifecycleRef = useRef(new MobileSessionFileDocLifecycle())
|
||||
const [diffComments, setDiffComments] = useState<DiffComment[]>([])
|
||||
const diffCommentsRef = useRef<DiffComment[]>([])
|
||||
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 (
|
||||
<View ref={setMobileSessionRootRef} style={styles.container}>
|
||||
@@ -4581,24 +4594,16 @@ export default function SessionScreen() {
|
||||
<Pressable
|
||||
style={[
|
||||
styles.createButton,
|
||||
(creating ||
|
||||
creatingBrowser ||
|
||||
creatingMarkdown ||
|
||||
connState !== 'connected') &&
|
||||
styles.createButtonDisabled
|
||||
(createTabBusy || connState !== 'connected') && styles.createButtonDisabled
|
||||
]}
|
||||
disabled={
|
||||
creating || creatingBrowser || creatingMarkdown || connState !== 'connected'
|
||||
}
|
||||
disabled={createTabBusy || connState !== 'connected'}
|
||||
onPress={() => {
|
||||
setCreateError('')
|
||||
setShowCreateTabDrawer(true)
|
||||
}}
|
||||
>
|
||||
<Text style={styles.createButtonText}>
|
||||
{creating || creatingBrowser || creatingMarkdown
|
||||
? 'Creating...'
|
||||
: 'Create Tab'}
|
||||
{createTabBusy ? 'Creating...' : 'Create Tab'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
@@ -4715,8 +4720,7 @@ export default function SessionScreen() {
|
||||
))}
|
||||
<MobileNativeChatOverlay
|
||||
controller={nativeChatController}
|
||||
onAttachImage={() => void attachImage('library')}
|
||||
isAttaching={isAttaching}
|
||||
images={nativeChatImages}
|
||||
onMicPress={handleDictationToggle}
|
||||
micActive={dictation.isRecording}
|
||||
dictationMode={dictationMode}
|
||||
|
||||
@@ -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<Record<string, string>>({})
|
||||
const [expandedPrFilePath, setExpandedPrFilePath] = useState<string | null>(null)
|
||||
const [prFileContents, setPrFileContents] = useState<Record<string, GitHubPRFileContents>>({})
|
||||
const [prFileLoadingPath, setPrFileLoadingPath] = useState<string | null>(null)
|
||||
const [prFileCommentDrafts, setPrFileCommentDrafts] = useState<Record<string, string>>({})
|
||||
const [copiedLinkKey, setCopiedLinkKey] = useState<string | null>(null)
|
||||
const copiedLinkResetTimerRef = useRef<ReturnType<typeof setTimeout> | 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] ? (
|
||||
<GitHubPrFileDiff
|
||||
filePath={file.path}
|
||||
contents={prFileContents[file.path]}
|
||||
contents={prFileContents[file.path]!}
|
||||
commentDrafts={prFileCommentDrafts}
|
||||
disabled={projectMutating}
|
||||
onCommentDraftChange={(draftKey, next) =>
|
||||
@@ -13268,7 +13266,7 @@ export default function MobileTasksScreen() {
|
||||
) : prFileContents[file.path] ? (
|
||||
<GitHubPrFileDiff
|
||||
filePath={file.path}
|
||||
contents={prFileContents[file.path]}
|
||||
contents={prFileContents[file.path]!}
|
||||
commentDrafts={prFileCommentDrafts}
|
||||
disabled={mutatingStatus}
|
||||
onCommentDraftChange={(draftKey, next) =>
|
||||
|
||||
@@ -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') {
|
||||
|
||||
+23
-27
@@ -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 <version>\" commit."
|
||||
else
|
||||
"Re-dispatch with bump_patch_version: true (or a higher release_version), " \
|
||||
"or land a \"Prepare mobile <version>\" 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 <version>\" commit.",
|
||||
"new builds. #{retry_guidance}",
|
||||
)
|
||||
end
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<string, BrowserFrameCacheEntry>()
|
||||
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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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<string, RetainedFrame>()
|
||||
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')}`
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<string, MobileBrowserViewMode>()
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
+80
@@ -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()
|
||||
})
|
||||
})
|
||||
+50
-1
@@ -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<HomeSnapshot | null> {
|
||||
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<HomeSnapshot | null> {
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
+50
@@ -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: []
|
||||
})
|
||||
})
|
||||
})
|
||||
+126
@@ -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<string, CacheEntry>()
|
||||
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
|
||||
}
|
||||
Vendored
+20
-2
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
Vendored
+18
-24
@@ -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<string, CachedRepos>()
|
||||
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()
|
||||
}
|
||||
|
||||
+20
-2
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
Vendored
+18
-27
@@ -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<string, CachedWorktrees>()
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
</MountedBottomDrawer>
|
||||
)
|
||||
}
|
||||
|
||||
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 = (
|
||||
<Animated.View
|
||||
pointerEvents={visible ? 'auto' : 'none'}
|
||||
style={[styles.overlay, { zIndex, elevation: zIndex }]}
|
||||
accessibilityViewIsModal
|
||||
aria-modal
|
||||
>
|
||||
<GestureHandlerRootView style={styles.root}>
|
||||
<Animated.View style={[styles.backdrop, backdropStyle]}>
|
||||
<Pressable style={StyleSheet.absoluteFill} onPress={dismiss} />
|
||||
</Animated.View>
|
||||
|
||||
<View style={[styles.anchor, isWideLayout && styles.anchorWide]} pointerEvents="box-none">
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.drawer,
|
||||
{
|
||||
width: '100%',
|
||||
maxWidth: isWideLayout ? modalMaxWidth : undefined,
|
||||
maxHeight: screenHeight - insets.top - spacing.lg,
|
||||
paddingBottom: insets.bottom + spacing.lg
|
||||
},
|
||||
drawerStyle
|
||||
]}
|
||||
>
|
||||
{!contentScrollable ? (
|
||||
<>
|
||||
<GestureDetector gesture={handlePanGesture}>
|
||||
<Animated.View
|
||||
style={styles.handleHitArea}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Dismiss drawer"
|
||||
>
|
||||
<View style={styles.handle} />
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
<View style={styles.staticContent}>{children}</View>
|
||||
</>
|
||||
) : dragContentToDismiss ? (
|
||||
<>
|
||||
<GestureDetector gesture={handlePanGesture}>
|
||||
<Animated.View
|
||||
style={styles.handleHitArea}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Dismiss drawer"
|
||||
>
|
||||
<View style={styles.handle} />
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
<GestureDetector gesture={contentPanGesture}>
|
||||
<Animated.View collapsable={false}>
|
||||
<GestureDetector gesture={scrollGesture}>
|
||||
<Animated.ScrollView
|
||||
bounces={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
onScroll={scrollHandler}
|
||||
scrollEventThrottle={16}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{children}
|
||||
</Animated.ScrollView>
|
||||
</GestureDetector>
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<GestureDetector gesture={handlePanGesture}>
|
||||
<Animated.View
|
||||
style={styles.handleHitArea}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Dismiss drawer"
|
||||
>
|
||||
<View style={styles.handle} />
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
<ScrollView
|
||||
bounces={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{children}
|
||||
</ScrollView>
|
||||
</>
|
||||
)}
|
||||
<View style={styles.bottomExtension} />
|
||||
</Animated.View>
|
||||
</View>
|
||||
</GestureHandlerRootView>
|
||||
</Animated.View>
|
||||
)
|
||||
|
||||
// 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 (
|
||||
<Modal visible transparent animationType="none" statusBarTranslucent onRequestClose={dismiss}>
|
||||
{overlay}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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<CustomKey[]> {
|
||||
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<void> {
|
||||
await AsyncStorage.setItem(CUSTOM_ACCESSORY_KEYS_STORAGE_KEY, JSON.stringify(keys))
|
||||
}
|
||||
|
||||
export function CustomKeyModal({ visible, onClose, onKeysChanged, onManageShortcuts }: Props) {
|
||||
const [step, setStep] = useState<Step>('choose-type')
|
||||
const [shortcutKey, setShortcutKey] = useState('c')
|
||||
@@ -104,8 +94,7 @@ export function CustomKeyModal({ visible, onClose, onKeysChanged, onManageShortc
|
||||
async (key: Omit<CustomKey, 'id'>) => {
|
||||
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}
|
||||
/>
|
||||
<Text style={styles.fieldLabel}>Command</Text>
|
||||
<TextInput
|
||||
@@ -382,6 +372,7 @@ export function CustomKeyModal({ visible, onClose, onKeysChanged, onManageShortc
|
||||
placeholderTextColor={colors.textMuted}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
maxLength={CUSTOM_ACCESSORY_KEY_MAX_BYTES_CHARACTERS - 1}
|
||||
/>
|
||||
<View style={styles.switchRow}>
|
||||
<Text style={styles.switchLabel}>Press Enter</Text>
|
||||
|
||||
@@ -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<Repo[]>(initialRepos ?? [])
|
||||
const [selectedRepo, setSelectedRepo] = useState<Repo | null>(null)
|
||||
const [drawerView, setDrawerView] = useState<NewWorktreeDrawerView>('form')
|
||||
const drawerTransitionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const { drawerView, formSheetVisible, formSheetInteractive, transitionDrawer, openSourceDrawer } =
|
||||
useNewWorktreeDrawerNavigation(visible)
|
||||
const createInFlightRef = useRef(false)
|
||||
const setupTrustActionInFlightRef = useRef(false)
|
||||
const [selectedAgentState, setSelectedAgent] = useState<AgentOption>(AGENT_OPTIONS[0]!)
|
||||
@@ -232,29 +227,6 @@ function NewWorktreeModalContent({
|
||||
[existingWorktrees, selectedRepo]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (drawerTransitionTimerRef.current) {
|
||||
clearTimeout(drawerTransitionTimerRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
function transitionDrawer(nextView: Exclude<NewWorktreeDrawerView, 'transition'>): 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({
|
||||
}
|
||||
}}
|
||||
>
|
||||
<BottomDrawer visible={visible && drawerView === 'form'} onClose={onClose}>
|
||||
<BottomDrawer visible={formSheetVisible} interactive={formSheetInteractive} onClose={onClose}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>Create Workspace</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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<T extends { id: string; label: string }> = {
|
||||
visible: boolean
|
||||
|
||||
@@ -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<SmartNameMode>(() => resolveDefaultSmartMode(availability))
|
||||
const [mrStateFilter, setMrStateFilter] = useState<MrStateFilter>('opened')
|
||||
const inputRef = useRef<TextInput>(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<typeof setTimeout> | 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 (
|
||||
<BottomDrawer
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
dragContentToDismiss={false}
|
||||
contentScrollable={false}
|
||||
fillAvailable
|
||||
// Why: sit above the pinned create form so the outer content-sized sheet
|
||||
// stays underneath and is revealed at its original height on dismiss.
|
||||
zIndex={1100}
|
||||
>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>Name or 'Create From'</Text>
|
||||
<Pressable onPress={closeSoon} hitSlop={8}>
|
||||
<Text style={styles.done}>Done</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<TextInput
|
||||
style={styles.search}
|
||||
value={composer.name}
|
||||
onChangeText={composer.setName}
|
||||
placeholder="Type a name or search a source"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<View style={styles.tabRow}>
|
||||
{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 (
|
||||
<Pressable
|
||||
key={option.id}
|
||||
style={[styles.tab, selected && styles.tabSelected]}
|
||||
onPress={() => setMode(option.id)}
|
||||
>
|
||||
<SmartSourceModeIcon icon={option.icon} color={tint} />
|
||||
<Text style={[styles.tabText, selected && styles.tabTextSelected]}>
|
||||
{option.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
|
||||
{effectiveMode === 'gitlab' ? (
|
||||
<View style={styles.chipRow}>
|
||||
{MR_STATE_FILTER_OPTIONS.map((option) => {
|
||||
const selected = option.id === mrStateFilter
|
||||
return (
|
||||
<Pressable
|
||||
key={option.id}
|
||||
style={[styles.chip, selected && styles.chipSelected]}
|
||||
onPress={() => setMrStateFilter(option.id)}
|
||||
>
|
||||
<Text style={[styles.chipText, selected && styles.chipTextSelected]}>
|
||||
{option.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)
|
||||
})}
|
||||
{/* 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. */}
|
||||
<View style={styles.root}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>Name or 'Create From'</Text>
|
||||
<Pressable onPress={onClose} hitSlop={8}>
|
||||
<Text style={styles.done}>Done</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{crossRepoPrompt ? (
|
||||
<View style={styles.crossRepo}>
|
||||
<Text style={styles.crossRepoText}>
|
||||
This item lives in {crossRepoPrompt.link.slug.owner}/{crossRepoPrompt.link.slug.repo}.
|
||||
</Text>
|
||||
<View style={styles.crossRepoActions}>
|
||||
<Pressable style={styles.crossRepoDismiss} onPress={dismissCrossRepoPrompt}>
|
||||
<Text style={styles.crossRepoDismissText}>Cancel</Text>
|
||||
</Pressable>
|
||||
<Pressable style={styles.crossRepoSwitch} onPress={() => void handleAcceptCrossRepo()}>
|
||||
<Text style={styles.crossRepoSwitchText}>
|
||||
Switch to {crossRepoPrompt.matchingRepo.displayName}
|
||||
<View style={styles.results}>
|
||||
{crossRepoPrompt ? (
|
||||
<View style={styles.crossRepo}>
|
||||
<Text style={styles.crossRepoText}>
|
||||
This item lives in {crossRepoPrompt.link.slug.owner}/
|
||||
{crossRepoPrompt.link.slug.repo}.
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{!sshReady && effectiveMode !== 'text' && effectiveMode !== 'linear' ? (
|
||||
<Text style={styles.notice}>Connect the repository to search sources.</Text>
|
||||
) : needsGitHubRemote ? (
|
||||
<Text style={styles.notice}>
|
||||
This SSH repo needs a GitHub remote to list issues and PRs.
|
||||
</Text>
|
||||
) : error ? (
|
||||
<Text style={styles.errorNotice}>{error}</Text>
|
||||
) : null}
|
||||
|
||||
<FlatList
|
||||
data={rows}
|
||||
keyExtractor={(row) => row.value}
|
||||
style={styles.list}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
nestedScrollEnabled
|
||||
ListFooterComponent={
|
||||
loading ? (
|
||||
<View style={styles.loading}>
|
||||
<ActivityIndicator size="small" color={colors.textSecondary} />
|
||||
<View style={styles.crossRepoActions}>
|
||||
<Pressable style={styles.crossRepoDismiss} onPress={dismissCrossRepoPrompt}>
|
||||
<Text style={styles.crossRepoDismissText}>Cancel</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={styles.crossRepoSwitch}
|
||||
onPress={() => void handleAcceptCrossRepo()}
|
||||
>
|
||||
<Text style={styles.crossRepoSwitchText}>
|
||||
Switch to {crossRepoPrompt.matchingRepo.displayName}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
) : showEmpty ? (
|
||||
<Text style={styles.empty}>{emptyHint || 'No results found.'}</Text>
|
||||
) : null
|
||||
}
|
||||
renderItem={({ item }) => (
|
||||
<SmartWorkspaceSourceRow row={item} onPress={() => handleSelectRow(item)} />
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!sshReady && effectiveMode !== 'text' && effectiveMode !== 'linear' ? (
|
||||
<Text style={styles.notice}>Connect the repository to search sources.</Text>
|
||||
) : needsGitHubRemote ? (
|
||||
<Text style={styles.notice}>
|
||||
This SSH repo needs a GitHub remote to list issues and PRs.
|
||||
</Text>
|
||||
) : error ? (
|
||||
<Text style={styles.errorNotice}>{error}</Text>
|
||||
) : null}
|
||||
|
||||
<FlatList
|
||||
data={rows}
|
||||
keyExtractor={(row) => row.value}
|
||||
style={styles.list}
|
||||
contentContainerStyle={styles.listContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
keyboardDismissMode="none"
|
||||
nestedScrollEnabled
|
||||
ListFooterComponent={
|
||||
loading ? (
|
||||
<View style={styles.loading}>
|
||||
<ActivityIndicator size="small" color={colors.textSecondary} />
|
||||
</View>
|
||||
) : showEmpty ? (
|
||||
<Text style={styles.empty}>{emptyHint || 'No results found.'}</Text>
|
||||
) : rows.length === 0 && effectiveMode === 'text' ? (
|
||||
<Text style={styles.empty}>Type a workspace name in the field below.</Text>
|
||||
) : null
|
||||
}
|
||||
renderItem={({ item }) => (
|
||||
<SmartWorkspaceSourceRow row={item} onPress={() => handleSelectRow(item)} />
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.dock}>
|
||||
{effectiveMode === 'gitlab' ? (
|
||||
<View style={styles.chipRow}>
|
||||
{MR_STATE_FILTER_OPTIONS.map((option) => {
|
||||
const selected = option.id === mrStateFilter
|
||||
return (
|
||||
<Pressable
|
||||
key={option.id}
|
||||
style={[styles.chip, selected && styles.chipSelected]}
|
||||
onPress={() => setMrStateFilter(option.id)}
|
||||
>
|
||||
<Text style={[styles.chipText, selected && styles.chipTextSelected]}>
|
||||
{option.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View style={styles.tabRow}>
|
||||
{modeTabs.map((option) => {
|
||||
const selected = option.id === effectiveMode
|
||||
const tint = selected ? colors.textPrimary : colors.textSecondary
|
||||
return (
|
||||
<Pressable
|
||||
key={option.id}
|
||||
style={[styles.tab, selected && styles.tabSelected]}
|
||||
onPress={() => setMode(option.id)}
|
||||
>
|
||||
<SmartSourceModeIcon icon={option.icon} color={tint} />
|
||||
<Text style={[styles.tabText, selected && styles.tabTextSelected]}>
|
||||
{option.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
style={styles.search}
|
||||
value={composer.name}
|
||||
onChangeText={composer.setName}
|
||||
placeholder="Type a name or search a source"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
// Still request native auto-focus; the delayed ref focus is the reliable path.
|
||||
autoFocus
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={onClose}
|
||||
blurOnSubmit={false}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</BottomDrawer>
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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({
|
||||
</Pressable>
|
||||
</View>
|
||||
) : (
|
||||
<Pressable
|
||||
// Why: real TextInput (not a Pressable fake) so the typed value is the
|
||||
// same composer.name the source drawer docks — focus handoff keeps the
|
||||
// string continuous even though native focus moves to the docked field.
|
||||
<TextInput
|
||||
style={[styles.input, disabled && styles.disabled]}
|
||||
disabled={disabled}
|
||||
onPress={openDrawer}
|
||||
>
|
||||
<Text
|
||||
style={[styles.inputText, !composer.name && styles.inputPlaceholder]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{composer.name || 'Type a name or search a source'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const BOTTOM_DRAWER_HIDE_DURATION_MS = 150
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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 = (
|
||||
<GestureDetector gesture={handlePanGesture}>
|
||||
<Animated.View
|
||||
style={styles.handleHitArea}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Dismiss drawer"
|
||||
>
|
||||
<View style={styles.handle} />
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
)
|
||||
|
||||
const body = !contentScrollable ? (
|
||||
<>
|
||||
{handle}
|
||||
<View style={[styles.staticContent, fillAvailable && styles.staticContentFill]}>
|
||||
{children}
|
||||
</View>
|
||||
</>
|
||||
) : dragContentToDismiss ? (
|
||||
<>
|
||||
{handle}
|
||||
<GestureDetector gesture={contentPanGesture}>
|
||||
<Animated.View collapsable={false}>
|
||||
<GestureDetector gesture={scrollGesture}>
|
||||
<Animated.ScrollView
|
||||
bounces={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
onScroll={scrollHandler}
|
||||
scrollEventThrottle={16}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{children}
|
||||
</Animated.ScrollView>
|
||||
</GestureDetector>
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{handle}
|
||||
<ScrollView
|
||||
bounces={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{children}
|
||||
</ScrollView>
|
||||
</>
|
||||
)
|
||||
|
||||
const overlay = (
|
||||
<Animated.View
|
||||
pointerEvents={visible && interactive ? 'auto' : 'none'}
|
||||
style={[styles.overlay, { zIndex, elevation: zIndex }]}
|
||||
accessibilityViewIsModal={interactive}
|
||||
aria-modal={interactive}
|
||||
>
|
||||
<GestureHandlerRootView style={styles.root}>
|
||||
<Animated.View
|
||||
// Why: pinned-under outer sheets keep progress=1 so size stays; hide
|
||||
// their backdrop so only the top interactive drawer dims the canvas.
|
||||
style={[styles.backdrop, interactive ? backdropStyle : { opacity: 0 }]}
|
||||
>
|
||||
{interactive ? <Pressable style={styles.backdropPressable} onPress={dismiss} /> : null}
|
||||
</Animated.View>
|
||||
|
||||
<View style={[styles.anchor, isWideLayout && styles.anchorWide]} pointerEvents="box-none">
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.drawer,
|
||||
fillAvailable ? styles.drawerFill : null,
|
||||
{
|
||||
width: '100%',
|
||||
maxWidth: isWideLayout ? modalMaxWidth : undefined,
|
||||
maxHeight: screenHeight - insets.top - spacing.lg,
|
||||
// Why: fill sheets shrink height AND lift with marginBottom so the
|
||||
// bottom edge sits on the keyboard top (height alone still leaves
|
||||
// the dock in the keyboard’s footprint). Non-fill sheets keep
|
||||
// the legacy translateY keyboard shift instead.
|
||||
height: fillHeight,
|
||||
marginBottom: fillAvailable ? keyboardInset : 0,
|
||||
paddingBottom:
|
||||
fillAvailable && keyboardInset > 0 ? spacing.sm : insets.bottom + spacing.lg
|
||||
},
|
||||
drawerStyle
|
||||
]}
|
||||
>
|
||||
{body}
|
||||
<View style={styles.bottomExtension} />
|
||||
</Animated.View>
|
||||
</View>
|
||||
</GestureHandlerRootView>
|
||||
</Animated.View>
|
||||
)
|
||||
|
||||
// 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 (
|
||||
<Modal visible transparent animationType="none" statusBarTranslucent onRequestClose={dismiss}>
|
||||
{overlay}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -167,7 +167,10 @@ function TableBlock({
|
||||
block: Extract<MarkdownBlock, { kind: 'table' }>
|
||||
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 (
|
||||
<ScrollView
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseInline, parseMarkdownBlocks } from './markdown-blocks'
|
||||
import { COMMENT_MARKDOWN_LIMITS, parseInline, parseMarkdownBlocks } from './markdown-blocks'
|
||||
|
||||
describe('parseMarkdownBlocks', () => {
|
||||
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 = '<br>'.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]
|
||||
|
||||
@@ -40,6 +40,20 @@ const ORDERED = /^\s*\d+[.)]\s+(.*)$/
|
||||
const HTML_BLOCK = /<(details|blockquote)\b[^>]*>([\s\S]*?)<\/\1>/i
|
||||
const SUMMARY = /<summary\b[^>]*>([\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 <b>/<kbd>/<sub> etc. don't
|
||||
// show literally. Conservative: only matches `<tag ...>` / `</tag>` 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 <br> before block parsing.
|
||||
const cleaned = content.replace(/<!--[\s\S]*?-->/g, '').replace(/<br\s*\/?>/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 <details>/<blockquote> 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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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<NewWorktreeDrawerView, 'transition'>) => void
|
||||
openSourceDrawer: () => void
|
||||
} {
|
||||
const [drawerView, setDrawerView] = useState<NewWorktreeDrawerView>('form')
|
||||
const formPinnedUnderSourceRef = useRef(false)
|
||||
const drawerTransitionTimerRef = useRef<ReturnType<typeof setTimeout> | 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<NewWorktreeDrawerView, 'transition'>): 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
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ export const troubleshootCommonIssues: TroubleshootSection[] = [
|
||||
icon: <WifiOff size={16} color={colors.textSecondary} />,
|
||||
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.'
|
||||
]
|
||||
|
||||
@@ -132,6 +132,27 @@ describe('DictationSetupPollController', () => {
|
||||
poller.dispose()
|
||||
})
|
||||
|
||||
it('shares one completion while repeated manual refreshes are pending', async () => {
|
||||
const requests = [deferred<boolean>(), deferred<boolean>()]
|
||||
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)
|
||||
|
||||
@@ -11,7 +11,7 @@ export class DictationSetupPollController {
|
||||
private timer: ReturnType<typeof setTimeout> | null = null
|
||||
private inFlight = false
|
||||
private immediateRefreshPending = false
|
||||
private refreshWaiters: Array<() => void> = []
|
||||
private refreshCompletion: { promise: Promise<void>; 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<void>((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<PollState>): 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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<RpcResponse> => {
|
||||
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<RpcResponse> => {
|
||||
|
||||
@@ -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<DirectoryLoadRevisions>(createDirectoryLoadRevisions())
|
||||
const pendingDirectoryRetriesRef = useRef<Set<string>>(new Set())
|
||||
const directoryCacheRef = useRef<DirectoryCache>({})
|
||||
const directoryCacheAccessRef = useRef(0)
|
||||
const [directoryCache, setDirectoryCache] = useState<DirectoryCache>({})
|
||||
const [expanded, setExpanded] = useState<Set<string>>(() => new Set())
|
||||
const expandedRef = useRef(expanded)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(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(
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<string, Map<string, MobileDirEntry>>
|
||||
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<string, Map<string, boolean>>()
|
||||
const ensureDir = (path: string): Map<string, boolean> => {
|
||||
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<string, ReadonlyMap<string, MobileDirEntry>>
|
||||
): 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)
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ export type DirectoryState = {
|
||||
entries: MobileDirEntry[]
|
||||
loading?: boolean
|
||||
error?: string
|
||||
lastAccess?: number
|
||||
}
|
||||
|
||||
export type DirectoryCache = Record<string, DirectoryState | undefined>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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<string>,
|
||||
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<string>,
|
||||
evictedPaths: readonly string[]
|
||||
): Set<string> {
|
||||
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<string, unknown>
|
||||
return (
|
||||
typeof entry.name === 'string' &&
|
||||
typeof entry.isDirectory === 'boolean' &&
|
||||
(entry.isSymlink === undefined || typeof entry.isSymlink === 'boolean')
|
||||
)
|
||||
}
|
||||
|
||||
function directoryAncestors(relativePath: string): Set<string> {
|
||||
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<string>,
|
||||
essentialPaths: ReadonlySet<string>
|
||||
): 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
|
||||
}
|
||||
@@ -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<RpcClient, 'sendRequest'>
|
||||
sendRequest: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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<RpcClient, 'sendRequest'>,
|
||||
worktree: string
|
||||
): Promise<MobileFileMutationOwnership> {
|
||||
const status = await requestResult<Pick<RuntimeStatus, 'capabilities'>>(
|
||||
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<TResult>(
|
||||
client: Pick<RpcClient, 'sendRequest'>,
|
||||
method: string,
|
||||
params: unknown
|
||||
): Promise<TResult> {
|
||||
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
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,16 @@ function clientOf(byMethod: Record<string, RpcResponse>): {
|
||||
|
||||
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'])
|
||||
})
|
||||
|
||||
|
||||
@@ -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<never>(() => undefined))
|
||||
const failActiveDictation = vi.fn()
|
||||
const pendingChunks = new Set<Promise<void>>()
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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<void> = Promise.resolve()
|
||||
@@ -51,7 +52,34 @@ function withNativeCallTimeout(nativeCall: Promise<void>): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
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<void> {
|
||||
assertTrackedTagCapacity(tag)
|
||||
const nativeActivation = activateKeepAwakeAsync(tag)
|
||||
try {
|
||||
await withNativeCallTimeout(nativeActivation)
|
||||
@@ -234,3 +262,12 @@ export function createMobileDictationKeepAwakeOwner(): MobileDictationKeepAwakeO
|
||||
export function drainMobileDictationKeepAwakeCleanup(): Promise<void> {
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -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<NotificationPermissionState> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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<string | null> {
|
||||
// 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<string> {
|
||||
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<void> {
|
||||
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<void> | 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()
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<string>()
|
||||
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
|
||||
|
||||
@@ -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<string | null>
|
||||
dismissAfterSchedule?: boolean
|
||||
}
|
||||
|
||||
const scheduledNotificationsByHostAndNotificationId = new Map<string, ScheduledNotificationState>()
|
||||
|
||||
// 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<NotificationPermissionState> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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<string | null>
|
||||
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<string, MobileScheduledNotificationState>()
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<boolean>
|
||||
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({
|
||||
</ScrollView>
|
||||
</View>
|
||||
) : null}
|
||||
{attachments.length > 0 ? (
|
||||
<ScrollView
|
||||
horizontal
|
||||
keyboardShouldPersistTaps="always"
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={styles.attachmentStrip}
|
||||
contentContainerStyle={styles.attachmentStripContent}
|
||||
>
|
||||
{attachments.map((attachment) => (
|
||||
<View key={attachment.id} style={styles.attachmentThumb}>
|
||||
<Image
|
||||
source={{ uri: attachment.previewUri }}
|
||||
style={styles.attachmentImage}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
{onRemoveAttachment ? (
|
||||
<Pressable
|
||||
accessibilityLabel="Remove image"
|
||||
style={styles.attachmentRemove}
|
||||
onPress={() => onRemoveAttachment(attachment.id)}
|
||||
hitSlop={8}
|
||||
>
|
||||
<X size={12} color={colors.textPrimary} strokeWidth={2.6} />
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
) : null}
|
||||
<View style={styles.bar}>
|
||||
{onAttachImage ? (
|
||||
<Pressable
|
||||
@@ -239,6 +280,45 @@ const styles = StyleSheet.create({
|
||||
fontFamily: typography.monoFamily,
|
||||
fontSize: typography.metaSize
|
||||
},
|
||||
attachmentStrip: {
|
||||
maxHeight: 76,
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
borderTopColor: colors.borderSubtle,
|
||||
backgroundColor: colors.bgPanel
|
||||
},
|
||||
attachmentStripContent: {
|
||||
gap: spacing.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm
|
||||
},
|
||||
attachmentThumb: {
|
||||
width: 60,
|
||||
height: 60,
|
||||
borderRadius: radii.button,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.borderSubtle,
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
attachmentImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: radii.button
|
||||
},
|
||||
attachmentRemove: {
|
||||
// Inset inside the thumb: Android drops touches outside the parent's bounds,
|
||||
// so an overhanging badge would lose part of its tap target.
|
||||
position: 'absolute',
|
||||
top: 2,
|
||||
right: 2,
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: 10,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.bgRaised,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.borderSubtle
|
||||
},
|
||||
bar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-end',
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
|
||||
vi.mock('react-native', async () => {
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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 (
|
||||
<Image
|
||||
source={{ uri }}
|
||||
style={styles.imageThumb}
|
||||
resizeMode="contain"
|
||||
accessibilityLabel={block.alt ?? 'Attached image'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Text style={[styles.imageRef, { fontSize: TEXT_SIZE * fontScale }]}>
|
||||
🖼 {block.alt ?? block.path ?? block.url ?? 'image'}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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<boolean>
|
||||
/** 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}
|
||||
|
||||
@@ -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'))
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<string> {
|
||||
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<string> {
|
||||
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<PickedMobileImage | null> {
|
||||
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<PickedMobileImage | null> {
|
||||
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<PickedMobileImage | null> {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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<typeof setTimeout> | 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<string, number>()
|
||||
const userMessagesByText = new Map<string, Array<{ id: string; index: number }>>()
|
||||
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<string>()
|
||||
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
|
||||
}
|
||||
@@ -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<RpcClient, 'sendRequest'> & {
|
||||
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)
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user