mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 08:03:13 +00:00
Merge branch 'main' of https://github.com/stablyai/orca into fix/mobile-markdown-mutation-ownership-consolidated
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
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
Monitor and steer your agents from your phone — get notified when an agent finishes and send follow-ups from anywhere.
|
||||
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.31](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.32](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
|
||||
Pair with your desktop app to monitor and steer your agents from your phone.
|
||||
|
||||
- **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) or [join TestFlight](https://testflight.apple.com/join/YjeGMQBA)
|
||||
- **Android:** [Download APK 0.0.31](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.31/app-release.apk)
|
||||
- **Android:** [Download APK 0.0.32](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.32/app-release.apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
+267
-25
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"updatedAt": "2026-07-20",
|
||||
"updatedAt": "2026-07-22",
|
||||
"policy": {
|
||||
"maturityLevels": [
|
||||
"experimental",
|
||||
@@ -1390,6 +1390,7 @@
|
||||
"headed desktop remote-server pairing",
|
||||
"headless remote-server parity",
|
||||
"daemon and relay reconnect",
|
||||
"remote completion classification across disconnect and reconnect",
|
||||
"terminal exit retirement and restart restore",
|
||||
"mixed-version fallback"
|
||||
],
|
||||
@@ -1415,21 +1416,36 @@
|
||||
"wsl",
|
||||
"remote-runtime"
|
||||
],
|
||||
"coverageNotes": "Deterministic macOS tests cover controller claims, daemon and SSH/relay operation replay, mixed-version selection, runtime ownership, exact provisional handoff, durable terminal retirement, two independent viewer mirrors, and guarded adoption of legacy live PTYs. 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. A headed Orca desktop server paired to a separate client is the primary live user topology and remains uncollected for this issue. SSH coverage is provider/relay contract and fault-injection coverage only; it does not substitute for paired-server coverage. Live Windows, WSL, and 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 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 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. After exact exit, assert terminal and tab listings omit the surface, a stale publication cannot restore it, restart cannot resurrect it, and an exact provisional handoff is consumed even when exit wins before the next snapshot.",
|
||||
"invariant": "For every claim-capable execution route, one provider-session identity has at most one live PTY owner and one canonical host surface across concurrent clients, retries, reconnects, and stale publications. A live orphan may be adopted only when the controller proves its exact handle and incarnation, its worktree and host owner match, no competing visual owner exists, and a host topology CAS wins. A viewer may classify completion only from successful host/provider inspection or explicit lifecycle evidence; transport, handle, or provider unavailability remains unknown and breaks any consecutive-idle proof. A physical exit retires that exact incarnation durably so stale client state and host restart cannot recreate it. Mixed-version routes select the unchanged legacy request before any authority side effect or execution-owner-local filesystem access.",
|
||||
"oracle": "Race independent clients and repeated operation IDs, then assert one physical spawn and one canonical PTY/surface; inject exit-before-reply, provider disconnect, conflicting claim scope, old daemon/relay capabilities, reused handles, stale incarnations, owner mismatch, and topology revision conflict; assert safe adoption or explicit failure without a second spawn or wrong-process attachment. Restore legacy split panes and groups beside a newer host-owned tab, preserving output, input, resize, titles, tab/leaf identity, active group, and multi-client convergence. For completion, drive a known running agent through outer transport loss, authoritative provider rejection, reconnect, explicit stop, real exit status, and successful hook completion; assert unavailable evidence never dispatches completion and two fresh authoritative idle samples are required after the gap. After exact exit, assert terminal and tab listings omit the surface, a stale publication cannot restore it, restart cannot resurrect it, and an exact provisional handoff is consumed even when exit wins before the next snapshot.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/shared/claimed-agent-pty-owner.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/providers/ssh-pty-provider-agent-session-create-operation.test.ts src/main/runtime/orca-runtime-agent-session-operation.test.ts src/main/runtime/remote-agent-session-host-authority.integration.test.ts src/main/runtime/orca-runtime-terminal-retirement.test.ts src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts src/renderer/src/runtime/remote-runtime-session-tabs-inflight.test.ts src/renderer/src/runtime/web-runtime-session.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts tests/e2e/remote-terminal-tab-retirement.unit.test.ts",
|
||||
"pnpm test:repro:remote-agent-session",
|
||||
"pnpm 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"
|
||||
"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",
|
||||
@@ -1448,6 +1464,62 @@
|
||||
"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": [
|
||||
@@ -1532,6 +1604,24 @@
|
||||
}
|
||||
],
|
||||
"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",
|
||||
@@ -1542,13 +1632,13 @@
|
||||
"summary": "Ten focused files and 325 tests passed after the final review fixes, covering claim scope, mixed-version Pi/SSH fallback ordering, operation replay, terminal retirement, causal inventory fencing, exact concurrent handoff confirmation, daemon-generation integration, transport behavior, and remote host integration."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-21",
|
||||
"date": "2026-07-22",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm test:repro:remote-agent-session",
|
||||
"result": "passed",
|
||||
"durationSeconds": 48.47,
|
||||
"summary": "The secondary build-backed headless parity harness passed over encrypted WebSocket pairing with independent clients, proving one spawn, retry adoption, durable exit retirement, stale-publication rejection, and no restart resurrection."
|
||||
"durationSeconds": 48.63,
|
||||
"summary": "The secondary build-backed headless parity harness passed post-rebase on main@72a2d7bc7 over encrypted WebSocket pairing with independent clients, proving one spawn, retry adoption, durable exit retirement, stale-publication rejection, and no restart resurrection."
|
||||
},
|
||||
{
|
||||
"date": "2026-07-22",
|
||||
@@ -1588,22 +1678,23 @@
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "partial",
|
||||
"evidence": "The exact cross-boundary retirement oracle is red on pre-#9687 commit 2a32c5c9a and PR #9053 head d3a1d3047 because the pinned persisted surface remains in the host publication, and green on main@4fce2de49. Duplicate-resume red evidence remains encoded in lower-layer tests; saved CI artifacts are still needed."
|
||||
"evidence": "Issue #9151 has local red/green evidence: current main dispatches process-exit from a stale remote handle, while the fix preserves unknown liveness and requires fresh consecutive idle evidence after reconnect. The exact retirement oracle is red on pre-#9687 commit 2a32c5c9a and PR #9053 head d3a1d3047 because the pinned persisted surface remains in the host publication, and green on main@4fce2de49. Duplicate-resume red evidence remains encoded in lower-layer tests; saved CI artifacts are still needed."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "Agent-session reconciliation runs only at explicit claim admission, dedupes concurrent provider listing, and adds no polling or renderer output work. Create-operation ledgers are capped globally and per client, expire after 24 hours, and reject rather than evict live replay fences. Capability caches are bounded or connection-scoped, and exact handoffs are consumed by the next authoritative snapshot."
|
||||
"evidence": "Agent-session reconciliation runs only at explicit claim admission, dedupes concurrent provider listing, and adds no polling or renderer output work. Completion inspection reuses the coordinator's per-pane in-flight guard, global concurrency/rate queue, and existing error backoff; the strict daemon path reduces two foreground RPCs to one. Create-operation ledgers are capped globally and per client, expire after 24 hours, and reject rather than evict live replay fences. Capability caches are bounded or connection-scoped, and exact handoffs are consumed by the next authoritative snapshot."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Run the focused gate and remote-server repro for at least 100 consecutive passes or 14 days across required CI platforms.",
|
||||
"Attach saved red/green evidence for duplicate remote resume and exit-before-snapshot retirement.",
|
||||
"Add the primary live journey with a headed Orca desktop server and a separate paired client; use a physical host when OS, ConPTY, update, sleep, firewall, or window lifecycle is causal.",
|
||||
"Automate the headed Orca desktop-server journey with a separate paired client; add a physical host only when OS, ConPTY, update, sleep, firewall, or window lifecycle is causal.",
|
||||
"Add live SSH/WSL provider evidence before claiming full provider coverage; Docker SSH proves only the SSH provider/relay path."
|
||||
],
|
||||
"knownGaps": [
|
||||
"The primary live topology—a headed Orca desktop server with an isolated profile paired to a separate persistent client—has not been run for this issue. The causal boundary is host membership rather than OS/window/ConPTY/update/sleep behavior, so a physical headed host was not required for the current disposition.",
|
||||
"The primary headed macOS desktop-server journey is collected but not automated in CI; Windows and Linux window, ConPTY, update, sleep/wake, and firewall behavior remain uncollected.",
|
||||
"Mixed-version pairings remain conservative only when the completion-aware client and strict-inspection host changes are both present; older peers retain their legacy classification behavior.",
|
||||
"The secondary headless parity harness runs on macOS with a local daemon-backed execution owner and independent short-lived encrypted RPC clients; two persistent viewer-store mirrors and reconnect ordering are joined deterministically in the cross-boundary unit test rather than mounted live.",
|
||||
"SSH and relay failure ordering is deterministic provider-contract coverage, not a live SSH-host journey or paired-Orca-server proof; WSL has no provider-specific run.",
|
||||
"SSH and relay failure ordering is deterministic provider-contract coverage, not a live SSH-host journey or paired-Orca-server proof; WSL has no provider-specific run, and Linux and Windows runs remain uncollected.",
|
||||
"Fresh-launch operation replay is memory-backed and intentionally does not survive runtime restart; a durable operation journal is a documented future extension.",
|
||||
"Automatic sleep checkpoints, verified nested-SSH execution namespaces, and multi-process profile coordination remain outside v1."
|
||||
],
|
||||
@@ -2461,11 +2552,107 @@
|
||||
],
|
||||
"demotionRule": "Cannot promote while Windows E2E is flaky, silently skipped, or screenshot-only."
|
||||
},
|
||||
{
|
||||
"id": "terminal-performance.cold-restore-replay-budget",
|
||||
"title": "Daemon cold restore keeps replay work and retained payloads bounded",
|
||||
"maturity": "experimental",
|
||||
"protection": "partial",
|
||||
"owner": "terminal-runtime",
|
||||
"layer": "main-daemon-unit",
|
||||
"surfaces": [
|
||||
"startup restore",
|
||||
"daemon history replay",
|
||||
"sleep and hibernation restore",
|
||||
"main-process memory"
|
||||
],
|
||||
"platforms": ["macos", "linux", "windows"],
|
||||
"providers": ["daemon", "wsl"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["daemon"],
|
||||
"coverageNotes": "Deterministic main-process tests cover byte-bounded cache eviction and ACK release, one-at-a-time replay admission, a fixed per-turn replay budget within one large output record, UTF-16 boundary preservation, and checkpoint-only restore bypass while another replay is paused. The same HistoryReader path carries WSL context, but live WSL and cross-platform startup-scale runs remain gaps.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/issues/9971",
|
||||
"https://github.com/stablyai/orca/pull/9990",
|
||||
"https://github.com/stablyai/orca/issues/9441"
|
||||
],
|
||||
"invariant": "Cold restore must reproduce persisted terminal output while admitting at most one scratch-emulator replay, yielding after at most 64 Ki UTF-16 code units or 1,024 replay operations, keeping sticky restore payloads within 16 MiB, and allowing header-only checkpoint restores to bypass the replay queue.",
|
||||
"oracle": "Pause setImmediate during two single-batch restores larger than one replay slice and require exactly one admitted yield at a time, preserved text across a surrogate-pair slice boundary, and a concurrent header-only checkpoint restore to finish without consuming a replay slot. Cache tests require least-recently-used eviction, rejection of one oversized payload, and zero retained cache bytes after renderer ACK.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/cold-restore-payload-cache.test.ts src/main/daemon/history-reader.test.ts src/main/daemon/terminal-history-incremental-restore.test.ts src/main/daemon/hibernation-cold-restore-repro.test.ts src/main/daemon/daemon-pty-adapter.test.ts"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/main/daemon/cold-restore-payload-cache.test.ts",
|
||||
"src/main/daemon/history-reader.test.ts",
|
||||
"src/main/daemon/terminal-history-incremental-restore.test.ts",
|
||||
"src/main/daemon/hibernation-cold-restore-repro.test.ts",
|
||||
"src/main/daemon/daemon-pty-adapter.test.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/main/daemon/terminal-history-incremental-restore.test.ts",
|
||||
"assertions": [
|
||||
"large single-batch replays yield within the record, preserve a surrogate pair at the slice boundary, and admit only one scratch replay at a time",
|
||||
"a header-only checkpoint restore completes while an unrelated incremental replay is paused"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/daemon/cold-restore-payload-cache.test.ts",
|
||||
"assertions": [
|
||||
"least-recently-used payloads are evicted to the aggregate byte budget",
|
||||
"one payload larger than the entire budget is not retained"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/daemon/daemon-pty-adapter.test.ts",
|
||||
"assertions": [
|
||||
"StrictMode remount receives sticky cold-restore data until renderer ACK clears its retained bytes"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-07-22",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/cold-restore-payload-cache.test.ts src/main/daemon/history-reader.test.ts src/main/daemon/terminal-history-incremental-restore.test.ts src/main/daemon/hibernation-cold-restore-repro.test.ts src/main/daemon/daemon-pty-adapter.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 5.68,
|
||||
"summary": "Five focused files passed 150 tests, including deterministic single-record replay slicing, one-at-a-time admission, UTF-16 boundary preservation, header-only queue bypass, byte-bounded LRU eviction, and ACK cleanup."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 15,
|
||||
"scope": "focused main-process cold-restore unit contract"
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "unknown",
|
||||
"evidence": "The focused deterministic slice is new and has no CI or soak history yet."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "partial",
|
||||
"evidence": "The prior implementation had no yield inside one large batch and queued header-only restores behind the shared semaphore by inspection; an intentional pre-fix test run was not recorded."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "Production admits one emulator replay globally, yields after a deterministic 64 Ki character or 1,024-operation budget even within one record, bypasses the semaphore for the common header-only final-checkpoint path, and caps sticky payloads at 16 MiB. No polling, subprocess, session inventory, or renderer wake loop is added."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Record an intentional-break red run for both the within-record yield and header-only bypass assertions.",
|
||||
"Collect startup event-loop-delay evidence with dozens of near-cap histories on representative macOS, Windows, and Linux hardware.",
|
||||
"Add live WSL restore evidence before claiming WSL coverage."
|
||||
],
|
||||
"knownGaps": [
|
||||
"The log decoder and final headless snapshot serialization remain synchronous inside the one-at-a-time replay slot; the gate bounds replay writes, not every CPU phase.",
|
||||
"No live Electron startup-scale run currently proves first-pane paint order or end-to-end restore latency with dozens of histories.",
|
||||
"SSH, remote-runtime, relay, and mobile do not use this local daemon history reader and are unaffected."
|
||||
],
|
||||
"demotionRule": "Keep experimental or demote to protection none if output differs across replay slices, header-only restores consume a replay slot, retained payload bytes exceed the cap, or the focused gate flakes."
|
||||
},
|
||||
{
|
||||
"id": "terminal-performance.no-hot-list-sessions",
|
||||
"title": "Hot terminal interactions do not call global PTY session listing",
|
||||
"maturity": "experimental",
|
||||
"protection": "none",
|
||||
"protection": "partial",
|
||||
"owner": "terminal-performance",
|
||||
"layer": "ipc-count-contract",
|
||||
"surfaces": [
|
||||
@@ -2489,19 +2676,74 @@
|
||||
"wsl",
|
||||
"remote-runtime"
|
||||
],
|
||||
"coveredPlatforms": [],
|
||||
"coveredPlatforms": [
|
||||
"macos"
|
||||
],
|
||||
"coveredProviders": [],
|
||||
"coverageNotes": "Registered gap on main. The targeted-hasPty product hardening and no-hot count assertions exist only on the pending reliability stack. It registers here with its owning split PR.",
|
||||
"coverageNotes": "Platform-neutral unit coverage proves the Resource Manager closed badge performs one readiness seed, coalesces unknown spawn signals, skips known-session reattach signals, and installs no interval. Broader terminal interaction coverage remains on the pending reliability stack.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/pull/7002",
|
||||
"https://github.com/stablyai/orca/pull/6858"
|
||||
"https://github.com/stablyai/orca/pull/6858",
|
||||
"https://github.com/stablyai/orca/issues/9386",
|
||||
"https://github.com/stablyai/orca/pull/9387"
|
||||
],
|
||||
"invariant": "Typing, focus, terminal switch, workspace switch, visibility resume, resize, render, and per-pane liveness paths must not call global pty:listSessions; they must use targeted per-PTY APIs or cached provider-owned state.",
|
||||
"oracle": "The current executable slice asserts targeted visibility/first-input liveness, resize re-assertion after visibility resume, light tab/active-state resume, SSH/remote skip behavior, and closed Resource Manager status badges avoid pty:listSessions; targeted hasPty/getSize calls are allowed for liveness/resize slices and forbidden for light tab/active-state resume. The full hot-path oracle still needs instrumentation around raw focus, split focus, workspace switch, render ticks, and high-session local/daemon/SSH fixtures.",
|
||||
"commands": [],
|
||||
"testFiles": [],
|
||||
"assertionRefs": [],
|
||||
"evidenceRuns": [],
|
||||
"oracle": "The current executable slice asserts targeted visibility/first-input liveness, resize re-assertion after visibility resume, light tab/active-state resume, SSH/remote skip behavior, and a closed Resource Manager budget of one readiness seed plus one coalesced inventory read only for unknown spawn IDs; known-session reattach signals and steady closed time perform zero reads. Targeted hasPty/getSize calls are allowed for liveness/resize slices and forbidden for light tab/active-state resume. The full hot-path oracle still needs instrumentation around raw focus, split focus, workspace switch, render ticks, and high-session local/daemon/SSH fixtures.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty.test.ts src/renderer/src/components/status-bar/use-resource-session-inventory.test.tsx src/renderer/src/components/status-bar/resource-session-inventory.test.ts src/renderer/src/components/status-bar/ResourceUsageStatusSegment.session-polling.test.ts"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/main/ipc/pty.test.ts",
|
||||
"src/renderer/src/components/status-bar/use-resource-session-inventory.test.tsx",
|
||||
"src/renderer/src/components/status-bar/resource-session-inventory.test.ts",
|
||||
"src/renderer/src/components/status-bar/ResourceUsageStatusSegment.session-polling.test.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/renderer/src/components/status-bar/use-resource-session-inventory.test.tsx",
|
||||
"assertions": [
|
||||
"the false-to-true workspace readiness transition performs one daemon inventory seed",
|
||||
"a failed readiness seed surfaces an error and a later inventory refresh recovers",
|
||||
"known-session reattach signals perform zero additional inventory reads",
|
||||
"multiple unknown background spawn signals coalesce to one inventory read",
|
||||
"spawn signals during a slow inventory read never overlap provider-wide scans and cause at most one required follow-up",
|
||||
"unknown sessions that exit before reconciliation cancel their queued inventory read",
|
||||
"unmount during a slow inventory read cannot schedule follow-up work",
|
||||
"exit and out-of-order refresh races cannot resurrect stale sessions"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/ipc/pty.test.ts",
|
||||
"assertions": [
|
||||
"global inventory starts local and SSH provider listings concurrently"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/status-bar/resource-session-inventory.test.ts",
|
||||
"assertions": [
|
||||
"daemon inventory construction copies its source and preserves count parity",
|
||||
"single and batch removals preserve unrelated sessions and no-op references"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/components/status-bar/ResourceUsageStatusSegment.session-polling.test.ts",
|
||||
"assertions": [
|
||||
"the closed inventory hook installs no interval",
|
||||
"the badge count comes from cached daemon inventory rather than wake-hint bindings"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-07-22",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/pty.test.ts src/renderer/src/components/status-bar/use-resource-session-inventory.test.tsx src/renderer/src/components/status-bar/resource-session-inventory.test.ts src/renderer/src/components/status-bar/ResourceUsageStatusSegment.session-polling.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 4.3,
|
||||
"summary": "4 files and 358 tests passed, covering readiness seed/recovery, zero interval polling, bounded unknown-spawn reconciliation, concurrent provider starts, exit fencing, cleanup, and out-of-order refresh fencing."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 20,
|
||||
"scope": "unit or focused Electron count gate"
|
||||
@@ -2512,7 +2754,7 @@
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "partial",
|
||||
"evidence": "Tests assert visibility resume prefers targeted hasPty over listSessions, first input after visibility resume calls targeted hasPty once, resize re-assertion after visibility resume uses getSize/resize without listSessions, light tab switches and visible active-state resume avoid listSessions/hasPty/getSize fanout while still allowing the active PTY scheduler hint, SSH/remote broad listing is skipped, Resource Manager broad session inventory polling is scoped to the open popover rather than its closed badge, and panes close only on authoritative false. Needs broader raw focus/workspace-switch/render/high-session count coverage before promotion."
|
||||
"evidence": "Tests assert visibility resume prefers targeted hasPty over listSessions, first input after visibility resume calls targeted hasPty once, resize re-assertion after visibility resume uses getSize/resize without listSessions, light tab switches and visible active-state resume avoid listSessions/hasPty/getSize fanout while still allowing the active PTY scheduler hint, SSH/remote broad listing is skipped, and the closed Resource Manager performs one readiness seed while known reattach signals and steady time perform no additional reads. Needs broader raw focus/workspace-switch/render/high-session count coverage before promotion."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
@@ -2524,8 +2766,8 @@
|
||||
"Run with enough preserved sessions/providers to make a broad listing observable."
|
||||
],
|
||||
"knownGaps": [
|
||||
"No executable coverage on main yet; the slice lives on the pending fix-terminal-reliability stack.",
|
||||
"Current command covers targeted visibility/first-input liveness, resize re-assertion on visibility resume, light tab/active-state resume, SSH/remote skip behavior, and closed Resource Manager session-poll avoidance, but not every hot interaction listed in the invariant.",
|
||||
"Current commands cover Resource Manager readiness/lifecycle inventory counts; the broader targeted-liveness slice still lives on the pending fix-terminal-reliability stack.",
|
||||
"Current coverage includes the closed Resource Manager's no-interval and known-reattach budgets, but not every hot interaction listed in the invariant.",
|
||||
"No Electron or IPC-level high-session counter gate yet proves raw focus, workspace switch, render, or high-session typing stay at zero global listSessions calls."
|
||||
],
|
||||
"demotionRule": "Cannot promote if the test allows broad listing in any hot interaction path."
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { buildWindowsCommandInvocation } from '../../src/main/claude-accounts/windows-command-invocation.ts'
|
||||
|
||||
const strategy = process.argv[2]
|
||||
if (!['baseline', 'candidate', 'explicit-cmd'].includes(strategy)) {
|
||||
throw new Error(
|
||||
'Usage: node config/scripts/claude-account-windows-spawn-repro.mjs <baseline|candidate|explicit-cmd>'
|
||||
)
|
||||
}
|
||||
if (process.platform !== 'win32') {
|
||||
throw new Error('This reproduction requires a physical Windows host.')
|
||||
}
|
||||
|
||||
const expectedArgs = [
|
||||
'',
|
||||
'two words',
|
||||
'amp&ersand',
|
||||
'pipe|value',
|
||||
'less<value',
|
||||
'greater>value',
|
||||
'caret^value',
|
||||
'trailing\\',
|
||||
'two-trailing\\\\',
|
||||
'(parentheses)',
|
||||
'100%',
|
||||
'%ORCA_ARG_TRAP%',
|
||||
'bang!value',
|
||||
'한글-λ'
|
||||
]
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), 'orca-claude-spawn-'))
|
||||
const reportedDir = join(tempRoot, 'Profile with spaces 한글')
|
||||
const reportedCapturePath = join(reportedDir, 'capture.json')
|
||||
const reportedPidPath = join(reportedDir, 'pids.json')
|
||||
const reportedShimPath = join(reportedDir, 'claude fixture.cmd')
|
||||
const reportedFixturePath = join(reportedDir, 'capture-child.cjs')
|
||||
const fixtureDir = join(tempRoot, 'Profile space & ^ (paren) %ORCA_PATH_TRAP% !bang! 한글')
|
||||
const capturePath = join(fixtureDir, 'capture.json')
|
||||
const pidPath = join(fixtureDir, 'pids.json')
|
||||
const shimPath = join(fixtureDir, 'claude fixture.cmd')
|
||||
const fixturePath = join(fixtureDir, 'capture-child.cjs')
|
||||
const fixtureEnv = {
|
||||
...process.env,
|
||||
CLAUDE_CONFIG_DIR: join(fixtureDir, 'config space & ^ (paren) %ORCA_ENV_LITERAL% !bang! 한글'),
|
||||
ORCA_ARG_TRAP: 'EXPANDED_ARG',
|
||||
ORCA_PATH_TRAP: 'EXPANDED_PATH',
|
||||
ORCA_FIXTURE_CAPTURE: capturePath,
|
||||
ORCA_FIXTURE_PIDS: pidPath,
|
||||
ORCA_FIXTURE_NODE: process.execPath
|
||||
}
|
||||
const reportedEnv = {
|
||||
...fixtureEnv,
|
||||
CLAUDE_CONFIG_DIR: join(reportedDir, 'config with spaces 한글'),
|
||||
ORCA_FIXTURE_CAPTURE: reportedCapturePath,
|
||||
ORCA_FIXTURE_PIDS: reportedPidPath
|
||||
}
|
||||
|
||||
function quoteForCandidate(value) {
|
||||
return `"${value.replace(/"/g, '""')}"`
|
||||
}
|
||||
|
||||
function launch(args, command = shimPath, env = fixtureEnv) {
|
||||
if (strategy === 'baseline') {
|
||||
return spawn(command, args, { cwd: tempRoot, env, shell: true, windowsHide: true })
|
||||
}
|
||||
if (strategy === 'candidate') {
|
||||
return spawn(quoteForCandidate(command), args, {
|
||||
cwd: tempRoot,
|
||||
env,
|
||||
shell: true,
|
||||
windowsHide: true
|
||||
})
|
||||
}
|
||||
const invocation = buildWindowsCommandInvocation(command, args)
|
||||
return spawn(invocation.command, invocation.args, {
|
||||
cwd: tempRoot,
|
||||
env,
|
||||
shell: false,
|
||||
windowsVerbatimArguments: invocation.windowsVerbatimArguments,
|
||||
windowsHide: true
|
||||
})
|
||||
}
|
||||
|
||||
function collect(child) {
|
||||
return new Promise((resolve) => {
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
child.stdout?.on('data', (chunk) => (stdout += chunk.toString()))
|
||||
child.stderr?.on('data', (chunk) => (stderr += chunk.toString()))
|
||||
child.on('error', (error) => resolve({ code: null, stdout, stderr, error: error.message }))
|
||||
child.on('close', (code) => resolve({ code, stdout, stderr, error: null }))
|
||||
})
|
||||
}
|
||||
|
||||
async function waitForFile(path, timeoutMs = 5_000) {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
return JSON.parse(await readFile(path, 'utf8'))
|
||||
} catch {
|
||||
await new Promise((resolve) => setTimeout(resolve, 25))
|
||||
}
|
||||
}
|
||||
throw new Error(`Timed out waiting for fixture output: ${path}`)
|
||||
}
|
||||
|
||||
async function taskExists(pid) {
|
||||
const result = await collect(
|
||||
spawn('tasklist.exe', ['/fi', `PID eq ${pid}`, '/fo', 'csv', '/nh'], {
|
||||
windowsHide: true
|
||||
})
|
||||
)
|
||||
if (result.error || result.code !== 0) {
|
||||
throw new Error(`tasklist failed for PID ${pid}: ${result.error ?? result.stderr}`)
|
||||
}
|
||||
return result.stdout.includes(`"${pid}"`)
|
||||
}
|
||||
|
||||
async function killTree(pid) {
|
||||
const result = await collect(
|
||||
spawn('taskkill.exe', ['/pid', String(pid), '/t', '/f'], { windowsHide: true })
|
||||
)
|
||||
if (result.error || result.code !== 0) {
|
||||
throw new Error(`taskkill failed for PID ${pid}: ${result.error ?? result.stderr}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForTreeExit(pids, timeoutMs = 5_000) {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
let alive = {}
|
||||
do {
|
||||
alive = Object.fromEntries(
|
||||
await Promise.all(
|
||||
Object.entries(pids).map(async ([name, pid]) => [name, await taskExists(pid)])
|
||||
)
|
||||
)
|
||||
if (!Object.values(alive).some(Boolean)) {
|
||||
return alive
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
} while (Date.now() < deadline)
|
||||
return alive
|
||||
}
|
||||
|
||||
const results = {
|
||||
strategy,
|
||||
reportedPath: null,
|
||||
pathMatrix: {},
|
||||
argvMatrix: {},
|
||||
hostilePathAndArgv: null,
|
||||
error: null,
|
||||
cancellation: null
|
||||
}
|
||||
const fixtureSource =
|
||||
`const { spawn } = require('node:child_process')\n` +
|
||||
`const { writeFileSync } = require('node:fs')\n` +
|
||||
`if (process.argv[2] === '--exit-error') { process.stderr.write('fixture error: 한글 & ^ % !\\n'); process.exit(23) }\n` +
|
||||
`if (process.argv[2] === '--linger') {\n` +
|
||||
` const grandchild = spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { windowsHide: true })\n` +
|
||||
` writeFileSync(process.env.ORCA_FIXTURE_PIDS, JSON.stringify({ child: process.pid, grandchild: grandchild.pid }))\n` +
|
||||
` setInterval(() => {}, 1000)\n` +
|
||||
`} else {\n` +
|
||||
` writeFileSync(process.env.ORCA_FIXTURE_CAPTURE, JSON.stringify({ argv: process.argv.slice(2), configDir: process.env.CLAUDE_CONFIG_DIR }))\n` +
|
||||
`}\n`
|
||||
const shimSource = '@echo off\r\n"%ORCA_FIXTURE_NODE%" "%~dp0capture-child.cjs" %*\r\n'
|
||||
let lingeringShellPid = null
|
||||
try {
|
||||
await mkdir(fixtureDir, { recursive: true })
|
||||
await mkdir(reportedDir, { recursive: true })
|
||||
await writeFile(fixturePath, fixtureSource, 'utf8')
|
||||
await writeFile(shimPath, shimSource, 'utf8')
|
||||
await writeFile(reportedFixturePath, fixtureSource, 'utf8')
|
||||
await writeFile(reportedShimPath, shimSource, 'utf8')
|
||||
|
||||
const reportedArgs = ['auth', 'status', '--json']
|
||||
const reportedRun = await collect(launch(reportedArgs, reportedShimPath, reportedEnv))
|
||||
let reportedCapture = null
|
||||
try {
|
||||
reportedCapture = await waitForFile(reportedCapturePath, 1_000)
|
||||
} catch {}
|
||||
results.reportedPath = {
|
||||
...reportedRun,
|
||||
actual: reportedCapture,
|
||||
expected: { argv: reportedArgs, configDir: reportedEnv.CLAUDE_CONFIG_DIR },
|
||||
pass:
|
||||
reportedRun.code === 0 &&
|
||||
JSON.stringify(reportedCapture) ===
|
||||
JSON.stringify({ argv: reportedArgs, configDir: reportedEnv.CLAUDE_CONFIG_DIR })
|
||||
}
|
||||
|
||||
for (const [name, segment] of Object.entries({
|
||||
spaces: 'profile space',
|
||||
ampersand: 'profile&name',
|
||||
caret: 'profile^name',
|
||||
parentheses: 'profile(name)',
|
||||
percent: 'profile%ORCA_PATH_TRAP%',
|
||||
bang: 'profile!name',
|
||||
unicode: 'profile-한글-λ'
|
||||
})) {
|
||||
const directory = join(tempRoot, segment)
|
||||
const captureFile = join(directory, 'capture.json')
|
||||
const command = join(directory, 'claude fixture.cmd')
|
||||
await mkdir(directory, { recursive: true })
|
||||
await writeFile(join(directory, 'capture-child.cjs'), fixtureSource, 'utf8')
|
||||
await writeFile(command, shimSource, 'utf8')
|
||||
const env = {
|
||||
...fixtureEnv,
|
||||
CLAUDE_CONFIG_DIR: join(directory, 'config'),
|
||||
ORCA_FIXTURE_CAPTURE: captureFile
|
||||
}
|
||||
const run = await collect(launch(reportedArgs, command, env))
|
||||
let actual = null
|
||||
try {
|
||||
actual = await waitForFile(captureFile, 500)
|
||||
} catch {}
|
||||
results.pathMatrix[name] = {
|
||||
code: run.code,
|
||||
stderr: run.stderr.trim(),
|
||||
actual: actual?.argv ?? null,
|
||||
pass: run.code === 0 && JSON.stringify(actual?.argv) === JSON.stringify(reportedArgs)
|
||||
}
|
||||
}
|
||||
|
||||
for (const [name, value] of Object.entries({
|
||||
empty: '',
|
||||
spaces: 'two words',
|
||||
ampersand: 'amp&ersand',
|
||||
pipe: 'pipe|value',
|
||||
lessThan: 'less<value',
|
||||
greaterThan: 'greater>value',
|
||||
caret: 'caret^value',
|
||||
trailingBackslash: 'trailing\\',
|
||||
twoTrailingBackslashes: 'two-trailing\\\\',
|
||||
parentheses: '(parentheses)',
|
||||
percent: '%ORCA_ARG_TRAP%',
|
||||
bang: 'bang!value',
|
||||
unicode: '한글-λ'
|
||||
})) {
|
||||
const args = ['prefix', value, 'suffix']
|
||||
const captureFile = join(reportedDir, `capture-${name}.json`)
|
||||
const env = { ...reportedEnv, ORCA_FIXTURE_CAPTURE: captureFile }
|
||||
const run = await collect(launch(args, reportedShimPath, env))
|
||||
let actual = null
|
||||
try {
|
||||
actual = await waitForFile(captureFile, 500)
|
||||
} catch {}
|
||||
results.argvMatrix[name] = {
|
||||
code: run.code,
|
||||
stderr: run.stderr.trim(),
|
||||
actual: actual?.argv ?? null,
|
||||
pass: run.code === 0 && JSON.stringify(actual?.argv) === JSON.stringify(args)
|
||||
}
|
||||
}
|
||||
|
||||
const argvRun = await collect(launch(expectedArgs))
|
||||
let capture = null
|
||||
try {
|
||||
capture = await waitForFile(capturePath, 1_000)
|
||||
} catch {}
|
||||
results.hostilePathAndArgv = {
|
||||
...argvRun,
|
||||
actual: capture,
|
||||
expected: { argv: expectedArgs, configDir: fixtureEnv.CLAUDE_CONFIG_DIR },
|
||||
pass:
|
||||
argvRun.code === 0 &&
|
||||
JSON.stringify(capture) ===
|
||||
JSON.stringify({ argv: expectedArgs, configDir: fixtureEnv.CLAUDE_CONFIG_DIR })
|
||||
}
|
||||
|
||||
results.error = await collect(launch(['--exit-error'], reportedShimPath, reportedEnv))
|
||||
results.error.pass =
|
||||
results.error.code === 23 && results.error.stderr.includes('fixture error: 한글 & ^ % !')
|
||||
|
||||
const lingering = launch(['--linger'], reportedShimPath, reportedEnv)
|
||||
lingeringShellPid = lingering.pid
|
||||
const lingeringResult = collect(lingering)
|
||||
try {
|
||||
const pids = await waitForFile(reportedPidPath)
|
||||
await killTree(lingering.pid)
|
||||
const alive = await waitForTreeExit({
|
||||
shell: lingering.pid,
|
||||
child: pids.child,
|
||||
grandchild: pids.grandchild
|
||||
})
|
||||
results.cancellation = {
|
||||
shell: lingering.pid,
|
||||
...pids,
|
||||
alive,
|
||||
pass: !Object.values(alive).some(Boolean)
|
||||
}
|
||||
} catch (error) {
|
||||
if (await taskExists(lingering.pid)) {
|
||||
await killTree(lingering.pid)
|
||||
}
|
||||
const launchResult = await Promise.race([
|
||||
lingeringResult,
|
||||
new Promise((resolve) =>
|
||||
setTimeout(
|
||||
() => resolve({ code: null, error: 'fixture did not exit after cleanup' }),
|
||||
5_000
|
||||
)
|
||||
)
|
||||
])
|
||||
results.cancellation = {
|
||||
shell: lingering.pid,
|
||||
launchResult,
|
||||
pass: false,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (lingeringShellPid && (await taskExists(lingeringShellPid))) {
|
||||
await killTree(lingeringShellPid)
|
||||
}
|
||||
try {
|
||||
let pids
|
||||
try {
|
||||
pids = JSON.parse(await readFile(reportedPidPath, 'utf8'))
|
||||
} catch {
|
||||
pids = JSON.parse(await readFile(pidPath, 'utf8'))
|
||||
}
|
||||
for (const pid of [pids.child, pids.grandchild]) {
|
||||
if (await taskExists(pid)) {
|
||||
await killTree(pid)
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
await rm(tempRoot, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(results, null, 2))
|
||||
process.exitCode =
|
||||
results.reportedPath?.pass &&
|
||||
Object.values(results.pathMatrix).every((result) => result.pass) &&
|
||||
Object.values(results.argvMatrix).every((result) => result.pass) &&
|
||||
results.hostilePathAndArgv?.pass &&
|
||||
results.error?.pass &&
|
||||
results.cancellation?.pass
|
||||
? 0
|
||||
: 1
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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,10 +23,15 @@
|
||||
"../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-preservation.ts",
|
||||
"../src/main/codex/codex-config-settings-removal.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",
|
||||
@@ -34,7 +43,11 @@
|
||||
"../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-baseline.ts",
|
||||
"../src/main/codex/config-settings-conflict-resolution.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",
|
||||
@@ -42,12 +55,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.8m">
|
||||
<title>downloads: 7.8m</title>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 8.1m">
|
||||
<title>downloads: 8.1m</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.8m</text>
|
||||
<text x="93.5" y="14">7.8m</text>
|
||||
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">8.1m</text>
|
||||
<text x="93.5" y="14">8.1m</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 943 B After Width: | Height: | Size: 943 B |
+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,7 +179,7 @@ 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'
|
||||
@@ -209,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 +230,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 +902,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 +1728,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 +1847,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 +1860,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 +1887,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 +1902,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 +2223,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 +2498,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 +2535,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 +2558,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 +3627,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
|
||||
@@ -4074,7 +4068,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) {
|
||||
@@ -4353,6 +4363,7 @@ export default function SessionScreen() {
|
||||
hostedChecksSupported: prIsGithubRepo
|
||||
})
|
||||
const showHeaderMoreButton = showAgentSessionHistoryAction || showChecksAction
|
||||
const createTabBusy = creating || creatingBrowser || creatingMarkdown
|
||||
|
||||
return (
|
||||
<View ref={setMobileSessionRootRef} style={styles.container}>
|
||||
@@ -4555,24 +4566,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>
|
||||
@@ -4689,8 +4692,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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { saveMobileClipboardImageAsTempFile } from './mobile-clipboard-image'
|
||||
// Type-only import so this module (and its unit test) stays free of the expo/
|
||||
// react-native picker chain; the concrete `pickImage` is injected by the hook.
|
||||
import type { MobileImageSource, PickedMobileImage } from './mobile-image-source-picker'
|
||||
|
||||
/** A picked-and-uploaded image held in the native-chat composer until submit.
|
||||
* `path` is the host temp file pasted into the agent on send; `previewUri` is a
|
||||
* local URI used only to render the composer thumbnail. */
|
||||
export type PendingNativeChatImage = {
|
||||
readonly id: string
|
||||
readonly path: string
|
||||
readonly previewUri: string
|
||||
}
|
||||
|
||||
export type UploadNativeChatImageDeps = {
|
||||
readonly client: Pick<RpcClient, 'sendRequest'>
|
||||
readonly getConnectionId: () => Promise<string | null>
|
||||
// Injected so this module stays free of expo/react-native imports (unit-testable).
|
||||
readonly pickImage: (source: MobileImageSource) => Promise<PickedMobileImage | null>
|
||||
// Fired once the user has picked an image and the host upload is about to start —
|
||||
// lets the UI show the attach spinner only for the transfer, not the picker.
|
||||
readonly onUploadStart?: () => void
|
||||
}
|
||||
|
||||
/** Picks an image and uploads it to the host, returning the host path + a local
|
||||
* preview URI — but does NOT paste it into the terminal. Unlike the terminal
|
||||
* attach flow, native chat holds the image as a composer chip and rides it along
|
||||
* on submit (desktop parity), so the chip and the agent input never diverge.
|
||||
* Returns null when the user cancels the picker. */
|
||||
export async function uploadMobileNativeChatImage(
|
||||
source: MobileImageSource,
|
||||
{ client, getConnectionId, pickImage, onUploadStart }: UploadNativeChatImageDeps
|
||||
): Promise<Omit<PendingNativeChatImage, 'id'> | null> {
|
||||
const picked = await pickImage(source)
|
||||
if (!picked) {
|
||||
return null
|
||||
}
|
||||
onUploadStart?.()
|
||||
const connectionId = await getConnectionId()
|
||||
const path = await saveMobileClipboardImageAsTempFile(client, picked.base64, { connectionId })
|
||||
// Prefer the picker's local URI for the thumbnail; fall back to an inline data
|
||||
// URI when the source omitted one (RN <Image> renders both).
|
||||
const previewUri = picked.uri ?? `data:image/png;base64,${picked.base64}`
|
||||
return { path, previewUri }
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isRenderableImageUri } from './mobile-native-chat-image-preview'
|
||||
|
||||
describe('isRenderableImageUri', () => {
|
||||
it('accepts local previews and real URLs the device can load', () => {
|
||||
for (const uri of [
|
||||
'file:///var/mobile/a.jpg',
|
||||
'data:image/png;base64,AAAA',
|
||||
'content://media/1',
|
||||
'blob:abc',
|
||||
'http://host/a.png',
|
||||
'https://host/a.png'
|
||||
]) {
|
||||
expect(isRenderableImageUri(uri)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects bare host paths (not loadable on the device) and empty values', () => {
|
||||
for (const uri of [
|
||||
'/tmp/orca-attach.png',
|
||||
'C:\\tmp\\a.png',
|
||||
'orca-attach.png',
|
||||
'',
|
||||
undefined
|
||||
]) {
|
||||
expect(isRenderableImageUri(uri)).toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
// A URI RN <Image> can actually load: a local composer/echo preview (file://,
|
||||
// data:, content://, blob:) or a real remote URL. A bare host path from the
|
||||
// transcript (e.g. /tmp/x.png on an SSH host) is not loadable on the device, so
|
||||
// it stays a text placeholder instead of a broken image.
|
||||
const RENDERABLE_IMAGE_URI = /^(file:|data:|https?:|content:|blob:)/i
|
||||
|
||||
export function isRenderableImageUri(uri: string | undefined): uri is string {
|
||||
return typeof uri === 'string' && RENDERABLE_IMAGE_URI.test(uri)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcResponse, RpcSuccess } from '../transport/types'
|
||||
import { pasteMobileNativeChatImagePaths } from './mobile-native-chat-image-send'
|
||||
|
||||
function sendResult(accepted: boolean, id = 'send'): RpcSuccess {
|
||||
return { id, ok: true, result: { send: { accepted } }, _meta: { runtimeId: 'r' } }
|
||||
}
|
||||
|
||||
function clientWithResponses(responses: RpcResponse[]): Pick<RpcClient, 'sendRequest'> & {
|
||||
calls: { method: string; params: Record<string, unknown> }[]
|
||||
} {
|
||||
const calls: { method: string; params: Record<string, unknown> }[] = []
|
||||
return {
|
||||
calls,
|
||||
sendRequest: vi.fn(async (method: string, params?: unknown) => {
|
||||
calls.push({ method, params: params as Record<string, unknown> })
|
||||
const response = responses.shift()
|
||||
if (!response) {
|
||||
throw new Error(`unexpected request: ${method}`)
|
||||
}
|
||||
return response
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
describe('pasteMobileNativeChatImagePaths', () => {
|
||||
it('clears the input line, then pastes each path as a bracketed, non-submitting terminal.send with the mobile client tag', async () => {
|
||||
const client = clientWithResponses([sendResult(true), sendResult(true), sendResult(true)])
|
||||
|
||||
const ok = await pasteMobileNativeChatImagePaths({
|
||||
client,
|
||||
terminal: 'term-1',
|
||||
deviceToken: 'device-9',
|
||||
imagePaths: ['/tmp/a.png', '/tmp/b.png']
|
||||
})
|
||||
|
||||
expect(ok).toBe(true)
|
||||
expect(client.calls).toHaveLength(3)
|
||||
// Leading Ctrl+U clears any stale input so a retry can't duplicate the image.
|
||||
expect(client.calls[0]).toEqual({
|
||||
method: 'terminal.send',
|
||||
params: {
|
||||
terminal: 'term-1',
|
||||
text: '\x15',
|
||||
enter: false,
|
||||
client: { id: 'device-9', type: 'mobile' }
|
||||
}
|
||||
})
|
||||
expect(client.calls[1]?.params.text).toBe('\x1b[200~/tmp/a.png\x1b[201~')
|
||||
expect(client.calls[2]?.params.text).toBe('\x1b[200~/tmp/b.png\x1b[201~')
|
||||
})
|
||||
|
||||
it('stops and reports failure as soon as a paste is rejected', async () => {
|
||||
// Clear accepted, first image paste rejected.
|
||||
const client = clientWithResponses([sendResult(true), sendResult(false)])
|
||||
|
||||
const ok = await pasteMobileNativeChatImagePaths({
|
||||
client,
|
||||
terminal: 'term-1',
|
||||
deviceToken: null,
|
||||
imagePaths: ['/tmp/a.png', '/tmp/b.png']
|
||||
})
|
||||
|
||||
expect(ok).toBe(false)
|
||||
// Never attempts the second path after the first is rejected.
|
||||
expect(client.calls).toHaveLength(2)
|
||||
expect(client.calls[1]?.params.text).toBe('\x1b[200~/tmp/a.png\x1b[201~')
|
||||
expect(client.calls[0]?.params).not.toHaveProperty('client')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { buildMobileImagePastePayload } from './mobile-clipboard-image'
|
||||
import { isTerminalSendRpcAccepted } from '../terminal/terminal-send-rpc-response'
|
||||
|
||||
// Give the agent TUI a beat to register each bracketed image paste before the
|
||||
// message text + Enter arrive, so the image attaches instead of being treated as
|
||||
// part of the prompt body (mirrors desktop's NATIVE_CHAT_IMAGE_ATTACHMENT_SETTLE_MS).
|
||||
export const MOBILE_NATIVE_CHAT_IMAGE_SETTLE_MS = 300
|
||||
|
||||
// Ctrl+U kills the agent's unsubmitted input line. Sent before pasting so a retry
|
||||
// after a rejected body/Enter can't leave a stale image paste that then rides along
|
||||
// with (and duplicates) the next attempt — matches desktop clearUnsubmittedAgentInput.
|
||||
const MOBILE_NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT = '\x15'
|
||||
|
||||
type MobileTerminalClient = { id: string; type: 'mobile' }
|
||||
|
||||
type PasteImagesArgs = {
|
||||
readonly client: Pick<RpcClient, 'sendRequest'>
|
||||
readonly terminal: string
|
||||
readonly deviceToken: string | null
|
||||
readonly imagePaths: readonly string[]
|
||||
}
|
||||
|
||||
/** Clears the agent's unsubmitted input line, then pastes each uploaded image
|
||||
* path into the terminal as a bracketed paste (no Enter) — the same payload
|
||||
* desktop native chat rides along on submit. The leading clear keeps a retry
|
||||
* idempotent after a failed body/Enter. Returns false as soon as the host rejects
|
||||
* one, so the caller can abort before Enter. */
|
||||
export async function pasteMobileNativeChatImagePaths({
|
||||
client,
|
||||
terminal,
|
||||
deviceToken,
|
||||
imagePaths
|
||||
}: PasteImagesArgs): Promise<boolean> {
|
||||
const mobileClient: MobileTerminalClient | null = deviceToken
|
||||
? { id: deviceToken, type: 'mobile' }
|
||||
: null
|
||||
const clientField = mobileClient ? { client: mobileClient } : {}
|
||||
for (const text of [
|
||||
MOBILE_NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT,
|
||||
...imagePaths.map(buildMobileImagePastePayload)
|
||||
]) {
|
||||
const response = await client.sendRequest('terminal.send', {
|
||||
terminal,
|
||||
text,
|
||||
enter: false,
|
||||
...clientField
|
||||
})
|
||||
if (!isTerminalSendRpcAccepted(response)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Single-sources the marker logic (pure functions over shared types):
|
||||
// Claude records an attached image as `[Image: source: /path]` (+ `[Image #N]`
|
||||
// prefix on the caption turn), and both render and echo reconciliation must
|
||||
// agree with desktop on how those marker turns are interpreted.
|
||||
export {
|
||||
imageSourcePathFromText,
|
||||
normalizeImageTranscriptMessages,
|
||||
stripImagePromptMarker
|
||||
} from '../../../src/shared/native-chat-image-transcript-markers'
|
||||
import { imageSourcePathFromText } from '../../../src/shared/native-chat-image-transcript-markers'
|
||||
import { isTextBlock, type NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
|
||||
/** A raw (un-normalized) transcript user turn that is an image-source marker —
|
||||
* the echo shape of an image riding along on a send. */
|
||||
export function isImageSourceUserTurn(message: NativeChatMessage): boolean {
|
||||
if (message.role !== 'user' || message.blocks.length !== 1) {
|
||||
return false
|
||||
}
|
||||
const block = message.blocks[0]
|
||||
return block !== undefined && isTextBlock(block) && imageSourcePathFromText(block.text) !== null
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user