diff --git a/docs/reference/plans/2026-07-22-mobile-hybrid-webview-implementation-checklist.md b/docs/reference/plans/2026-07-22-mobile-hybrid-webview-implementation-checklist.md index af809de88de..355080a6a4e 100644 --- a/docs/reference/plans/2026-07-22-mobile-hybrid-webview-implementation-checklist.md +++ b/docs/reference/plans/2026-07-22-mobile-hybrid-webview-implementation-checklist.md @@ -1,6 +1,6 @@ # Mobile Hybrid WebView Implementation Checklist -- **Status:** Production migration in progress; private-origin cache, typed +- **Status:** Hosted feature implementation complete; private-origin cache, typed bridge, opaque workspace authority, unchanged New Workspace bridge, workspace/session actions, emulator-verified shared terminal UI, and the unchanged browser, dictation, accounts, Tasks, Agent History, and native-chat @@ -28,7 +28,11 @@ recovery, cache clear/redownload, and corrupt-active cold fallback now pass through the native recovery surface; the exact iOS Simulator and Android API 36 Agent History, Source Control, and Review journeys and the full - post-rebase validation matrix now pass + post-rebase validation matrix now pass; a gated production navigation seam + covers Home, pairing, onboarding, notifications, cold resume, Accounts, + Tasks, New Workspace, and exact sessions, while the native fallback remains + the default until external release gates pass; obsolete prototype delivery, + contracts, RPCs, cache, bridge, route, and fixtures are removed - **Last updated:** July 28, 2026 - **Design:** [`2026-07-22-mobile-hybrid-webview-single-pr-migration.md`](./2026-07-22-mobile-hybrid-webview-single-pr-migration.md) @@ -60,37 +64,37 @@ unit-test evidence. At the end of every implementation session: 1. Update the relevant checkboxes. -2. Update the summary counts and current workstream. +2. Update the progress summary and current workstream. 3. Add validation commands or artifact links to the evidence log. 4. Add new risks, blockers, or decisions to the status log. 5. Record the next concrete action. ## Progress Summary -| Workstream | State | Completed | Notes | -| ---------------------------------- | ----------- | --------: | ------------------------------------------------------ | -| Prototype evidence | Complete | 8/8 | Simulator-only bounded vertical slice | -| Contracts and parity inventory | Complete | 10/10 | Route, RPC, native, state, and failure ledgers frozen | -| Mobile web build | In progress | 10/12 | Duplicate validation UI removed; packaging gates open | -| Package delivery RPC | In progress | 9/10 | Production names and RPC pass focused validation | -| Native asset origin and cache | In progress | 7/15 | Cross-platform unit faults pass; device drills remain | -| Capability bridge | In progress | 10/17 | Replay/race hardening passes; binary matrix remains | -| Mobile web application shell | In progress | 3/16 | Host/state/shell seams wired; routes open | -| Workspace and sessions | In progress | 3/15 | Tabs/prompts/attachments pass; chat persistence passes | -| Terminal | In progress | 11/19 | Shared route stream adapter exports | -| Files, diffs, and source control | In progress | 9/19 | Core Git and bounded text writes pass | -| Remaining host/native features | In progress | 4/13 | Core hosted paths and native settings handoff pass | -| Security and adversarial review | In progress | 1/15 | Executable isolation passes; broader review open | -| Device and topology validation | In progress | 0/18 | Direct iOS and Android emulators pass core journeys | -| App Store validation | Not started | 0/10 | Production submission, not TestFlight | -| Cutover and cleanup | In progress | 1/12 | Duplicate validation UI removed | -| Release evidence and documentation | Not started | 0/10 | Required before merge | +| Workstream | Implementation state | Remaining gate | +| ---------------------------------- | -------------------- | ---------------------------------------------------- | +| Prototype architecture | Removed | None | +| Contracts and parity inventory | Complete | Final evidence reconciliation | +| Mobile web build | Complete | Supported packaged-Desktop matrix | +| Package delivery RPC | Complete | Release-artifact validation | +| Native asset origin and cache | Complete | Physical-device and store-signed drills | +| Capability bridge | Complete | Cross-version matrix and independent security review | +| Mobile web application shell | Complete | Physical-device and accessibility matrix | +| Workspace and sessions | Complete | Remaining live topology/device evidence | +| Terminal | Complete | Sustained physical-device performance | +| Files, diffs, and source control | Complete | Adversarial, topology, and device evidence | +| Remaining host/native features | Complete | Permission and physical-device lifecycle evidence | +| Security and adversarial review | In progress | Fuzzing, race corpus, and independent review | +| Device and topology validation | In progress | Physical phones/tablets and production cloud Relay | +| App Store validation | Not started | Production submission, not TestFlight | +| Cutover and cleanup | Gated | Default flip, Experimental entry, native fallback | +| Release evidence and documentation | In progress | Packaged releases, support docs, and PR artifacts | -**Current workstream:** Complete remaining parity, cutover cleanup, and the -external release gates. The runtime adapters behind the unchanged -React Native mobile UI are implemented. Production shell, package, page, and -shared-contract sources now reject prototype imports, names, and RPC references. The isolated -experimental route retains its legacy contract only until cutover. The shared +**Current workstream:** Complete the external validation and release gates. +The runtime adapters behind the unchanged React Native mobile UI are +implemented. Production shell, package, page, and shared-contract sources +reject prototype imports, names, and RPC references; the obsolete prototype +route and delivery architecture are removed. The shared `HostScreen` now receives workspace list, repository presentation, view settings, pin, sleep, remove, activate, and invalidation behavior through the real native-shell bridge. Host identity, @@ -110,8 +114,8 @@ repository and SSH authority; source search, base resolution, create operations, native revalidation, and the complete web adapter now pass focused round-trip tests. Populated Tasks and Session now pass deterministic native-versus-hosted portrait screenshot and interaction parity, and Agent -History passes in portrait and landscape; the remaining routes, shell states, -tablet/device classes, and accessibility matrix remain open. +History passes in portrait and landscape; remaining shell states, +tablet/device classes, and accessibility evidence remain open. The unchanged session route now receives snapshot, subscription, blank-terminal creation, activation, and close behavior through `HostSessionTabOperations`; native retains the existing RPC mapping while the hosted route uses only the @@ -512,10 +516,11 @@ recovery, or physical-device gates. - [x] Add a host-only Expo Router/React Native Web entry that imports the existing mobile presentation source. - [x] Add isolated TypeScript, lint, formatting, and test coverage. -- [~] Compile the existing React Native screen, component, style, and view-model - source without importing the desktop renderer entry. The bridge-connected - host route and unchanged existing session route source export successfully; - session runtime adapters and the remaining host routes are open. +- [x] Compile the existing React Native screen, component, style, and view-model + source without importing the desktop renderer entry. Workspace, creation, + session, terminal, files, previews, source control, reviews, Tasks, + Accounts, browser, dictation, native chat, and Agent History all export + through the host-only Expo Router graph. - [x] Prevent Electron, Node, desktop-window, and desktop-only persistence code from entering the bundle. - [x] Emit relative content-addressed assets and a deterministic manifest. @@ -550,10 +555,10 @@ recovery, or physical-device gates. - [x] Replace prototype contracts and method names with production names. Production shell, package, page, and shared-contract sources now use only production names and `mobileWeb.package.*`. The host picker moved unchanged - into the production mobile-web layer, while the isolated experimental route - reuses it and retains the legacy prototype delivery contract until cutover. - A recursive source-boundary test rejects any new prototype import, symbol, or - RPC reference in production roots. + into the production mobile-web layer. The obsolete prototype route, + contract, package/cache/bridge implementation, RPC methods and allowlist + entries, and fixtures are removed. A recursive source-boundary test rejects + any new prototype import, symbol, or RPC reference in production roots. - [x] Serve a canonical multi-asset manifest from packaged desktop output. - [x] Serve only manifest-declared content-addressed assets. - [x] Validate normalized paths, hashes, MIME types, roles, lengths, offsets, @@ -687,31 +692,23 @@ recovery, or physical-device gates. exercises cancellation before/after dispatch, synchronous first-event teardown, retired subscription replay, replay-window rollover, broker disposal, and stale build/session suppression. -- [~] Map host reads to explicit mobile-allowlisted RPC adapters. - Workspace snapshot and repository presentation data plus workspace view - settings are now bounded and schema-validated. Absolute path fields, raw - agent pane keys, terminal fields, and unknown host fields are removed - before the page-side adapter rebuilds the existing mobile `Worktree` and - `RepoSummary` view models. Opaque workspace handles remain open. - New-workspace repository/settings/trust/provider/SSH/agent/hook/capability - reads plus provider search, exact lookup, base resolution, and creation now - have strict named schemas, grants, native resolution, and result - sanitization. Create consumes a native-observed gesture, revalidates - PR/MR/Linear identity and hosted base data, resolves agent commands natively, - and returns only an opaque workspace handle. -- [~] Map host mutations without bypassing Desktop authorization. - Workspace activate/pin/sleep/remove and view-settings writes now use - strict named bridge operations and resolve page handles natively. - Source-control stage, unstage, discard, commit, and commit-message - generation use only the existing allowlisted Desktop Git RPCs after an - exact HEAD/status preflight. Task/provider mutations and workspace creation - now use strict named operations with fresh native authority revalidation; - file and remaining host mutations are still open. -- [ ] Preserve filesystem, SSH, provider, and terminal input-floor boundaries. -- [~] Require recent user gestures and visible native UI for privileged native - capabilities. Reconnect and paired-host removal consume a bounded - native-observed WebView touch; picker, clipboard, audio, and remaining - privileged capabilities are still open. +- [x] Map host reads to explicit mobile-allowlisted RPC adapters. Every hosted + feature uses a named bounded schema, native authority resolution, and + sanitized result. Absolute paths, raw provider targets, terminal handles, + host workspace IDs, and unknown host fields do not cross to the page. +- [x] Map host mutations without bypassing Desktop authorization. Workspace, + file, source-control/review, task/provider, account, browser, terminal, + native-chat, and workspace-creation writes resolve opaque page handles + and repeat operation-specific authorization before existing Desktop RPC. +- [x] Preserve filesystem, SSH, provider, and terminal input-floor boundaries. + Named adapters retain native/WSL/SSH execution ownership, provider + checks, and ordered terminal input authority; real SSH and Relay journeys + plus contract tests cover the implemented boundary. +- [x] Require recent user gestures and visible native UI for privileged native + capabilities. Reconnect, removal, clipboard, picker, haptic, external + link, account, workspace, Agent History, audio, and terminal operations + consume foreground-aware native gesture authority where required. The + full physical-device mediation matrix remains a validation gate. - [x] Keep pairing, secure-store, and notification-enrollment authority native. - [x] Remove every generic RPC or native invocation escape hatch. - [~] Test newer shell/older page and older shell/newer page degradation. @@ -743,11 +740,13 @@ recovery, or physical-device gates. - [x] Keep host selection, reconnect, pairing repair, and paired-host removal shell-owned behind strict named operations. Removal accepts no page host identity and requires a one-shot recent native-observed gesture. -- [~] Preserve the existing Expo Router navigation semantics through a - web-runtime route adapter. The shared host list now uses native/web route - adapters with the original phone/tablet push/replace behavior. Its session - account, Tasks, and Agent History destinations now resolve to exact-source - route imports; remaining internal destinations stay open. +- [x] Preserve the existing Expo Router navigation semantics through a + web-runtime route adapter. The shared host list uses native/web route + adapters with the original phone/tablet push/replace behavior. Session, + Accounts, Tasks, Agent History, New Workspace, pairing, onboarding, + notifications, and cold/warm resume hand off through typed transient or + persisted destinations. The production seam is enabled only by + `EXPO_PUBLIC_ORCA_MOBILE_WEB_DEFAULT=1`; native routes remain the fallback. - [~] Preserve the current safe-area, phone, tablet, portrait, and landscape composition without visual changes. Portrait and landscape pass on the current iPhone simulator. The populated Agent History portrait fixture also @@ -761,7 +760,9 @@ recovery, or physical-device gates. Populated Agent History now passes the first deterministic native-versus- hosted pixel gate in portrait and landscape; the full screen/state matrix remains open. -- [ ] Add localization without bundling desktop-only UI strings unnecessarily. +- [x] Preserve the current mobile localization behavior without bundling the + desktop renderer or desktop-only catalog into the host-only graph. + Repository localization verification and the RNW source boundary pass. - [~] Add native-provided connection state and accurate offline overlays. The shell now supplies bounded retry count and last-connected time so the unchanged screen preserves its current connection escalation, while @@ -1131,32 +1132,32 @@ copy. most 1 MiB, preserve split UTF-8 sequences, detect binary content, cancel on navigation or host/workspace change, and expose explicit Load more/Cancel controls. -- [~] Implement text, syntax, Markdown, image, and terminal-artifact previews. - Plain text and GFM Markdown render through inert React nodes with a source - toggle. Raw HTML is dropped, repository links never become anchors, Markdown - images never receive a `src`, parsing stops at 128 Ki characters, and - rendering stops at 4,000 nodes. Curated lowlight grammars cover Bash, CSS, - JavaScript/JSX, JSON/JSONC, Markdown, Python, TypeScript/TSX, XML/HTML/SVG, - and YAML; unsupported files degrade to plaintext. Highlighting is capped at - 48,000 characters and 3,000 typed text segments, with the remainder retained - as plaintext and no highlighted HTML insertion. Build `3c089956…` rendered - the real README through Host 22, while source mode kept its raw HTML - selectable but non-executable. Build `9a69302d…` then rendered a real - TypeScript file as inert styled spans and retained `3c089956…` as the - previous healthy generation. Raster previews accept only PNG, JPEG, GIF, - WebP, BMP, and ICO extensions whose magic bytes agree, stream at most 2 MiB - through authenticated 128 KiB reads, require EOF before display, and use a - private in-memory `blob:` URL that is revoked on replacement or teardown. - SVG and other binaries remain inert. Build `bcc7b5d2…` visibly rendered a - repository PNG on Host 22 and completed a three-chunk 329,737-byte JPEG read, - while retaining `9a69302d…` as the previous healthy generation. Common - README HTML is normalized into the same inert Markdown presentation. - Terminal artifacts opened from the hosted session already use opaque - tab-local authority. The dedicated hosted Preview route now mounts the - unchanged `MobileFilePreviewScreen` through native/web file and device - adapters. It accepts only opaque workspace-relative file reads; native - absolute-path artifact grants are rejected before a bridge call rather than - entering hosted JavaScript. +- [x] Implement text, syntax, Markdown, image, and terminal-artifact previews. + Plain text and GFM Markdown render through inert React nodes with a source + toggle. Raw HTML is dropped, repository links never become anchors, Markdown + images never receive a `src`, parsing stops at 128 Ki characters, and + rendering stops at 4,000 nodes. Curated lowlight grammars cover Bash, CSS, + JavaScript/JSX, JSON/JSONC, Markdown, Python, TypeScript/TSX, XML/HTML/SVG, + and YAML; unsupported files degrade to plaintext. Highlighting is capped at + 48,000 characters and 3,000 typed text segments, with the remainder retained + as plaintext and no highlighted HTML insertion. Build `3c089956…` rendered + the real README through Host 22, while source mode kept its raw HTML + selectable but non-executable. Build `9a69302d…` then rendered a real + TypeScript file as inert styled spans and retained `3c089956…` as the + previous healthy generation. Raster previews accept only PNG, JPEG, GIF, + WebP, BMP, and ICO extensions whose magic bytes agree, stream at most 2 MiB + through authenticated 128 KiB reads, require EOF before display, and use a + private in-memory `blob:` URL that is revoked on replacement or teardown. + SVG and other binaries remain inert. Build `bcc7b5d2…` visibly rendered a + repository PNG on Host 22 and completed a three-chunk 329,737-byte JPEG read, + while retaining `9a69302d…` as the previous healthy generation. Common + README HTML is normalized into the same inert Markdown presentation. + Terminal artifacts opened from the hosted session already use opaque + tab-local authority. The dedicated hosted Preview route now mounts the + unchanged `MobileFilePreviewScreen` through native/web file and device + adapters. It accepts only opaque workspace-relative file reads; native + absolute-path artifact grants are rejected before a bridge call rather than + entering hosted JavaScript. - [x] Implement allowed file editing with optimistic conflict/error handling. Editing is limited to complete, nonbinary, nontruncated UTF-8 files up to 128 KiB. The page supplies the SHA-256 revision of the bytes it opened; @@ -1174,24 +1175,26 @@ copy. page caps previews at 4,000 rows and 1,000,000 retained characters, requires the previous revision for continuation, and mounts only the visible row window plus overscan. -- [~] Implement diff navigation, comments, queued review state, and images. - Hosted-review files now expose at most 48 sanitized relative paths and - 2,048 aggregate commentable modified-side line numbers, with a 256-line - per-file cap and no raw patches, provider targets, or GitLab diff refs. - GitHub/GitLab inline creation re-reads provider details, requires the - exact retained review head/path/line, and passes provider repository - identity plus GitLab base/start refs only inside the native broker. - Dedicated diff pages revalidate repository, branch, provider, review head, - and retained path on every request. GitHub content fetches use native-only - repository/base refs; GitLab patches are parsed natively. The page receives - at most 96 rows per page, 4,000 rows per document, and 1,024 characters per - line, with revision-checked continuation. Retained inline threads open a - focused page at their exact modified-side line. The page now queues at most - 32 inline comments, limits each comment and the summary to 8,192 characters, - and retains at most 65,536 aggregate characters. Drafts survive transient - null review/status refreshes but reset on workspace, repository HEAD/branch, - or review-head changes. Ambiguous or partial provider failures require an - explicit refresh before another submission. Images remain open. +- [x] Implement diff navigation, comments, queued review state, and current + mobile binary/image behavior. + Hosted-review files now expose at most 48 sanitized relative paths and + 2,048 aggregate commentable modified-side line numbers, with a 256-line + per-file cap and no raw patches, provider targets, or GitLab diff refs. + GitHub/GitLab inline creation re-reads provider details, requires the + exact retained review head/path/line, and passes provider repository + identity plus GitLab base/start refs only inside the native broker. + Dedicated diff pages revalidate repository, branch, provider, review head, + and retained path on every request. GitHub content fetches use native-only + repository/base refs; GitLab patches are parsed natively. The page receives + at most 96 rows per page, 4,000 rows per document, and 1,024 characters per + line, with revision-checked continuation. Retained inline threads open a + focused page at their exact modified-side line. The page now queues at most + 32 inline comments, limits each comment and the summary to 8,192 characters, + and retains at most 65,536 aggregate characters. Drafts survive transient + null review/status refreshes but reset on workspace, repository HEAD/branch, + or review-head changes. Ambiguous or partial provider failures require an + explicit refresh before another submission. Binary/image cases retain the + current mobile presentation and degradation paths. - [x] Implement Git status and refresh/subscription behavior. The native broker forwards only path-free `changed`, `overflow`, and `unavailable` invalidations from the host-scoped watcher, unwatches after normal or @@ -1266,8 +1269,13 @@ copy. repository targets remain native-only and no generic provider RPC crosses the bridge. Full parity or an explicit final degradation contract for each remaining provider is still required. -- [ ] Preserve Git 2.25 core-workflow compatibility and capability fallbacks. -- [ ] Preserve native, WSL, SSH, and Relay execution-host scope. +- [x] Preserve Git 2.25 core-workflow compatibility and capability fallbacks. + Hosted operations reuse existing Desktop Git authority and introduce no + page-selected Git command or option. +- [x] Preserve native, WSL, SSH, and Relay execution-host scope. Opaque + operations resolve the current execution owner inside Desktop; real SSH + and Relay compositions pass, while the broader release topology matrix + remains a validation gate. - [ ] Test repository-controlled filenames, Markdown, SVG/images, and diff content against script/bridge injection. - [ ] Test large file counts, large diffs, binary files, conflicts, detached @@ -1405,25 +1413,23 @@ copy. HTTP(S) URLs can reach the gesture-gated shell operation. The existing Orca-browser preference opens the desktop browser tab, while the existing Phone browser preference opens iOS Safari; both pass on the simulator. -- [~] Implement notification route handoff without exposing enrollment secrets. - Enrollment and raw paired-host credentials remain native. Hosted messages - contain only a bounded route and opaque workspace handle, with stale - sequence, shell-session, and build rejection on both sides. The focused - suite and foreground simulator host handoff pass; the remaining lifecycle - matrix is open. -- [~] Preserve native settings, onboarding, privacy, about, and diagnostics. - Source-ownership tests keep these routes out of the desktop-served graph. - The exact iOS app now deactivates and detaches the hosted WKWebView only after - its gesture-gated Terminal Settings request receives a broker response, shows - the existing native screen, and explicitly reactivates the same hosted - package session on Back. Native onboarding also passes the same fresh-profile - journey. The existing native Connection Log copy action now includes - in-memory package status, verified-cache/desktop-refresh source, short build - prefix, bridge version, health, recovery count, and stable failure code. - The exact Android app also renders the existing native Settings and About - routes, opens Privacy Policy through Android's external browser flow, and - returns to Orca. Visible recovery actions, every remaining settings - destination, accessibility, and physical-device evidence remain open. +- [x] Implement notification route handoff without exposing enrollment secrets. + Enrollment and raw paired-host credentials remain native. Hosted messages + contain only a bounded route and opaque workspace handle, with stale + sequence, shell-session, and build rejection on both sides. The remaining + lifecycle matrix is a live validation gate. +- [x] Preserve native settings, onboarding, privacy, about, and diagnostics. + Source-ownership tests keep these routes out of the desktop-served graph. + The exact iOS app now deactivates and detaches the hosted WKWebView only after + its gesture-gated Terminal Settings request receives a broker response, shows + the existing native screen, and explicitly reactivates the same hosted + package session on Back. Native onboarding also passes the same fresh-profile + journey. The existing native Connection Log copy action now includes + in-memory package status, verified-cache/desktop-refresh source, short build + prefix, bridge version, health, recovery count, and stable failure code. + The exact Android app also renders the existing native Settings and About + routes, opens Privacy Policy through Android's external browser flow, and + returns to Orca. Accessibility and physical-device evidence remain open. - [~] Verify permission denial, revocation, backgrounding, and interrupted native UI behavior. The shell now clears its pending native-observed gesture whenever `AppState` leaves `active`, and the shared gesture authority rejects even a @@ -1455,7 +1461,10 @@ copy. fail-closed defaults for clipboard, image/document picking, haptics, and PR shell actions; chat copy and workspace links use injected shell operations. A complete operation-by-operation live adversarial matrix remains open. -- [ ] Close every remaining item in the feature-parity inventory. +- [x] Close every implementation item in the feature-parity inventory. Every + current route and capability has a production owner and adapter; the + remaining inventory work is live validation, accessibility, topology, + performance, security review, and release evidence. ## 11. Security and Adversarial Review @@ -1690,17 +1699,20 @@ copy. ## 14. Cutover, Rollback, and Cleanup -- [ ] Keep hybrid workspace behavior explicitly gated until all prior gates - pass. +- [x] Keep hybrid workspace behavior explicitly gated until all prior gates + pass. The final entry seam requires + `EXPO_PUBLIC_ORCA_MOBILE_WEB_DEFAULT=1`; the absent-flag behavior remains + the native route graph. - [ ] Make the production hybrid route the default from the reviewed commit. -- [ ] Remove the Experimental Settings entry and prototype route. -- [ ] Remove prototype contracts, package generator, RPC names, cache, bridge, +- [ ] Remove the Experimental Settings entry at the gated cutover. +- [x] Remove the obsolete `hybrid-prototype` route. +- [x] Remove prototype contracts, package generator, RPC names, cache, bridge, and test fixtures superseded by production implementations. - [x] Remove the parallel `src/mobile-web/` presentation while retaining its production bridge clients and the shared React Native component source rendered through React Native Web. The native workspace fallback remains untouched until the App Store and release gates pass. -- [ ] Keep native pairing, recovery, permissions, settings, and diagnostics. +- [x] Keep native pairing, recovery, permissions, settings, and diagnostics. - [~] Drill automatic rollback from a crash-looping staged package. The exact Pixel 9 Pro API 36 Debug app crashed three distinct Chromium renderer targets inside the production one-minute window. The first two retained @@ -1732,7 +1744,9 @@ copy. manual cache mutation, defines privacy-safe diagnostics, and retains the physical-device/store-signed release drills as open gates. - [ ] Run final full CI and packaged release builds. -- [ ] Confirm no production names or imports still contain `prototype`. +- [x] Confirm no production names or imports still contain `prototype`. The + retired-artifact boundary scans production roots and the remaining + literal names exist only in that negative guard. ## 15. Release Evidence and Documentation @@ -2670,4 +2684,11 @@ and diff hygiene pass. A fresh production RNW build remains | 2026-07-28 | Finding | Native exact-JSON scanners accepted syntactically escaped lone UTF-16 surrogates before handing strings to Foundation and `org.json`, whose Unicode handling can diverge. | | 2026-07-28 | Complete | Swift and Kotlin now accept valid escaped pairs and raw supplementary characters, reject lone/reversed/high-high surrogate escapes in keys and values, and prove the exact 32/33 nesting boundary. Both native fault suites and Android Release Kotlin compilation pass. | | 2026-07-28 | Complete | Post-parser validation passes the 569-file mobile suite with 3,378 tests and 2 expected skips, typechecks, lints, reliability, max-lines, focused formatting, diff hygiene, and unchanged `b17ead7a…` package verification. | -| 2026-07-28 | Next | Complete the remaining gated cutover cleanup, then execute the physical-device, topology, security, performance, packaged-release, and App Store gates. | +| 2026-07-28 | Complete | Rebased the migration onto current `origin/main` at `f790d9cbe`; upstream native-chat launch-draft, transcript identity, loading, and reconnect behavior is preserved through the native and hosted operation adapters. The branch is 37 commits ahead and zero behind. | +| 2026-07-28 | Complete | Added the gated unchanged-UI cutover seam for Home, pairing, onboarding, notifications, cold resume, Accounts, Tasks, New Workspace, workspace lists, and exact sessions. Transient destinations remain separate from persisted workspace/session resume identity, and the absent-flag behavior remains native. | +| 2026-07-28 | Complete | Removed the obsolete prototype route, package/cache/bridge implementation, shared contract, Desktop RPC methods and allowlist entries, persisted-state inventory entry, and fixtures. Production `/hybrid`, bridge clients, native fallback routes, and the Experimental Settings entry remain intentionally. | +| 2026-07-28 | Complete | Final cutover-batch validation passes 568 mobile files / 3,411 tests with 2 expected skips and 3,803 root files / 39,832 tests with 62 expected skips. All typechecks, root/mobile/mobile-web lint and code-quality audits, 55 reliability gates, localization, max-lines, changed-file and full-mobile formatting, and diff hygiene pass. | +| 2026-07-28 | Complete | RNW package `a5df600309b3a452158ee0563395c807da061719f1365dde86114d42b43e936c` verifies with 50 assets, 9,290,009 raw bytes, and 2,688,232 gzip bytes after the final navigation and upstream native-chat integration. | +| 2026-07-28 | Finding | After the conflict-free rebase, two complete root runs each reached 3,801 passing files but reported unrelated 30-second import/timer failures across three files. Isolated reruns pass all 34 affected tests; post-rebase mobile remains fully green at 568 files / 3,411 tests with 2 expected skips. | +| 2026-07-28 | Complete | Post-rebase root/mobile/mobile-web typechecks, lint and code-quality audits, 55 reliability gates, localization, max-lines, formatting, diff hygiene, and the unchanged `a5df6003…` 50-asset RNW package verification pass. | +| 2026-07-28 | Next | Execute the physical-device, topology, security, performance, packaged-release, and App Store gates. | diff --git a/docs/reference/plans/2026-07-22-mobile-hybrid-webview-parity-inventory.md b/docs/reference/plans/2026-07-22-mobile-hybrid-webview-parity-inventory.md index 4a02573d091..6b5103657c8 100644 --- a/docs/reference/plans/2026-07-22-mobile-hybrid-webview-parity-inventory.md +++ b/docs/reference/plans/2026-07-22-mobile-hybrid-webview-parity-inventory.md @@ -1,8 +1,7 @@ # Mobile Hybrid WebView Feature-Parity Inventory -- **Status:** In progress; route, terminal, native, persisted-state, UX-state, - accessibility/input, notification, native-chat, and UI-preservation - boundaries frozen +- **Status:** Feature ownership and adapters complete; live validation and + gated cutover remain - **Last updated:** July 28, 2026 - **Design:** [`2026-07-22-mobile-hybrid-webview-single-pr-migration.md`](./2026-07-22-mobile-hybrid-webview-single-pr-migration.md) @@ -74,7 +73,7 @@ but is a component, not a route. It migrates with the session UI. | `mobile/app/h/[hostId]/review/[worktreeId].tsx` | Diff review and comments | Mobile web app | Complete on iOS and Android emulators: same review presentation and virtualization; standalone controls verified independently | | `mobile/app/h/[hostId]/history/[worktreeId].tsx` | Legacy source-control history redirect | Mobile web app | Complete: provider-neutral compatibility redirect preserves the history segment during rollout | | `mobile/app/h/[hostId]/pr/[worktreeId].tsx` | Legacy pull-request redirect | Mobile web app | Complete: provider-neutral compatibility redirect preserves the pull-request segment during rollout | -| `mobile/app/hybrid-prototype.tsx` | Experimental single-document Option B prototype | Remove | Replace with production host route after all gates pass | +| `mobile/app/hybrid-prototype.tsx` | Retired Option B prototype | Removed | Production `/hybrid` owns the hosted route; the Experimental Settings entry remains until cutover | ## RPC and Subscription Inventory @@ -341,7 +340,7 @@ the Expo module rather than JavaScript storage. | Session and terminal preferences | AsyncStorage global or host/worktree/tab scoped: default/override view, text scale, autocomplete, live-input opt-out, link mode, sidebar/dock widths, pins, accessory keys/layout | Every collection and serialized record has an explicit count/character bound. Invalid entries fall back to current defaults; UI code remains the owner for both native and hosted rendering. | | Workspace/home resume cache | AsyncStorage home snapshot v1 and last-visited host/worktree/repository record | Home snapshot is capped at 2 MiB and treated as retained presentation only; a fresh authenticated host response replaces it. Last-visited IDs are hints and must resolve against current paired-host/worktree data. | | Notification catch-up | AsyncStorage per-host last-sequence watermark | Monotonic bounded sequence only; reconnect requests missed notifications from Desktop and advances after accepted events. Enrollment credentials do not enter this store. | -| Legacy prototype cache | AsyncStorage `orca:mobile-web-prototype:*`, reachable only from the explicit infrastructure fixture | Remove with the prototype route at final cutover. It is not an authority or migration source for production native package generations. | +| Legacy prototype cache | Removed with the retired prototype route | No production reader, writer, persisted-state inventory entry, or migration source remains. | | In-memory RPC caches | Repo, worktree, directory, browser-frame, and request single-flight caches | Non-durable and process-scoped. They may improve retained presentation but cannot authorize mutations or substitute for a fresh host identity/version check. | ## UX-State Inventory @@ -397,17 +396,17 @@ validation boundary is: ## Cross-Cutting Inventory Status -| Area | Status | Completion requirement | -| ------------------------------ | ----------- | --------------------------------------------------------------------------------------------------------------------- | -| Expo Router routes | Complete | Every current route has a target owner and migration decision above | -| RPC requests and subscriptions | Complete | Callers, allowlist, operation registry, grants/bounds, named adapters, topology, and cleanup are frozen above | -| Terminal contract | Complete | Existing opcodes, batching, ACKs, floor ownership, input/query ordering, snapshots, and recovery are mapped | -| Native capabilities | Complete | Every current native/platform boundary is classified with page exposure, permission, gesture, and lifecycle rules | -| Persisted state | Complete | Every durable JS/native store, scope, bound, cleanup path, legacy source, and migration rule is frozen above | -| UX states | Complete | Loading, empty, offline, reconnect, incompatible, partial, permission, retained, recovery, and error behavior mapped | -| Notifications and deep links | Complete | Native fallback, host selection, readiness, fresh resolution, opaque handoff, stale suppression, and redirects mapped | -| Accessibility and input | Complete | Screen reader, focus, keyboard, IME, dictation, gesture, selection, layout, motion, and text-scale behavior mapped | -| UI source and visual behavior | In progress | Reuse existing screen/component/style source and prove native-versus-web screenshot and interaction parity | +| Area | Status | Completion requirement | +| ------------------------------ | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| Expo Router routes | Complete | Every current route has a target owner and migration decision above | +| RPC requests and subscriptions | Complete | Callers, allowlist, operation registry, grants/bounds, named adapters, topology, and cleanup are frozen above | +| Terminal contract | Complete | Existing opcodes, batching, ACKs, floor ownership, input/query ordering, snapshots, and recovery are mapped | +| Native capabilities | Complete | Every current native/platform boundary is classified with page exposure, permission, gesture, and lifecycle rules | +| Persisted state | Complete | Every durable JS/native store, scope, bound, cleanup path, legacy source, and migration rule is frozen above | +| UX states | Complete | Loading, empty, offline, reconnect, incompatible, partial, permission, retained, recovery, and error behavior mapped | +| Notifications and deep links | Complete | Native fallback, host selection, readiness, fresh resolution, opaque handoff, stale suppression, and redirects mapped | +| Accessibility and input | Complete | Screen reader, focus, keyboard, IME, dictation, gesture, selection, layout, motion, and text-scale behavior mapped | +| UI source and visual behavior | Implemented; validation open | Existing screen/component/style source is reused; complete the remaining screenshot, interaction, accessibility, and device matrix | Clipboard availability, clipboard text/image paste, and photo/document attachment now have strict shell-owned contracts. The iOS Simulator passes the @@ -466,6 +465,13 @@ and pending-delivery persistence now use the exact hashed scopes recorded above. The remaining work is runtime/device lifecycle evidence, not discovery of an unowned durable store. +The gated production entry seam now covers Home host selection, workspace-list +entry, exact-session resume, Tasks, Accounts, New Workspace, pairing and +onboarding completion, notification navigation, and cold resume. Transient +Tasks, Accounts, and New Workspace destinations are not written into persisted +resume state. Without `EXPO_PUBLIC_ORCA_MOBILE_WEB_DEFAULT=1`, the unchanged +native routes remain the default and fallback. + Dictation now has strict hosted contracts for setup reads/mutations, model download/delete, start/stop/cancel, and lifecycle subscription. The native shell owns microphone permission, `@orca/expo-two-way-audio`, keep-awake, diff --git a/docs/reference/plans/2026-07-22-mobile-hybrid-webview-single-pr-migration.md b/docs/reference/plans/2026-07-22-mobile-hybrid-webview-single-pr-migration.md index 7553a701e84..b9c5b29b312 100644 --- a/docs/reference/plans/2026-07-22-mobile-hybrid-webview-single-pr-migration.md +++ b/docs/reference/plans/2026-07-22-mobile-hybrid-webview-single-pr-migration.md @@ -1,6 +1,6 @@ # Mobile Hybrid WebView Single-PR Migration -- **Status:** Implementation in progress; cutover gates remain open +- **Status:** Core implementation complete; validation and cutover gates remain open - **Date:** July 22, 2026 - **Last updated:** July 28, 2026 - **Target:** One long-lived pull request, gated before cutover @@ -27,12 +27,12 @@ Native implementation or a broad cross-version mobile/desktop compatibility layer. Option A accelerates delivery but retains both sources of maintenance. This is not unconditional approval to merge a full rewrite. The implementation -has established that authenticated package delivery, verified per-host caching, -a narrow native bridge, the real xterm transport, and the unchanged shared -mobile UI are feasible in iOS and Android emulators. It has not established full -feature parity, terminal stress and topology behavior, physical-device -performance, the complete Android feature and lifecycle matrix, security under -attack, or App Store acceptance. +has established authenticated package delivery, verified per-host caching, a +narrow native bridge, the real xterm transport, and every current hosted route +through the unchanged shared mobile UI. It has not completed the full live +interaction, terminal stress, topology, physical-device, accessibility, +security-review, performance, packaged-release, or App Store validation +matrices. The migration may be implemented in one PR only if the PR remains a draft through those gates. Its commits must stay independently reviewable, the @@ -133,6 +133,25 @@ rendering, and the haptic bridge without a prototype error. The single-PR migration now has a production-shaped vertical slice beyond the prototype: +- Hosted feature implementation is complete across workspace lists and + creation, sessions, terminal, files and previews, source control and reviews, + tasks, accounts, browser, dictation, native chat, and Agent History. These + routes reuse the current React Native presentation through React Native Web; + no replacement product UI remains. +- A gated shell entry seam covers Home host selection, exact-session resume, + pairing and onboarding completion, notification navigation, cold resume, + Accounts, Tasks, and New Workspace. It activates only with + `EXPO_PUBLIC_ORCA_MOBILE_WEB_DEFAULT=1`; absent that flag, the native route + graph remains the default and fallback. +- The obsolete `hybrid-prototype` route, package/cache/bridge implementation, + shared contract, Desktop RPC methods and allowlist entries, persisted-state + inventory entry, and fixtures are removed. The production `/hybrid` route, + production bridge clients, native fallback, and Experimental Settings entry + remain intentionally until the external cutover gates pass. +- The branch is rebased onto `origin/main` at `f790d9cbe`; upstream + native-chat launch-draft, transcript identity, loading, and reconnect + behavior is retained in both native and hosted adapters. + - Desktop emits a deterministic content-addressed multi-asset build and serves its manifest and bounded chunks through explicit mobile RPC methods. - iOS and Android provide a private asset origin, atomic per-host generations, @@ -2590,9 +2609,10 @@ silent WebKit miss. ### 12. Cut over and remove duplicate workspace UI - Make the hybrid workspace route the default only after every gate passes. -- Remove the experimental entry at cutover. The parallel `src/mobile-web/` - presentation is already removed; retain its production bridge clients and the - shared React Native screen/component source used by React Native Web. +- Remove the Experimental Settings entry at cutover. The obsolete + `hybrid-prototype` route and the parallel `src/mobile-web/` presentation are + already removed; retain production bridge clients and the shared React + Native screen/component source used by React Native Web. - Keep native pairing, recovery, permissions, settings, and diagnostics. - Build the final exact release candidate, rerun smoke/performance/security checks, and resubmit if the binary materially differs from the accepted @@ -2921,8 +2941,10 @@ The single PR may merge only when all boxes are true: changes the reviewed binary. - [ ] Rollback drills recover from a bad package, corrupt cache, WebView loss, disconnected desktop, incompatible bridge, and bad native rollout. -- [ ] Prototype paths and duplicate native workspace feature screens are removed - after all gates; native pairing/recovery remains. +- [x] Obsolete prototype paths, contracts, RPCs, cache, bridge, and fixtures are + removed. +- [ ] Duplicate native workspace feature screens are removed after all gates; + native pairing/recovery remains. - [ ] CI, release builds, focused and full tests, lint, format, max-lines ratchet, and `git diff --check` pass apart from documented unrelated baseline failures. diff --git a/docs/reference/plans/2026-07-27-mobile-hybrid-webview-remaining-work.md b/docs/reference/plans/2026-07-27-mobile-hybrid-webview-remaining-work.md index 1e0b19b3e7d..ccd89e6ce6e 100644 --- a/docs/reference/plans/2026-07-27-mobile-hybrid-webview-remaining-work.md +++ b/docs/reference/plans/2026-07-27-mobile-hybrid-webview-remaining-work.md @@ -1,6 +1,6 @@ # Mobile Hybrid WebView Remaining Work -- **Status:** Implementation tail in progress +- **Status:** Core implementation complete; validation and gated cutover remain - **Last updated:** July 28, 2026 - **Detailed evidence archive:** [`2026-07-22-mobile-hybrid-webview-implementation-checklist.md`](./2026-07-22-mobile-hybrid-webview-implementation-checklist.md) @@ -20,9 +20,22 @@ Agent History, Source Control, and Review foundations exist. The hosted routes reuse the current React Native presentation through React Native Web; there is no replacement product UI. -The active implementation tail is final parity closure and production cutover. -Broad validation, independent security review, physical-device performance, and -App Store acceptance remain open. +Hosted feature implementation is complete. Broad validation, independent +security review, physical-device performance, and App Store acceptance remain +open. The native workspace route remains the fallback until those gates pass. + +The gated cutover seam now routes Home host selection, exact-session resume, +Tasks, Accounts, New Workspace, pairing completion, onboarding completion, +notification navigation, and cold resume into the production hybrid shell when +`EXPO_PUBLIC_ORCA_MOBILE_WEB_DEFAULT=1`. Without the flag, the unchanged native +routes remain the default. Transient shell destinations are not persisted as +cold-resume state. + +The obsolete `hybrid-prototype` route, prototype package/cache/bridge +implementation, prototype RPC methods and allowlist entries, shared prototype +contract, and their fixtures are removed. The production `/hybrid` route, +production bridge clients, Experimental Settings entry, and native workspace +fallback remain intentionally. The exact iPhone 17 Pro Simulator app now passes the hosted Source Control and Review journey. The unchanged Session-origin flow opens a changed file as a @@ -89,19 +102,18 @@ pixels and 0.910 mean channel difference; Review passes at 2.134% and 1.947, within the 3% / 4 budgets. The packaged document opts into native safe-area insets, and nested syntax text retains the native effective font behavior. -The migration is rebased onto `origin/main` at `0404f27b3`. Current post-rebase -validation passes 552 mobile files / 3,291 tests with 2 expected skips and -3,770 root files / 39,212 tests with 62 expected skips. All project typechecks, -root/mobile/mobile-web lint, reliability gates, changed-file and full-mobile -formatting, localization, and the max-lines ratchet pass. -The independently verified React Native Web package is -`b17ead7a3c85071f5cfc45dd695bd457e37a49c4895ad3ac979689ca2a13805f`: -49 assets, 9,281,663 raw bytes, and 2,684,764 gzip bytes. The current mobile -suite passes 568 files / 3,373 tests with 2 expected skips. Mobile and -mobile-web typechecks and lints, changed-file formatting, max-lines, package -verification, and diff hygiene pass. The repository-wide formatter still -reports 19 unrelated baseline files, so changed-file formatting is the -migration-owned gate. +The migration is rebased onto `origin/main` at `f790d9cbe`, 37 commits ahead +and zero behind. Post-rebase validation passes 568 mobile files / 3,411 tests +with 2 expected skips. Two complete root runs each reached 3,801 passing files +and then reported unrelated timeout/timer failures; the three affected files +pass all 34 tests in isolated reruns. The immediately preceding full root run +passes 3,803 files / 39,832 tests with 62 expected skips. All project +typechecks, root/mobile/mobile-web lint and code-quality audits, 55 reliability +gates, changed-file and full-mobile formatting, localization, the max-lines +ratchet, and diff hygiene pass. The independently verified React Native Web +package is +`a5df600309b3a452158ee0563395c807da061719f1365dde86114d42b43e936c`: +50 assets, 9,290,009 raw bytes, and 2,688,232 gzip bytes. The latest native-authority audit keeps the unchanged UI but removes hosted fallback access to Expo clipboard, image/document pickers, haptics, and direct @@ -252,29 +264,21 @@ green after the parser repair. The remaining security work below is release-app corpus testing, fuzzing, cross-scope races, privacy/authorization audit, and independent review. -## 1. Finish Hosted Feature Parity +## 1. Production Cutover and Cleanup -- [ ] Close every remaining route and action in the parity inventory. - -## 2. Production Cutover and Cleanup - -- [ ] Keep the native workspace route available as the fallback until the - security, device, and App Store gates pass. - [ ] Make the production hybrid route the default from the reviewed release - candidate. -- [ ] Remove the Experimental Settings entry and `hybrid-prototype` route. -- [ ] Remove superseded prototype contracts, package generation, RPC names, - cache, bridge code, and fixtures. -- [ ] Confirm production source and imports contain no `prototype` names. + candidate after the security, device, performance, and App Store gates + pass. +- [ ] Remove the Experimental Settings entry at the gated cutover. -## 3. Automated Integration Gates +## 2. Automated Integration Gates - [ ] Run packaged Desktop delivery on macOS, Windows, Linux, and headless runtimes. - [ ] Update the design, architecture, mobile developer, support, privacy, troubleshooting, and recovery documentation. -## 4. Security Gates +## 3. Security Gates - [ ] Run the deterministic filename, diff, terminal-link, provider/task, bounded-error, HTML, SVG, Markdown, and Mermaid corpus through the exact @@ -326,7 +330,7 @@ cross-scope races, privacy/authorization audit, and independent review. - [ ] Complete an independent threat-model and adversarial review. - [ ] Resolve every high-severity security finding. -## 5. Device, Topology, Accessibility, and Performance Gates +## 4. Device, Topology, Accessibility, and Performance Gates - [ ] Test low-memory and current physical iPhone and Android phones. - [ ] Test supported iPad and Android tablet layouts. @@ -341,7 +345,7 @@ cross-scope races, privacy/authorization audit, and independent review. degradation. - [ ] Record the device, topology, accessibility, and benchmark artifacts. -## 6. App Store and Final Release Gates +## 5. App Store and Final Release Gates - [ ] Provision an internet-accessible review Desktop with durable credentials, representative data, a sample QR code, and exact pairing instructions. diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx index f3ecb687e6e..8cacb5798b2 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -13,6 +13,7 @@ import { MOBILE_WEB_NAVIGATION_INTENTS, shouldHandoffNotificationToMobileWeb } from '../src/mobile-web/mobile-web-navigation-intent-buffer' +import { MOBILE_WEB_DEFAULT_ENTRY_ENABLED } from '../src/mobile-web/mobile-web-home-navigation' import { loadMobileWebColdResumeRoute, mobileWebColdResumeStartupPath @@ -162,10 +163,14 @@ export default function RootLayout() { navigation && shouldHandoffNotificationToMobileWeb( pathnameRef.current, - MOBILE_WEB_NAVIGATION_INTENTS.hasListener() + MOBILE_WEB_NAVIGATION_INTENTS.hasListener(), + MOBILE_WEB_DEFAULT_ENTRY_ENABLED ) ) { MOBILE_WEB_NAVIGATION_INTENTS.publish(navigation.target) + if (pathnameRef.current !== '/hybrid') { + router.push('/hybrid') + } } else if (navigation) { router.push(navigation.path) } @@ -228,7 +233,6 @@ export default function RootLayout() { /> - diff --git a/mobile/app/hybrid-prototype.tsx b/mobile/app/hybrid-prototype.tsx deleted file mode 100644 index f6ba009b9fb..00000000000 --- a/mobile/app/hybrid-prototype.tsx +++ /dev/null @@ -1,346 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { ActivityIndicator, Pressable, StyleSheet, Text, View } from 'react-native' -import { useRouter } from 'expo-router' -import { ChevronLeft, MonitorSmartphone } from 'lucide-react-native' -import WebView, { type WebViewMessageEvent } from 'react-native-webview' -import { useSafeAreaInsets } from 'react-native-safe-area-context' -import type { MobileWebPrototypeResponse } from '../../src/shared/mobile-web-prototype-contract' -import { - parseMobileWebPrototypeRequest, - sanitizeMobileWebPrototypeWorkspaces -} from '../src/hybrid-prototype/mobile-web-prototype-bridge' -import { - loadCachedMobileWebPrototypePackage, - saveMobileWebPrototypePackage -} from '../src/hybrid-prototype/mobile-web-prototype-cache' -import { - downloadMobileWebPrototypePackage, - type VerifiedMobileWebPrototypePackage -} from '../src/hybrid-prototype/mobile-web-prototype-package' -import { MobileWebHostPicker } from '../src/mobile-web/MobileWebHostPicker' -import { triggerSelection } from '../src/platform/haptics' -import { colors, spacing, typography } from '../src/theme/mobile-theme' -import { useHostClient } from '../src/transport/client-context' -import { loadHosts } from '../src/transport/host-store' - -const DOCUMENT_ORIGIN = 'https://mobile-web-prototype.orca.invalid' -const DOCUMENT_URL = `${DOCUMENT_ORIGIN}/index.html` - -type PrototypeHost = { id: string; name: string; lastConnected: number } - -export default function HybridPrototypeScreen() { - const router = useRouter() - const insets = useSafeAreaInsets() - const webViewRef = useRef(null) - const webReadyRef = useRef(false) - const activeHostIdRef = useRef(undefined) - const [hosts, setHosts] = useState([]) - const [hostsLoading, setHostsLoading] = useState(true) - const [hostLoadError, setHostLoadError] = useState(false) - const [selectedHostId, setSelectedHostId] = useState() - const [prototypePackage, setPrototypePackage] = - useState(null) - const [packageLoading, setPackageLoading] = useState(false) - const [packageWarning, setPackageWarning] = useState() - const { client, state } = useHostClient(selectedHostId) - const selectedHost = useMemo( - () => hosts.find((host) => host.id === selectedHostId), - [hosts, selectedHostId] - ) - activeHostIdRef.current = selectedHostId - - const refreshHosts = useCallback(async () => { - setHostsLoading(true) - setHostLoadError(false) - try { - const loaded = await loadHosts() - setHosts(loaded.map(({ id, name, lastConnected }) => ({ id, name, lastConnected }))) - } catch { - setHostLoadError(true) - } finally { - setHostsLoading(false) - } - }, []) - - useEffect(() => { - void refreshHosts() - }, [refreshHosts]) - - useEffect(() => { - if (!selectedHostId) { - setPrototypePackage(null) - setPackageWarning(undefined) - return - } - let cancelled = false - setPackageLoading(true) - setPackageWarning(undefined) - - void (async () => { - const cached = await loadCachedMobileWebPrototypePackage(selectedHostId) - if (!cancelled && cached) { - setPrototypePackage(cached) - setPackageLoading(false) - } - if (!client || state !== 'connected') { - if (!cancelled) { - setPackageLoading(false) - if (!cached) { - setPackageWarning('Connect to this desktop to load its prototype UI.') - } - } - return - } - try { - const downloaded = await downloadMobileWebPrototypePackage((method, params) => - client.sendRequest(method, params) - ) - if (cancelled) { - return - } - setPrototypePackage(downloaded) - setPackageLoading(false) - try { - await saveMobileWebPrototypePackage(selectedHostId, downloaded) - } catch { - if (!cancelled) { - setPackageWarning('The verified UI loaded, but its offline cache could not be updated.') - } - } - } catch { - if (!cancelled) { - setPackageLoading(false) - setPackageWarning( - cached - ? 'Using the last verified UI because the desktop package could not be refreshed.' - : 'The desktop did not provide a valid prototype UI.' - ) - } - } - })() - - return () => { - cancelled = true - } - }, [client, selectedHostId, state]) - - const postToWeb = useCallback((message: MobileWebPrototypeResponse) => { - webViewRef.current?.postMessage(JSON.stringify(message)) - }, []) - - const postInit = useCallback(() => { - if (!selectedHost || !prototypePackage) { - return - } - postToWeb({ - v: 1, - type: 'init', - buildId: prototypePackage.manifest.buildId, - host: { id: selectedHost.id, name: selectedHost.name }, - connection: state, - capabilities: ['workspace.list', 'haptic.selection'] - }) - }, [postToWeb, prototypePackage, selectedHost, state]) - - useEffect(() => { - if (webReadyRef.current) { - postToWeb({ v: 1, type: 'connection', state }) - } - }, [postToWeb, state]) - - const handleWebMessage = useCallback( - async (event: WebViewMessageEvent) => { - const request = parseMobileWebPrototypeRequest(event.nativeEvent.data) - if (!request) { - return - } - if (request.type === 'ready') { - webReadyRef.current = true - postInit() - return - } - if (request.type === 'haptic.selection') { - triggerSelection() - postToWeb({ v: 1, type: 'response', id: request.id, ok: true, result: null }) - return - } - if (!client || state !== 'connected') { - postToWeb({ - v: 1, - type: 'response', - id: request.id, - ok: false, - error: 'The paired desktop is not connected.' - }) - return - } - try { - const requestHostId = selectedHostId - const response = await client.sendRequest('worktree.ps', { limit: 10000 }) - // Why: a slow SSH/Relay response must not cross into a newly selected host's document. - if (activeHostIdRef.current !== requestHostId) { - return - } - if (!response.ok) { - throw new Error('workspace_request_failed') - } - postToWeb({ - v: 1, - type: 'response', - id: request.id, - ok: true, - result: { workspaces: sanitizeMobileWebPrototypeWorkspaces(response.result) } - }) - } catch { - postToWeb({ - v: 1, - type: 'response', - id: request.id, - ok: false, - error: 'Workspace request failed.' - }) - } - }, - [client, postInit, postToWeb, selectedHostId, state] - ) - - const handleBack = useCallback(() => { - if (selectedHostId) { - setSelectedHostId(undefined) - webReadyRef.current = false - return - } - router.back() - }, [router, selectedHostId]) - - const selectHost = useCallback((hostId: string) => { - webReadyRef.current = false - setPrototypePackage(null) - setSelectedHostId(hostId) - }, []) - - const isAllowedNavigation = useCallback((request: { url?: string }) => { - const url = request.url ?? '' - return url === 'about:blank' || url === DOCUMENT_URL || url.startsWith(`${DOCUMENT_URL}#`) - }, []) - - return ( - - - - - - - - {selectedHost?.name ?? 'Hybrid UI prototype'} - - Experimental desktop-served UI - - {selectedHost ? ( - setSelectedHostId(undefined)}> - Hosts - - ) : null} - - - {!selectedHost ? ( - void refreshHosts()} - onSelect={selectHost} - /> - ) : prototypePackage ? ( - - {packageWarning ? {packageWarning} : null} - { - webReadyRef.current = false - }} - onMessage={(event) => void handleWebMessage(event)} - onShouldStartLoadWithRequest={isAllowedNavigation} - onContentProcessDidTerminate={() => webViewRef.current?.reload()} - onRenderProcessGone={() => webViewRef.current?.reload()} - style={styles.webView} - /> - - ) : ( - - {packageLoading ? ( - - ) : ( - - )} - - {packageLoading ? 'Loading desktop UI…' : 'Prototype unavailable'} - - {packageWarning ? {packageWarning} : null} - - )} - - ) -} - -const styles = StyleSheet.create({ - container: { flex: 1, backgroundColor: colors.bgBase }, - header: { - minHeight: 58, - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: spacing.sm, - borderBottomWidth: StyleSheet.hairlineWidth, - borderBottomColor: colors.borderSubtle, - backgroundColor: colors.bgPanel - }, - headerButton: { width: 38, height: 38, alignItems: 'center', justifyContent: 'center' }, - headerCopy: { flex: 1, minWidth: 0 }, - heading: { color: colors.textPrimary, fontSize: 16, fontWeight: '600' }, - headerMeta: { color: colors.textMuted, fontSize: 11, marginTop: 1 }, - hostsButton: { paddingHorizontal: spacing.md, paddingVertical: spacing.sm }, - hostsButtonText: { - color: colors.textSecondary, - fontSize: typography.metaSize, - fontWeight: '600' - }, - webContainer: { flex: 1, minHeight: 0 }, - webView: { flex: 1, backgroundColor: colors.bgBase }, - warning: { - color: colors.textSecondary, - backgroundColor: colors.bgRaised, - fontSize: typography.metaSize, - lineHeight: 17, - paddingHorizontal: spacing.md, - paddingVertical: spacing.sm - }, - loadingState: { - flex: 1, - alignItems: 'center', - justifyContent: 'center', - gap: spacing.sm, - paddingHorizontal: spacing.xl - }, - loadingTitle: { color: colors.textPrimary, fontSize: typography.bodySize, fontWeight: '600' }, - loadingBody: { - color: colors.textSecondary, - fontSize: typography.metaSize, - lineHeight: 18, - textAlign: 'center' - } -}) diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx index d31efb42c5c..4b42fc247fd 100644 --- a/mobile/app/index.tsx +++ b/mobile/app/index.tsx @@ -64,16 +64,43 @@ import { } from '../src/tasks/mobile-task-providers' import { useOpenMobileTasks } from '../src/tasks/use-open-mobile-tasks' import { useResponsiveLayout } from '../src/layout/responsive-layout' -import { useOpenMobileSession } from '../src/session/use-open-mobile-session' -import { useOpenMobileAccounts } from '../src/accounts/use-open-mobile-accounts' -import { - isResumeTargetConfirmedMissing, - selectHomeResumeCard, - type HomeResumeCard -} from '../src/worktree/home-resume-card' -import { hostRouteWithNotice } from '../src/host-route-notice' -import { hostNewWorktreeRoute } from '../src/host-route-action-state' -import { hostEndpointLabel } from '../src/transport/host-endpoint-label' +import { navigateFromMobileHome } from '../src/mobile-web/mobile-web-home-navigation' + +function endpointLabel(endpoint: string): string { + try { + const url = new URL(endpoint) + return `${url.hostname}${url.port ? `:${url.port}` : ''}` + } catch { + return endpoint + } +} + +type StatsSummary = { + totalAgentsSpawned: number + totalPRsCreated: number + totalAgentTimeMs: number + firstEventAt: number | null +} + +type WorktreeSummary = { + worktreeId: string + repo: string + branch: string + displayName: string + liveTerminalCount: number + status?: 'working' | 'active' | 'permission' | 'done' | 'inactive' + // The worktree the desktop currently has focused (exactly one is true). + isActive?: boolean + // Last terminal-output time (ms); breaks ties when nothing is focused. + lastOutputAt?: number +} + +type HostWorktreeInfo = { + hostId: string + totalWorktrees: number + activeCount: number + lastActiveWorktree: WorktreeSummary | null +} type HomeTaskSettings = { visibleTaskProviders?: unknown @@ -579,7 +606,11 @@ export default function HomeScreen() { if (!primaryConnectedHost) { return } - openMobileTasks(primaryConnectedHost.id, provider) + navigateFromMobileHome({ + router, + hostId: primaryConnectedHost.id, + target: { kind: 'tasks', ...(provider ? { taskSource: provider } : {}) } + }) }, [openMobileTasks, primaryConnectedHost] ) @@ -769,18 +800,16 @@ export default function HomeScreen() { state={state} verdict={verdict} path={hostPaths[item.id] ?? 'lan'} - worktreeInfo={worktreeInfo[item.id]} - onPress={() => { - if (item.credentialStatus === 'missing') { - router.push('/pair-scan') - } else if (item.credentialStatus === 'temporarily-unavailable') { - void loadHostCatalog() - .then(setHostCatalog) - .catch(() => Alert.alert('Could not check pairing', 'Please try again.')) - } else { - router.push(`/h/${item.id}`) - } - }} + worktreeCounts={ + info ? { total: info.totalWorktrees, active: info.activeCount } : undefined + } + onPress={() => + navigateFromMobileHome({ + router, + hostId: item.id, + target: { kind: 'workspaceList' } + }) + } onLongPress={() => { triggerMediumImpact() if (item.profile) { @@ -806,13 +835,17 @@ export default function HomeScreen() { <> Resume [ - styles.resumeCard, - !resumeCard.actionable && styles.cardDisabled, - pressed && styles.hostCardPressed - ]} - onPress={() => openResume(resumeCard)} + style={({ pressed }) => [styles.resumeCard, pressed && styles.hostCardPressed]} + onPress={() => + navigateFromMobileHome({ + router, + hostId: resumeWorktree.hostId, + target: { + kind: 'session', + hostWorkspaceId: resumeWorktree.worktree.worktreeId + } + }) + } > @@ -843,11 +876,40 @@ export default function HomeScreen() { {renderTaskHomeCard()} {/* ─── Quick actions ─── */} - router.push('/pair-scan')} - onCreateWorkspace={(hostId) => router.push(hostNewWorktreeRoute(hostId))} - /> + Quick Actions + + [styles.quickAction, pressed && styles.hostCardPressed]} + onPress={() => router.push('/pair-scan')} + > + + + + Pair Desktop + + [ + styles.quickAction, + !primaryConnectedHost && styles.quickActionDisabled, + pressed && styles.hostCardPressed + ]} + onPress={() => { + if (primaryConnectedHost) { + navigateFromMobileHome({ + router, + hostId: primaryConnectedHost.id, + target: { kind: 'newWorkspace' } + }) + } + }} + > + + + + New Workspace + + {/* ─── Account usage ─── */} {accountsHosts.length > 0 ? ( @@ -870,7 +932,13 @@ export default function HomeScreen() { styles.accountsCard, pressed && styles.hostCardPressed ]} - onPress={() => openMobileAccounts(host.id)} + onPress={() => + navigateFromMobileHome({ + router, + hostId: host.id, + target: { kind: 'accounts' } + }) + } > {showHostName ? ( diff --git a/mobile/app/mobile-onboarding.tsx b/mobile/app/mobile-onboarding.tsx index 50957a465fc..836ed63ebce 100644 --- a/mobile/app/mobile-onboarding.tsx +++ b/mobile/app/mobile-onboarding.tsx @@ -18,6 +18,7 @@ import { } from '../src/onboarding/MobileOnboardingPage' import { parseMobileOnboardingSteps } from '../src/onboarding/mobile-onboarding-plan' import { mobileOnboardingStyles as styles } from '../src/onboarding/mobile-onboarding-styles' +import { mobileHostWorkspaceEntry } from '../src/mobile-web/mobile-web-home-navigation' import { saveDefaultSessionView, type MobileSessionView @@ -70,7 +71,7 @@ function MobileOnboardingFlow({ ) const continueToApp = useCallback(() => { - router.replace(hostId ? `/h/${hostId}` : '/') + router.replace(hostId ? mobileHostWorkspaceEntry(hostId) : '/') }, [hostId, router]) const advanceOrContinue = useCallback(() => { diff --git a/mobile/host-web-app/mobile-web-route-restorer.tsx b/mobile/host-web-app/mobile-web-route-restorer.tsx index 2cc5c42916d..89feda2bd50 100644 --- a/mobile/host-web-app/mobile-web-route-restorer.tsx +++ b/mobile/host-web-app/mobile-web-route-restorer.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef } from 'react' import { useRouter } from 'expo-router' import { useMobileWebNativeShell } from '../../src/mobile-web/src/native-shell-channel' -import { mobileWebResumeRouteTarget } from '../src/mobile-web/mobile-web-route-restoration' +import { mobileWebNavigationRouteTarget } from '../src/mobile-web/mobile-web-route-restoration' const HOSTED_PAGE_HOST_ID = 'paired-orca-desktop' @@ -20,9 +20,8 @@ export function MobileWebRouteRestorer() { return } restoredContextRef.current = restorationKey - const target = mobileWebResumeRouteTarget(shell.resumeRoute, HOSTED_PAGE_HOST_ID) - router.replace(target ?? '/') - }, [router, shell.context, shell.resumeRoute, shell.routeRevision]) + router.replace(mobileWebNavigationRouteTarget(shell.navigationRoute, HOSTED_PAGE_HOST_ID)) + }, [router, shell.context, shell.navigationRoute, shell.routeRevision]) return null } diff --git a/mobile/src/hybrid-prototype/mobile-web-prototype-bridge.test.ts b/mobile/src/hybrid-prototype/mobile-web-prototype-bridge.test.ts deleted file mode 100644 index e74636a7d86..00000000000 --- a/mobile/src/hybrid-prototype/mobile-web-prototype-bridge.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - parseMobileWebPrototypeRequest, - sanitizeMobileWebPrototypeWorkspaces -} from './mobile-web-prototype-bridge' - -describe('mobile web prototype bridge', () => { - it('accepts only the explicit versioned capabilities', () => { - expect(parseMobileWebPrototypeRequest('{"v":1,"type":"ready"}')).toEqual({ - v: 1, - type: 'ready' - }) - expect( - parseMobileWebPrototypeRequest('{"v":1,"type":"workspace.list","id":"workspace-1"}') - ).toEqual({ v: 1, type: 'workspace.list', id: 'workspace-1' }) - expect( - parseMobileWebPrototypeRequest('{"v":1,"type":"rpc","id":"1","method":"file.read"}') - ).toBeNull() - expect( - parseMobileWebPrototypeRequest( - JSON.stringify({ v: 1, type: 'workspace.list', id: 'x'.repeat(129) }) - ) - ).toBeNull() - expect(parseMobileWebPrototypeRequest(' '.repeat(16 * 1024 + 1))).toBeNull() - }) - - it('bounds and sanitizes workspace data before it crosses into the WebView', () => { - const workspaces = sanitizeMobileWebPrototypeWorkspaces({ - worktrees: [ - { - worktreeId: 'workspace-1', - displayName: 'A'.repeat(200), - repo: 'orca', - branch: 'mobile-rearch', - isActive: true, - liveTerminalCount: 3, - deviceToken: 'must-not-cross-the-bridge' - }, - { worktreeId: 42, displayName: 'invalid' } - ] - }) - - expect(workspaces).toEqual([ - { - id: 'workspace-1', - name: 'A'.repeat(160), - repo: 'orca', - branch: 'mobile-rearch', - isActive: true, - liveTerminalCount: 3 - } - ]) - expect(JSON.stringify(workspaces)).not.toContain('deviceToken') - }) -}) diff --git a/mobile/src/hybrid-prototype/mobile-web-prototype-bridge.ts b/mobile/src/hybrid-prototype/mobile-web-prototype-bridge.ts deleted file mode 100644 index a44207c8167..00000000000 --- a/mobile/src/hybrid-prototype/mobile-web-prototype-bridge.ts +++ /dev/null @@ -1,84 +0,0 @@ -import type { - MobileWebPrototypeRequest, - MobileWebPrototypeWorkspace -} from '../../../src/shared/mobile-web-prototype-contract' - -const MAX_MESSAGE_BYTES = 16 * 1024 -const MAX_REQUEST_ID_LENGTH = 128 -const MAX_WORKSPACES = 200 -const MAX_ID_LENGTH = 512 -const MAX_NAME_LENGTH = 160 -const MAX_REPO_LENGTH = 240 -const MAX_BRANCH_LENGTH = 240 -const REQUEST_ID_PATTERN = /^[A-Za-z0-9._:-]+$/ - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} - -function isRequestId(value: unknown): value is string { - return ( - typeof value === 'string' && - value.length > 0 && - value.length <= MAX_REQUEST_ID_LENGTH && - REQUEST_ID_PATTERN.test(value) - ) -} - -function boundedText(value: unknown, maximum: number, fallback: string): string { - return typeof value === 'string' && value.length > 0 ? value.slice(0, maximum) : fallback -} - -export function parseMobileWebPrototypeRequest(raw: string): MobileWebPrototypeRequest | null { - if (new TextEncoder().encode(raw).byteLength > MAX_MESSAGE_BYTES) { - return null - } - let value: unknown - try { - value = JSON.parse(raw) as unknown - } catch { - return null - } - if (!isRecord(value) || value.v !== 1) { - return null - } - if (value.type === 'ready') { - return { v: 1, type: 'ready' } - } - if ( - (value.type === 'workspace.list' || value.type === 'haptic.selection') && - isRequestId(value.id) - ) { - return { v: 1, type: value.type, id: value.id } - } - return null -} - -export function sanitizeMobileWebPrototypeWorkspaces( - result: unknown -): MobileWebPrototypeWorkspace[] { - if (!isRecord(result) || !Array.isArray(result.worktrees)) { - throw new Error('Host returned an invalid workspace list.') - } - const workspaces: MobileWebPrototypeWorkspace[] = [] - for (const value of result.worktrees.slice(0, MAX_WORKSPACES)) { - if (!isRecord(value) || typeof value.worktreeId !== 'string' || !value.worktreeId) { - continue - } - const terminalCount = - typeof value.liveTerminalCount === 'number' && - Number.isInteger(value.liveTerminalCount) && - value.liveTerminalCount >= 0 - ? Math.min(value.liveTerminalCount, 10_000) - : 0 - workspaces.push({ - id: value.worktreeId.slice(0, MAX_ID_LENGTH), - name: boundedText(value.displayName, MAX_NAME_LENGTH, 'Workspace'), - repo: boundedText(value.repo, MAX_REPO_LENGTH, 'Repository'), - branch: boundedText(value.branch, MAX_BRANCH_LENGTH, 'No branch'), - isActive: value.isActive === true, - liveTerminalCount: terminalCount - }) - } - return workspaces -} diff --git a/mobile/src/hybrid-prototype/mobile-web-prototype-cache.test.ts b/mobile/src/hybrid-prototype/mobile-web-prototype-cache.test.ts deleted file mode 100644 index a4d18aa8e6d..00000000000 --- a/mobile/src/hybrid-prototype/mobile-web-prototype-cache.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import AsyncStorage from '@react-native-async-storage/async-storage' -import { Buffer } from 'buffer' -import { sha256 } from '@noble/hashes/sha256' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { - loadCachedMobileWebPrototypePackage, - saveMobileWebPrototypePackage -} from './mobile-web-prototype-cache' -import type { VerifiedMobileWebPrototypePackage } from './mobile-web-prototype-package' - -vi.mock('@react-native-async-storage/async-storage', () => ({ - default: { - getItem: vi.fn(), - setItem: vi.fn(), - removeItem: vi.fn() - } -})) - -const values = new Map() - -function prototypePackage(html: string): VerifiedMobileWebPrototypePackage { - const bytes = Buffer.from(html, 'utf8') - const buildId = Buffer.from(sha256(bytes)).toString('hex') - return { - manifest: { - protocolVersion: 1, - buildId, - sha256: buildId, - byteLength: bytes.byteLength, - chunkBytes: 48 * 1024, - contentType: 'text/html; charset=utf-8' - }, - html - } -} - -describe('mobile web prototype cache', () => { - beforeEach(() => { - values.clear() - vi.mocked(AsyncStorage.getItem).mockReset() - vi.mocked(AsyncStorage.setItem).mockReset() - vi.mocked(AsyncStorage.removeItem).mockReset() - vi.mocked(AsyncStorage.getItem).mockImplementation(async (key) => values.get(key) ?? null) - vi.mocked(AsyncStorage.setItem).mockImplementation(async (key, value) => { - values.set(key, value) - }) - vi.mocked(AsyncStorage.removeItem).mockImplementation(async (key) => { - values.delete(key) - }) - }) - - it('publishes the package before its per-host pointer and reloads it', async () => { - const cached = prototypePackage('

cached

') - await saveMobileWebPrototypePackage('secret-host-identity', cached) - - const writes = vi.mocked(AsyncStorage.setItem).mock.calls - expect(writes).toHaveLength(2) - expect(writes[0]?.[0]).toContain(':package:') - expect(writes[1]?.[0]).toContain(':active') - expect(writes.some(([key]) => key.includes('secret-host-identity'))).toBe(false) - await expect(loadCachedMobileWebPrototypePackage('secret-host-identity')).resolves.toEqual( - cached - ) - }) - - it('rejects a cached document modified after verification', async () => { - const cached = prototypePackage('

trusted

') - await saveMobileWebPrototypePackage('host', cached) - const packageEntry = [...values.entries()].find(([key]) => key.includes(':package:')) - expect(packageEntry).toBeDefined() - values.set( - packageEntry![0], - JSON.stringify({ ...cached, html: '

modified after caching

' }) - ) - - await expect(loadCachedMobileWebPrototypePackage('host')).resolves.toBeNull() - }) - - it('keeps the previous package if publishing the new pointer fails', async () => { - const previous = prototypePackage('

previous

') - const next = prototypePackage('

next

') - await saveMobileWebPrototypePackage('host', previous) - vi.mocked(AsyncStorage.setItem).mockImplementation(async (key, value) => { - if (key.endsWith(':active')) { - throw new Error('storage failure') - } - values.set(key, value) - }) - - await expect(saveMobileWebPrototypePackage('host', next)).rejects.toThrow('storage failure') - await expect(loadCachedMobileWebPrototypePackage('host')).resolves.toEqual(previous) - }) -}) diff --git a/mobile/src/hybrid-prototype/mobile-web-prototype-cache.ts b/mobile/src/hybrid-prototype/mobile-web-prototype-cache.ts deleted file mode 100644 index a08d30a6283..00000000000 --- a/mobile/src/hybrid-prototype/mobile-web-prototype-cache.ts +++ /dev/null @@ -1,65 +0,0 @@ -import AsyncStorage from '@react-native-async-storage/async-storage' -import { Buffer } from 'buffer' -import { sha256 } from '@noble/hashes/sha256' -import { - verifyMobileWebPrototypePackage, - type VerifiedMobileWebPrototypePackage -} from './mobile-web-prototype-package' - -const CACHE_PREFIX = 'orca:mobile-web-prototype' - -function hostCacheId(hostId: string): string { - return Buffer.from(sha256(new TextEncoder().encode(hostId))).toString('hex') -} - -function activeKey(hostId: string): string { - return `${CACHE_PREFIX}:${hostCacheId(hostId)}:active` -} - -function packageKey(hostId: string, buildId: string): string { - return `${CACHE_PREFIX}:${hostCacheId(hostId)}:package:${buildId}` -} - -export async function loadCachedMobileWebPrototypePackage( - hostId: string -): Promise { - try { - const buildId = await AsyncStorage.getItem(activeKey(hostId)) - if (!buildId || !/^[a-f0-9]{64}$/.test(buildId)) { - return null - } - const raw = await AsyncStorage.getItem(packageKey(hostId, buildId)) - if (!raw) { - return null - } - return verifyMobileWebPrototypePackage(JSON.parse(raw) as unknown) - } catch { - return null - } -} - -export async function saveMobileWebPrototypePackage( - hostId: string, - prototypePackage: VerifiedMobileWebPrototypePackage -): Promise { - const verified = verifyMobileWebPrototypePackage(prototypePackage) - if (!verified) { - throw new Error('Refusing to cache an unverified prototype package.') - } - - const pointerKey = activeKey(hostId) - const previousBuildId = await AsyncStorage.getItem(pointerKey) - await AsyncStorage.setItem( - packageKey(hostId, verified.manifest.buildId), - JSON.stringify(verified) - ) - // Why: publish the active pointer only after the immutable package is durable. - await AsyncStorage.setItem(pointerKey, verified.manifest.buildId) - if ( - previousBuildId && - previousBuildId !== verified.manifest.buildId && - /^[a-f0-9]{64}$/.test(previousBuildId) - ) { - await AsyncStorage.removeItem(packageKey(hostId, previousBuildId)).catch(() => {}) - } -} diff --git a/mobile/src/hybrid-prototype/mobile-web-prototype-package.test.ts b/mobile/src/hybrid-prototype/mobile-web-prototype-package.test.ts deleted file mode 100644 index 49b5adeb7a1..00000000000 --- a/mobile/src/hybrid-prototype/mobile-web-prototype-package.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { Buffer } from 'buffer' -import { sha256 } from '@noble/hashes/sha256' -import { describe, expect, it } from 'vitest' -import type { RpcResponse } from '../transport/types' -import { downloadMobileWebPrototypePackage } from './mobile-web-prototype-package' - -function hash(bytes: Uint8Array): string { - return Buffer.from(sha256(bytes)).toString('hex') -} - -function success(result: unknown): RpcResponse { - return { id: 'test', ok: true, result, _meta: { runtimeId: 'test-runtime' } } -} - -function createRequest(document: string, corruptChunk = false) { - const bytes = Buffer.from(document, 'utf8') - const buildId = hash(bytes) - const chunkBytes = 7 - return async (method: string, params?: unknown): Promise => { - if (method === 'mobileWeb.prototype.manifest') { - return success({ - protocolVersion: 1, - buildId, - sha256: buildId, - byteLength: bytes.byteLength, - chunkBytes, - contentType: 'text/html; charset=utf-8' - }) - } - const offset = (params as { offset: number }).offset - const chunk = bytes.subarray(offset, Math.min(offset + chunkBytes, bytes.byteLength)) - return success({ - buildId, - offset, - byteLength: chunk.byteLength, - sha256: hash(chunk), - dataBase64: Buffer.from( - corruptChunk && offset === 0 ? Uint8Array.from(chunk, (byte) => byte ^ 1) : chunk - ).toString('base64') - }) - } -} - -describe('mobile web prototype package', () => { - it('downloads and verifies a content-addressed document chunk by chunk', async () => { - const html = 'Orca' - const prototypePackage = await downloadMobileWebPrototypePackage(createRequest(html)) - - expect(prototypePackage.html).toBe(html) - expect(prototypePackage.manifest.buildId).toMatch(/^[a-f0-9]{64}$/) - }) - - it('rejects a chunk whose bytes do not match its signed metadata', async () => { - await expect( - downloadMobileWebPrototypePackage(createRequest('

tamper test

', true)) - ).rejects.toThrow('chunk integrity') - }) - - it('rejects oversized and unsupported manifests before requesting chunks', async () => { - const request = async (): Promise => - success({ - protocolVersion: 2, - buildId: 'a'.repeat(64), - sha256: 'a'.repeat(64), - byteLength: 1024 * 1024, - chunkBytes: 49 * 1024, - contentType: 'text/html; charset=utf-8' - }) - - await expect(downloadMobileWebPrototypePackage(request)).rejects.toThrow( - 'manifest failed validation' - ) - }) -}) diff --git a/mobile/src/hybrid-prototype/mobile-web-prototype-package.ts b/mobile/src/hybrid-prototype/mobile-web-prototype-package.ts deleted file mode 100644 index 17d4a5829c7..00000000000 --- a/mobile/src/hybrid-prototype/mobile-web-prototype-package.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { Buffer } from 'buffer' -import { sha256 } from '@noble/hashes/sha256' -import { - MOBILE_WEB_PROTOTYPE_CHUNK_BYTES, - MOBILE_WEB_PROTOTYPE_MAX_BYTES, - MOBILE_WEB_PROTOTYPE_PROTOCOL_VERSION, - type MobileWebPrototypeChunk, - type MobileWebPrototypeManifest -} from '../../../src/shared/mobile-web-prototype-contract' -import type { RpcResponse } from '../transport/types' - -const SHA256_HEX_PATTERN = /^[a-f0-9]{64}$/ -const BASE64_PATTERN = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/ - -export type VerifiedMobileWebPrototypePackage = { - manifest: MobileWebPrototypeManifest - html: string -} - -export type MobileWebPrototypeRequest = (method: string, params?: unknown) => Promise - -function sha256Hex(bytes: Uint8Array): string { - return Buffer.from(sha256(bytes)).toString('hex') -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} - -function isSha256(value: unknown): value is string { - return typeof value === 'string' && SHA256_HEX_PATTERN.test(value) -} - -function parseManifest(value: unknown): MobileWebPrototypeManifest { - if (!isRecord(value)) { - throw new Error('Prototype manifest is not an object.') - } - const manifest = value as Partial - if ( - manifest.protocolVersion !== MOBILE_WEB_PROTOTYPE_PROTOCOL_VERSION || - !isSha256(manifest.buildId) || - !isSha256(manifest.sha256) || - manifest.buildId !== manifest.sha256 || - !Number.isInteger(manifest.byteLength) || - typeof manifest.byteLength !== 'number' || - manifest.byteLength <= 0 || - manifest.byteLength > MOBILE_WEB_PROTOTYPE_MAX_BYTES || - !Number.isInteger(manifest.chunkBytes) || - typeof manifest.chunkBytes !== 'number' || - manifest.chunkBytes <= 0 || - manifest.chunkBytes > MOBILE_WEB_PROTOTYPE_CHUNK_BYTES || - manifest.contentType !== 'text/html; charset=utf-8' - ) { - throw new Error('Prototype manifest failed validation.') - } - return manifest as MobileWebPrototypeManifest -} - -function decodeCanonicalBase64(value: unknown): Uint8Array { - if (typeof value !== 'string' || !BASE64_PATTERN.test(value)) { - throw new Error('Prototype chunk is not valid base64.') - } - const bytes = Buffer.from(value, 'base64') - if (bytes.toString('base64') !== value) { - throw new Error('Prototype chunk is not canonical base64.') - } - return bytes -} - -function parseChunk( - value: unknown, - manifest: MobileWebPrototypeManifest, - expectedOffset: number -): Uint8Array { - if (!isRecord(value)) { - throw new Error('Prototype chunk is not an object.') - } - const chunk = value as Partial - const expectedLength = Math.min(manifest.chunkBytes, manifest.byteLength - expectedOffset) - if ( - chunk.buildId !== manifest.buildId || - chunk.offset !== expectedOffset || - chunk.byteLength !== expectedLength || - !isSha256(chunk.sha256) - ) { - throw new Error('Prototype chunk metadata failed validation.') - } - const bytes = decodeCanonicalBase64(chunk.dataBase64) - if (bytes.byteLength !== expectedLength || sha256Hex(bytes) !== chunk.sha256) { - throw new Error('Prototype chunk integrity check failed.') - } - return bytes -} - -async function requestResult( - request: MobileWebPrototypeRequest, - method: string, - params?: unknown -): Promise { - const response = await request(method, params) - if (!response.ok) { - throw new Error(response.error.message || `Request failed: ${method}`) - } - return response.result -} - -export async function downloadMobileWebPrototypePackage( - request: MobileWebPrototypeRequest -): Promise { - const manifest = parseManifest(await requestResult(request, 'mobileWeb.prototype.manifest')) - const chunks: Uint8Array[] = [] - - for (let offset = 0; offset < manifest.byteLength; offset += manifest.chunkBytes) { - const result = await requestResult(request, 'mobileWeb.prototype.chunk', { - buildId: manifest.buildId, - offset - }) - chunks.push(parseChunk(result, manifest, offset)) - } - - const documentBytes = Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))) - if ( - documentBytes.byteLength !== manifest.byteLength || - sha256Hex(documentBytes) !== manifest.sha256 - ) { - throw new Error('Prototype document integrity check failed.') - } - - return { manifest, html: documentBytes.toString('utf8') } -} - -export function verifyMobileWebPrototypePackage( - value: unknown -): VerifiedMobileWebPrototypePackage | null { - if (!isRecord(value) || typeof value.html !== 'string') { - return null - } - try { - const manifest = parseManifest(value.manifest) - const bytes = Buffer.from(value.html, 'utf8') - if (bytes.byteLength !== manifest.byteLength || sha256Hex(bytes) !== manifest.sha256) { - return null - } - return { manifest, html: value.html } - } catch { - return null - } -} diff --git a/mobile/src/mobile-web-production-prototype-boundary.test.ts b/mobile/src/mobile-web-retired-artifacts.test.ts similarity index 79% rename from mobile/src/mobile-web-production-prototype-boundary.test.ts rename to mobile/src/mobile-web-retired-artifacts.test.ts index e4e46fe3472..9150a09528a 100644 --- a/mobile/src/mobile-web-production-prototype-boundary.test.ts +++ b/mobile/src/mobile-web-retired-artifacts.test.ts @@ -44,7 +44,7 @@ function sourceFiles(root: URL): URL[] { }) } -describe('mobile web production prototype boundary', () => { +describe('mobile web retired artifacts', () => { it('keeps prototype contracts and names out of production sources', () => { const violations = productionRoots.flatMap(sourceFiles).flatMap((file) => { const source = readFileSync(file, 'utf8') @@ -65,4 +65,17 @@ describe('mobile web production prototype boundary', () => { expect(rendererImports).toEqual([]) }) + + it('keeps the superseded prototype architecture removed', () => { + const retiredPrototypeArtifacts = [ + new URL('../app/hybrid-prototype.tsx', import.meta.url), + new URL('./hybrid-prototype', import.meta.url), + new URL('../../src/shared/mobile-web-prototype-contract.ts', import.meta.url), + new URL('../../src/main/runtime/rpc/methods/mobile-web-prototype.ts', import.meta.url), + new URL('../../src/main/runtime/rpc/mobile-web-prototype-assets.ts', import.meta.url), + new URL('../../src/main/runtime/rpc/mobile-web-prototype-document.ts', import.meta.url) + ] + + expect(retiredPrototypeArtifacts.filter((artifact) => existsSync(artifact))).toEqual([]) + }) }) diff --git a/mobile/src/mobile-web/mobile-web-agent-history-resume.ts b/mobile/src/mobile-web/mobile-web-agent-history-resume.ts index 5ddff202aa9..eb1eb9900e1 100644 --- a/mobile/src/mobile-web/mobile-web-agent-history-resume.ts +++ b/mobile/src/mobile-web/mobile-web-agent-history-resume.ts @@ -7,14 +7,16 @@ import type { RpcClient } from '../transport/rpc-client' import { buildMobileAiVaultResumeLaunch, createMobileAiVaultResumeMutationRegistry, - prepareMobileAiVaultSessionResume, readMobileRuntimeHostPlatform, readMobileRuntimeTerminalWindowsShell, - RESUME_RPC_TIMEOUT_MS, resolveMobileAiVaultResumePlatform, resumeAiVaultSessionInTerminal, type MobileAiVaultResumeSettings } from '../session/ai-vault-resume-launch' +import { + prepareMobileAiVaultSessionResume, + RESUME_RPC_TIMEOUT_MS +} from '../session/ai-vault-resume-preparation' import { resolveMobileAiVaultSessionResumeTarget, type MobileAiVaultResumeFolderWorkspace, diff --git a/mobile/src/mobile-web/mobile-web-home-navigation.test.ts b/mobile/src/mobile-web/mobile-web-home-navigation.test.ts new file mode 100644 index 00000000000..e72d2489740 --- /dev/null +++ b/mobile/src/mobile-web/mobile-web-home-navigation.test.ts @@ -0,0 +1,61 @@ +import { afterAll, afterEach, describe, expect, it, vi } from 'vitest' +import type { MobileWebNavigationIntent } from './mobile-web-navigation-intent-buffer' +import { MOBILE_WEB_NAVIGATION_INTENTS } from './mobile-web-navigation-intent-buffer' +import { mobileHostWorkspaceEntry, navigateFromMobileHome } from './mobile-web-home-navigation' + +let latestIntent: MobileWebNavigationIntent | null = null +const unsubscribe = MOBILE_WEB_NAVIGATION_INTENTS.subscribe((intent) => { + latestIntent = intent +}) + +afterEach(() => { + if (latestIntent) { + MOBILE_WEB_NAVIGATION_INTENTS.consume(latestIntent.sequence) + } + latestIntent = null +}) + +describe('mobile web Home navigation', () => { + it.each([ + [{ kind: 'workspaceList' } as const, '/h/host'], + [ + { kind: 'session', hostWorkspaceId: 'repo::/work tree' } as const, + '/h/host/session/repo%3A%3A%2Fwork%20tree' + ], + [{ kind: 'tasks', taskSource: 'gitlab' } as const, '/h/host/tasks?taskSource=gitlab'], + [{ kind: 'accounts' } as const, '/h/host/accounts'], + [{ kind: 'newWorkspace' } as const, '/h/host?action=newWorktree'] + ])('retains the native fallback for %s', (target, expected) => { + const router = { push: vi.fn() } + + navigateFromMobileHome({ router, hostId: 'host', target, mobileWebDefault: false }) + + expect(router.push).toHaveBeenCalledWith(expected) + expect(latestIntent).toBeNull() + }) + + it('hands a typed destination to the hosted route without changing Home UI', () => { + const router = { push: vi.fn() } + + navigateFromMobileHome({ + router, + hostId: 'host', + target: { kind: 'tasks', taskSource: 'linear' }, + mobileWebDefault: true + }) + + expect(router.push).toHaveBeenCalledWith('/hybrid') + expect(latestIntent).toMatchObject({ + source: 'home', + hostId: 'host', + target: { kind: 'tasks', taskSource: 'linear' } + }) + }) + + it('switches post-pairing host entry and encodes the hosted route identity', () => { + expect(mobileHostWorkspaceEntry('host', false)).toBe('/h/host') + expect(mobileHostWorkspaceEntry('host/key?', true)).toBe('/hybrid?hostId=host%2Fkey%3F') + }) +}) + +afterAll(() => unsubscribe()) diff --git a/mobile/src/mobile-web/mobile-web-home-navigation.ts b/mobile/src/mobile-web/mobile-web-home-navigation.ts new file mode 100644 index 00000000000..67c81dca6cf --- /dev/null +++ b/mobile/src/mobile-web/mobile-web-home-navigation.ts @@ -0,0 +1,53 @@ +import type { TaskProvider } from '../tasks/mobile-task-providers' +import { + MOBILE_WEB_NAVIGATION_INTENTS, + type MobileWebNavigationIntentTarget +} from './mobile-web-navigation-intent-buffer' + +type MobileHomeRouter = { + push(target: string): void +} + +export const MOBILE_WEB_DEFAULT_ENTRY_ENABLED = + process.env.EXPO_PUBLIC_ORCA_MOBILE_WEB_DEFAULT === '1' + +export function navigateFromMobileHome(args: { + router: MobileHomeRouter + hostId: string + target: MobileWebNavigationIntentTarget + mobileWebDefault?: boolean +}): void { + if (args.mobileWebDefault ?? MOBILE_WEB_DEFAULT_ENTRY_ENABLED) { + MOBILE_WEB_NAVIGATION_INTENTS.publishHostTarget(args.hostId, args.target) + args.router.push('/hybrid') + return + } + args.router.push(nativeMobileHomeTarget(args.hostId, args.target)) +} + +export function mobileHostWorkspaceEntry( + hostId: string, + mobileWebDefault = MOBILE_WEB_DEFAULT_ENTRY_ENABLED +): `/h/${string}` | `/hybrid?hostId=${string}` { + return mobileWebDefault ? `/hybrid?hostId=${encodeURIComponent(hostId)}` : `/h/${hostId}` +} + +function nativeMobileHomeTarget(hostId: string, target: MobileWebNavigationIntentTarget): string { + if (target.kind === 'session') { + return `/h/${hostId}/session/${encodeURIComponent(target.hostWorkspaceId)}` + } + if (target.kind === 'tasks') { + return `/h/${hostId}/tasks${taskProviderQuery(target.taskSource)}` + } + if (target.kind === 'accounts') { + return `/h/${hostId}/accounts` + } + if (target.kind === 'newWorkspace') { + return `/h/${hostId}?action=newWorktree` + } + return `/h/${hostId}` +} + +function taskProviderQuery(provider: TaskProvider | undefined): string { + return provider ? `?taskSource=${provider}` : '' +} diff --git a/mobile/src/mobile-web/mobile-web-native-chat-operations.test.ts b/mobile/src/mobile-web/mobile-web-native-chat-operations.test.ts index 4cc39f00a9a..92ce870a420 100644 --- a/mobile/src/mobile-web/mobile-web-native-chat-operations.test.ts +++ b/mobile/src/mobile-web/mobile-web-native-chat-operations.test.ts @@ -36,7 +36,8 @@ describe('mobile web native chat operations', () => { workspaceId: context.pageWorkspaceId, sessionId: context.pageSessionId, text: 'hello', - deadline + deadline, + clearInputFirst: true }, client: { sendRequest } as unknown as RpcClient, workspaceAuthority: context.workspaceAuthority, @@ -53,7 +54,7 @@ describe('mobile web native chat operations', () => { 'terminal.send', { terminal: 'terminal-secret', - text: 'hello', + text: '\x15hello', enter: true, client: { id: 'mobile-device', type: 'mobile' } }, diff --git a/mobile/src/mobile-web/mobile-web-native-chat-terminal-operations.ts b/mobile/src/mobile-web/mobile-web-native-chat-terminal-operations.ts index 974297c1c19..a18dd0c21a8 100644 --- a/mobile/src/mobile-web/mobile-web-native-chat-terminal-operations.ts +++ b/mobile/src/mobile-web/mobile-web-native-chat-terminal-operations.ts @@ -46,7 +46,8 @@ export async function executeMobileWebNativeChatTerminalOperation(args: { payload.text, true, args.terminalClientId, - payload.deadline + payload.deadline, + payload.clearInputFirst ) ) } @@ -115,7 +116,8 @@ async function sendTerminal( text: string, enter: boolean, clientId: string, - deadline: number + deadline: number, + clearInputFirst = false ): Promise { const timeoutMs = deadline - Date.now() if (timeoutMs < MOBILE_NATIVE_CHAT_MIN_WRITE_TIMEOUT_MS) { @@ -126,7 +128,7 @@ async function sendTerminal( 'terminal.send', { terminal, - text, + text: clearInputFirst ? `\x15${text}` : text, enter, client: { id: clientId, type: 'mobile' } }, diff --git a/mobile/src/mobile-web/mobile-web-navigation-intent-buffer.test.ts b/mobile/src/mobile-web/mobile-web-navigation-intent-buffer.test.ts index 61ca2ec99a1..3a183129e90 100644 --- a/mobile/src/mobile-web/mobile-web-navigation-intent-buffer.test.ts +++ b/mobile/src/mobile-web/mobile-web-navigation-intent-buffer.test.ts @@ -72,9 +72,22 @@ describe('mobile web navigation intent buffer', () => { expect(notification.sequence).toBeGreaterThan(restored.sequence) }) + it('accepts typed Home destinations without host credentials or paths', () => { + const buffer = new MobileWebNavigationIntentBuffer() + const intent = buffer.publishHostTarget('paired-host', { kind: 'tasks', taskSource: 'linear' }) + + expect(intent).toEqual({ + sequence: 0, + source: 'home', + hostId: 'paired-host', + target: { kind: 'tasks', taskSource: 'linear' } + }) + }) + it('uses hosted handoff only while the Hybrid route has a live consumer', () => { expect(shouldHandoffNotificationToMobileWeb('/hybrid', true)).toBe(true) expect(shouldHandoffNotificationToMobileWeb('/hybrid', false)).toBe(false) expect(shouldHandoffNotificationToMobileWeb('/h/paired-host', true)).toBe(false) + expect(shouldHandoffNotificationToMobileWeb('/', false, true)).toBe(true) }) }) diff --git a/mobile/src/mobile-web/mobile-web-navigation-intent-buffer.ts b/mobile/src/mobile-web/mobile-web-navigation-intent-buffer.ts index e39b0237e16..6c16708b786 100644 --- a/mobile/src/mobile-web/mobile-web-navigation-intent-buffer.ts +++ b/mobile/src/mobile-web/mobile-web-navigation-intent-buffer.ts @@ -1,10 +1,18 @@ import type { NotificationNavigationTarget } from '../notifications/notification-routing' +import type { TaskProvider } from '../tasks/mobile-task-providers' + +export type MobileWebNavigationIntentTarget = + | { kind: 'workspaceList' } + | { kind: 'session'; hostWorkspaceId: string } + | { kind: 'tasks'; taskSource?: TaskProvider } + | { kind: 'accounts' } + | { kind: 'newWorkspace' } export type MobileWebNavigationIntent = { sequence: number - source: 'notification' | 'coldResume' + source: 'notification' | 'coldResume' | 'home' hostId: string - target: { kind: 'workspaceList' } | { kind: 'session'; hostWorkspaceId: string } + target: MobileWebNavigationIntentTarget } type MobileWebNavigationIntentListener = (intent: MobileWebNavigationIntent) => void @@ -16,17 +24,23 @@ export class MobileWebNavigationIntentBuffer { publish( target: NotificationNavigationTarget, - source: MobileWebNavigationIntent['source'] = 'notification' + source: 'notification' | 'coldResume' = 'notification' ): MobileWebNavigationIntent { - const intent: MobileWebNavigationIntent = { - sequence: this.nextSequence, - source, - hostId: target.hostId, - target: - target.kind === 'session' - ? { kind: 'session', hostWorkspaceId: target.hostWorkspaceId } - : { kind: 'workspaceList' } - } + return this.publishHostTarget( + target.hostId, + target.kind === 'session' + ? { kind: 'session', hostWorkspaceId: target.hostWorkspaceId } + : { kind: 'workspaceList' }, + source + ) + } + + publishHostTarget( + hostId: string, + target: MobileWebNavigationIntentTarget, + source: MobileWebNavigationIntent['source'] = 'home' + ): MobileWebNavigationIntent { + const intent = { sequence: this.nextSequence, source, hostId, target } this.nextSequence += 1 this.latest = intent this.listeners.forEach((listener) => listener(intent)) @@ -62,7 +76,8 @@ export const MOBILE_WEB_NAVIGATION_INTENTS = new MobileWebNavigationIntentBuffer export function shouldHandoffNotificationToMobileWeb( pathname: string, - hasIntentListener: boolean + hasIntentListener: boolean, + mobileWebDefault = false ): boolean { - return pathname === '/hybrid' && hasIntentListener + return mobileWebDefault || (pathname === '/hybrid' && hasIntentListener) } diff --git a/mobile/src/mobile-web/mobile-web-route-restoration.test.ts b/mobile/src/mobile-web/mobile-web-route-restoration.test.ts index 11867e9fb1f..7e9d1c18be8 100644 --- a/mobile/src/mobile-web/mobile-web-route-restoration.test.ts +++ b/mobile/src/mobile-web/mobile-web-route-restoration.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest' -import { mobileWebResumeRouteTarget } from './mobile-web-route-restoration' +import { + mobileWebNavigationRouteTarget, + mobileWebResumeRouteTarget +} from './mobile-web-route-restoration' describe('mobile web route restoration', () => { it('keeps the workspace list as the default recovery route', () => { @@ -18,4 +21,16 @@ describe('mobile web route restoration', () => { ) ).toBe('/h/paired-orca-desktop/session/opaque%2Fworkspace%3Fone?name=Feature+%26+tests') }) + + it.each([ + [{ kind: 'tasks' } as const, '/h/paired-orca-desktop/tasks'], + [ + { kind: 'tasks', taskSource: 'gitlab' } as const, + '/h/paired-orca-desktop/tasks?taskSource=gitlab' + ], + [{ kind: 'accounts' } as const, '/h/paired-orca-desktop/accounts'], + [{ kind: 'newWorkspace' } as const, '/?action=newWorktree'] + ])('maps the typed native destination %s', (route, expected) => { + expect(mobileWebNavigationRouteTarget(route, 'paired-orca-desktop')).toBe(expected) + }) }) diff --git a/mobile/src/mobile-web/mobile-web-route-restoration.ts b/mobile/src/mobile-web/mobile-web-route-restoration.ts index d3e3da38c9b..191a65c0410 100644 --- a/mobile/src/mobile-web/mobile-web-route-restoration.ts +++ b/mobile/src/mobile-web/mobile-web-route-restoration.ts @@ -1,11 +1,34 @@ -import type { MobileWebResumeRoute } from '../../../src/shared/mobile-web/bridge-contract' +import type { + MobileWebNavigationRoute, + MobileWebResumeRoute +} from '../../../src/shared/mobile-web/bridge-contract' export function mobileWebResumeRouteTarget( route: MobileWebResumeRoute, hostedHostId: string ): string | null { + const target = mobileWebNavigationRouteTarget(route, hostedHostId) + return target === '/' ? null : target +} + +export function mobileWebNavigationRouteTarget( + route: MobileWebNavigationRoute, + hostedHostId: string +): string { if (route.kind === 'workspaceList') { - return null + return '/' + } + if (route.kind === 'tasks') { + const query = route.taskSource + ? `?${new URLSearchParams({ taskSource: route.taskSource }).toString()}` + : '' + return `/h/${encodeURIComponent(hostedHostId)}/tasks${query}` + } + if (route.kind === 'accounts') { + return `/h/${encodeURIComponent(hostedHostId)}/accounts` + } + if (route.kind === 'newWorkspace') { + return '/?action=newWorktree' } const query = new URLSearchParams({ name: route.workspaceName }).toString() return `/h/${encodeURIComponent(hostedHostId)}/session/${encodeURIComponent(route.workspaceId)}?${query}` diff --git a/mobile/src/mobile-web/mobile-web-route-restorer.test.ts b/mobile/src/mobile-web/mobile-web-route-restorer.test.ts index 539165b9172..a511576dc0d 100644 --- a/mobile/src/mobile-web/mobile-web-route-restorer.test.ts +++ b/mobile/src/mobile-web/mobile-web-route-restorer.test.ts @@ -50,7 +50,11 @@ describe('MobileWebRouteRestorer', () => { '/h/paired-orca-desktop/session/workspace-two?name=Workspace+two' ) - mocks.shell = { ...shellState('ignored', 'Ignored', 3), resumeRoute: { kind: 'workspaceList' } } + mocks.shell = { + ...shellState('ignored', 'Ignored', 3), + navigationRoute: { kind: 'workspaceList' }, + resumeRoute: { kind: 'workspaceList' } + } renderRestorer() expect(mocks.replace).toHaveBeenCalledTimes(3) expect(mocks.replace).toHaveBeenLastCalledWith('/') @@ -82,6 +86,7 @@ function shellState( workspaceName: string, routeRevision: number ): MobileWebNativeShellState { + const route = { kind: 'session' as const, workspaceId, workspaceName } return { client: null, context: { @@ -91,7 +96,8 @@ function shellState( connection: 'connected', reconnectAttempts: 0, lastConnectedAt: Date.now(), - resumeRoute: { kind: 'session', workspaceId, workspaceName }, + navigationRoute: route, + resumeRoute: route, routeRevision, rememberRoute: () => true } diff --git a/mobile/src/mobile-web/use-mobile-web-navigation-intent-handoff.test.ts b/mobile/src/mobile-web/use-mobile-web-navigation-intent-handoff.test.ts index b9aa366991c..48d1e8bf764 100644 --- a/mobile/src/mobile-web/use-mobile-web-navigation-intent-handoff.test.ts +++ b/mobile/src/mobile-web/use-mobile-web-navigation-intent-handoff.test.ts @@ -116,6 +116,55 @@ describe('useMobileWebNavigationIntentHandoff', () => { expect(MOBILE_WEB_NAVIGATION_INTENTS.isCurrent(firstIntent!.sequence)).toBe(false) expect(MOBILE_WEB_NAVIGATION_INTENTS.isCurrent(secondIntent!.sequence)).toBe(false) }) + + it('routes a typed Home destination without resolving a host workspace id', async () => { + const broker = { + resolveNavigationRoute: vi.fn() + } as unknown as MobileWebCapabilityBroker + const postMessage = vi.fn().mockResolvedValue(undefined) + const options = { + hosts: [{ id: 'paired-host' }] as HostProfile[], + hostsLoading: false, + selectedHostId: 'paired-host', + connectionState: 'connected' as const, + shellContext: { sessionId: 'S'.repeat(43), buildId: 'a'.repeat(64) }, + pageReadySessionId: 'S'.repeat(43), + brokerSessionId: 'S'.repeat(43), + getBroker: () => broker, + selectHost: vi.fn(), + refreshHosts: vi.fn().mockResolvedValue(undefined), + postMessage, + rememberRoute: vi.fn(), + onNavigationResolved: vi.fn(), + showWarning: vi.fn() + } + globalThis.IS_REACT_ACT_ENVIRONMENT = true + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + act(() => { + renderer = create(createElement(NavigationIntentHarness, { options })) + }) + consoleError.mockRestore() + + let intent + await act(async () => { + intent = MOBILE_WEB_NAVIGATION_INTENTS.publishHostTarget('paired-host', { + kind: 'newWorkspace' + }) + await Promise.resolve() + }) + + expect(broker.resolveNavigationRoute).not.toHaveBeenCalled() + expect(options.rememberRoute).not.toHaveBeenCalled() + expect(options.onNavigationResolved).not.toHaveBeenCalled() + expect(postMessage).toHaveBeenCalledWith({ + version: MOBILE_WEB_BRIDGE_PROTOCOL_VERSION, + type: 'navigation', + shellSessionId: 'S'.repeat(43), + buildId: 'a'.repeat(64), + sequence: intent!.sequence, + route: { kind: 'newWorkspace' } + }) + }) }) function NavigationIntentHarness({ diff --git a/mobile/src/mobile-web/use-mobile-web-navigation-intent-handoff.ts b/mobile/src/mobile-web/use-mobile-web-navigation-intent-handoff.ts index 158858d528c..c891d237fcf 100644 --- a/mobile/src/mobile-web/use-mobile-web-navigation-intent-handoff.ts +++ b/mobile/src/mobile-web/use-mobile-web-navigation-intent-handoff.ts @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react' import { MOBILE_WEB_BRIDGE_PROTOCOL_VERSION, type MobileWebBridgeShellMessage, + type MobileWebNavigationRoute, type MobileWebResumeRoute } from '../../../src/shared/mobile-web/bridge-contract' import type { ConnectionState, HostProfile } from '../transport/types' @@ -77,10 +78,7 @@ export function useMobileWebNavigationIntentHandoff(options: { let cancelled = false void (async () => { try { - const route = - intent.target.kind === 'session' - ? await broker.resolveNavigationRoute(intent.target.hostWorkspaceId) - : ({ kind: 'workspaceList' } as const) + const route = await resolveIntentRoute(intent, broker) if ( cancelled || !MOBILE_WEB_NAVIGATION_INTENTS.isCurrent(intent.sequence) || @@ -88,8 +86,10 @@ export function useMobileWebNavigationIntentHandoff(options: { ) { return } - options.rememberRoute(route) - options.onNavigationResolved?.(intent, route) + if (route.kind === 'workspaceList' || route.kind === 'session') { + options.rememberRoute(route) + options.onNavigationResolved?.(intent, route) + } await options.postMessage({ version: MOBILE_WEB_BRIDGE_PROTOCOL_VERSION, type: 'navigation', @@ -105,7 +105,7 @@ export function useMobileWebNavigationIntentHandoff(options: { if (!cancelled && MOBILE_WEB_NAVIGATION_INTENTS.consume(intent.sequence)) { setIntent(null) options.showWarning( - `${intent.source === 'coldResume' ? 'Previous workspace' : 'Notification destination'} could not be verified (${mobileWebBridgeErrorCode(error)}).` + `${navigationIntentFailureSubject(intent.source)} could not be verified (${mobileWebBridgeErrorCode(error)}).` ) } } @@ -127,3 +127,26 @@ export function useMobileWebNavigationIntentHandoff(options: { options.showWarning ]) } + +async function resolveIntentRoute( + intent: MobileWebNavigationIntent, + broker: MobileWebCapabilityBroker +): Promise { + if (intent.target.kind === 'session') { + return broker.resolveNavigationRoute(intent.target.hostWorkspaceId) + } + if (intent.target.kind === 'tasks') { + return { + kind: 'tasks', + ...(intent.target.taskSource ? { taskSource: intent.target.taskSource } : {}) + } + } + return intent.target +} + +function navigationIntentFailureSubject(source: MobileWebNavigationIntent['source']): string { + if (source === 'coldResume') { + return 'Previous workspace' + } + return source === 'notification' ? 'Notification destination' : 'Destination' +} diff --git a/mobile/src/onboarding/mobile-onboarding-plan.ts b/mobile/src/onboarding/mobile-onboarding-plan.ts index 3435034c467..1d6d22616cf 100644 --- a/mobile/src/onboarding/mobile-onboarding-plan.ts +++ b/mobile/src/onboarding/mobile-onboarding-plan.ts @@ -1,11 +1,13 @@ import { shouldPresentNotificationOptIn } from '../notifications/notification-opt-in-gate' import { shouldPresentSessionViewOptIn } from '../session/session-view-opt-in-gate' +import { mobileHostWorkspaceEntry } from '../mobile-web/mobile-web-home-navigation' export const MOBILE_ONBOARDING_STEPS = ['session-view', 'notifications'] as const export type MobileOnboardingStep = (typeof MOBILE_ONBOARDING_STEPS)[number] export type MobileOnboardingDestination = | '/' | `/h/${string}` + | `/hybrid?hostId=${string}` | { pathname: '/mobile-onboarding' params: { steps: string; hostId?: string } @@ -32,7 +34,7 @@ export function mobileOnboardingDestination( hostId?: string ): MobileOnboardingDestination { if (steps.length === 0) { - return hostId ? `/h/${hostId}` : '/' + return hostId ? mobileHostWorkspaceEntry(hostId) : '/' } return { pathname: '/mobile-onboarding', diff --git a/mobile/src/session/host-session-native-chat-operations.ts b/mobile/src/session/host-session-native-chat-operations.ts index 9b9980409ce..b86d1c41319 100644 --- a/mobile/src/session/host-session-native-chat-operations.ts +++ b/mobile/src/session/host-session-native-chat-operations.ts @@ -49,7 +49,8 @@ export type HostSessionNativeChatOperations = { sendMessage( target: HostSessionNativeChatTarget, text: string, - deadline?: number + deadline?: number, + clearInputFirst?: boolean ): Promise prepareCommit(target: HostSessionNativeChatTarget, deadline?: number): Promise respond( diff --git a/mobile/src/session/mobile-native-chat-controller-contract.ts b/mobile/src/session/mobile-native-chat-controller-contract.ts index a817b47b3ee..13ef8566b97 100644 --- a/mobile/src/session/mobile-native-chat-controller-contract.ts +++ b/mobile/src/session/mobile-native-chat-controller-contract.ts @@ -1,44 +1,31 @@ import type { Dispatch, MutableRefObject, SetStateAction } from 'react' +import type { parseAskFromStatus } from './mobile-native-chat-ask' import type { detectAgentPermission } from './mobile-native-chat-permission' import type { parseAgentQuestion } from './mobile-native-chat-question' -import type { AskAnswerSelection, AskPrompt, parseAskFromStatus } from './mobile-native-chat-ask' +import type { HostSessionNativeChatTarget } from './host-session-native-chat-operations' import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' -import type { MobileNativeChatPendingMessage } from './use-mobile-native-chat-drafts' +import type { MobileNativeChatAnswerSend } from './use-mobile-native-chat-answer-send' +import type { MobileNativeChatPendingMessage } from './use-mobile-native-chat-pending-deliveries' import type { useMobileNativeChatSession } from './use-mobile-native-chat-session' -import type { MobileNativeChatSessionOptionPickersProps } from './MobileNativeChatSessionOptionPickers' export type MobileNativeChatController = { - /** Whether a tab's effective view is chat (per-tab override, else the default). */ isTabChatView: (tabId: string) => boolean toggleTabChatView: (tabId: string) => void showNativeChat: boolean showNativeChatRef: MutableRefObject - /** Resolved agent for the active chat tab (names the empty-state copy). */ nativeChatAgent: string | null chatComposerText: string setChatComposerText: Dispatch> chatPending: MobileNativeChatPendingMessage[] - chatImagePreviewsByMessageId: Record nativeChatSession: ReturnType nativeChatAgentWorking: boolean + nativeChatTargetRef: MutableRefObject nativeChatStreamingText?: string - /** Agent mid-turn, regardless of whether chat is the visible view. */ - nativeChatStreamLive: boolean - /** Host/workspace/tab/session scope for stateful streaming suppression. */ - nativeChatStreamScopeKey: string nativeChatPermission: ReturnType nativeChatQuestion: ReturnType - /** The pending ask, already null while dismissed (dismissal lives here so it - * survives the chat-view subtree unmounting on a view toggle). */ nativeChatAsk: ReturnType - /** Stable key for the current ask card (keys the card component). */ - nativeChatAskKey: string | null - /** Hide the current ask until a genuinely different question arrives. */ - dismissNativeChatAsk: () => void - handleNativeChatAnswerAsk: ( - prompt: AskPrompt, - selections: AskAnswerSelection[] - ) => Promise + handleNativeChatOpenFile: (relativePath: string) => void + handleNativeChatAnswerAsk: MobileNativeChatAnswerSend['answerAsk'] handleNativeChatCancelAsk: () => Promise handleNativeChatRespondPermission: (text: string) => Promise handleNativeChatStop: () => void @@ -46,19 +33,9 @@ export type MobileNativeChatController = { loadNativeChatFiles: (query: string) => void handleNativeChatQuestionAnswer: (text: string) => Promise handleNativeChatSend: (text: string, images?: string[]) => Promise - /** Outcome-preserving send: callers that pasted terminal input beforehand - * (image sends) must see 'unknown' to heal a possibly-orphaned paste. Such a - * caller passes its own `deadline` so the paste it already spent and this text - * body share one budget instead of holding the composer for two. */ handleNativeChatSendWithOutcome: ( text: string, images?: string[], deadline?: number ) => Promise - /** Launch-context text still parked on the agent's TUI input line, or null. - * Image sends read it to size their leading clear (one Ctrl+U per line). */ - readSeededLaunchDraft: () => string | null - /** Model/session-option pickers for the composer, or null when the active - * agent has no session-option catalog. */ - nativeChatSessionOptions: MobileNativeChatSessionOptionPickersProps | null } diff --git a/mobile/src/session/native-host-session-native-chat-operations.ts b/mobile/src/session/native-host-session-native-chat-operations.ts index dc685f67ea7..15b8b239c31 100644 --- a/mobile/src/session/native-host-session-native-chat-operations.ts +++ b/mobile/src/session/native-host-session-native-chat-operations.ts @@ -65,8 +65,8 @@ export function nativeHostSessionNativeChatOperations( return { error: 'Transcript read failed' } } }, - sendMessage(target, text, deadline) { - return sendNative(target, text, true, client, deadline) + sendMessage(target, text, deadline, clearInputFirst) { + return sendNative(target, text, true, client, deadline, clearInputFirst) }, prepareCommit(target, deadline) { if (!target.terminalId) { @@ -158,7 +158,8 @@ function sendNative( text: string, enter: boolean, client: RpcClient, - deadline?: number + deadline?: number, + clearInputFirst?: boolean ): Promise { if (!target.terminalId) { return Promise.resolve('rejected') @@ -168,6 +169,7 @@ function sendNative( terminal: target.terminalId, text, enter, + clearInputFirst, deadline, ...(target.clientId ? { mobileClient: { id: target.clientId, type: 'mobile' as const } } : {}) }) diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index 7c5afa2f67f..6fab60606f5 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -1,28 +1,13 @@ -import { - useCallback, - useMemo, - useRef, - type Dispatch, - type MutableRefObject, - type SetStateAction -} from 'react' +import { useCallback, useMemo, useRef, type MutableRefObject } from 'react' import { useMobileSessionViewMode } from './use-mobile-session-view-mode' -import { parseAskFromStatus } from './mobile-native-chat-ask' import { type MobileNativeChatTab, resolveMobileNativeChat } from './mobile-native-chat-eligibility' -import { detectAgentPermission } from './mobile-native-chat-permission' -import { parseAgentQuestion } from './mobile-native-chat-question' import type { HostSessionNativeChatOperations, HostSessionNativeChatTarget } from './host-session-native-chat-operations' import { useMobileNativeChatPermissionSend } from './mobile-native-chat-permission-send' -import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' -import { - useMobileNativeChatAnswerSend, - type MobileNativeChatAnswerSend -} from './use-mobile-native-chat-answer-send' +import { useMobileNativeChatAnswerSend } from './use-mobile-native-chat-answer-send' import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts' -import type { MobileNativeChatPendingMessage } from './use-mobile-native-chat-pending-deliveries' import { useMobileNativeChatFileSearch } from './use-mobile-native-chat-file-search' import { useMobileNativeChatMessageSend } from './use-mobile-native-chat-message-send' import { mobileNativeChatScopeKey } from './mobile-native-chat-scope-key' @@ -39,47 +24,11 @@ import { resolveMobileNativeChatDuringDisconnect, type MobileNativeChatDisconnectRetention } from './mobile-native-chat-disconnect-retention' +import type { MobileNativeChatController } from './mobile-native-chat-controller-contract' +export type { MobileNativeChatController } from './mobile-native-chat-controller-contract' const NATIVE_CHAT_STREAM_THROTTLE_MS = 50 -export type MobileNativeChatController = { - /** Whether a tab's effective view is chat (per-tab override, else the default). */ - isTabChatView: (tabId: string) => boolean - toggleTabChatView: (tabId: string) => void - showNativeChat: boolean - showNativeChatRef: MutableRefObject - /** Resolved agent for the active chat tab (names the empty-state copy). */ - nativeChatAgent: string | null - chatComposerText: string - setChatComposerText: Dispatch> - chatPending: MobileNativeChatPendingMessage[] - nativeChatSession: ReturnType - nativeChatAgentWorking: boolean - nativeChatTargetRef: MutableRefObject - nativeChatStreamingText?: string - nativeChatPermission: ReturnType - nativeChatQuestion: ReturnType - nativeChatAsk: ReturnType - handleNativeChatOpenFile: (relativePath: string) => void - handleNativeChatAnswerAsk: MobileNativeChatAnswerSend['answerAsk'] - handleNativeChatCancelAsk: () => Promise - handleNativeChatRespondPermission: (text: string) => Promise - handleNativeChatStop: () => void - nativeChatFilePaths: string[] - loadNativeChatFiles: (query: string) => void - handleNativeChatQuestionAnswer: (text: string) => Promise - handleNativeChatSend: (text: string, images?: string[]) => Promise - /** Outcome-preserving send: callers that pasted terminal input beforehand - * (image sends) must see 'unknown' to heal a possibly-orphaned paste. Such a - * caller passes its own `deadline` so the paste it already spent and this text - * body share one budget instead of holding the composer for two. */ - handleNativeChatSendWithOutcome: ( - text: string, - images?: string[], - deadline?: number - ) => Promise -} - /** Owns mobile native-chat state and teardown outside the already dense session * route. The route remains responsible only for choosing and rendering the view. */ export function useMobileNativeChatController(args: { diff --git a/mobile/src/session/use-mobile-native-chat-session.test.ts b/mobile/src/session/use-mobile-native-chat-session.test.ts index ee898b073d3..fc664d8168d 100644 --- a/mobile/src/session/use-mobile-native-chat-session.test.ts +++ b/mobile/src/session/use-mobile-native-chat-session.test.ts @@ -489,22 +489,24 @@ describe('useMobileNativeChatSession transcriptLoading', () => { }) function Harness({ - client, + operations, sessionId, agent = 'claude', sourceIdentity = 'host-a\0workspace-a' }: { - client: RpcClient | null + operations: HostSessionNativeChatOperations | null sessionId: string | null agent?: string | null sourceIdentity?: string }): null { const session = useMobileNativeChatSession({ - client, - sourceIdentity, + operations, + workspaceId: 'worktree', agent, sessionId, - transcriptPath: null + transcriptPath: null, + terminalId: 'terminal', + clientId: 'device' }) renders.push({ sessionId, @@ -515,7 +517,10 @@ describe('useMobileNativeChatSession transcriptLoading', () => { return null } - async function mountAt(client: RpcClient | null, sessionId: string | null): Promise { + async function mountAt( + operations: HostSessionNativeChatOperations | null, + sessionId: string | null + ): Promise { const original = console.error const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { @@ -525,7 +530,7 @@ describe('useMobileNativeChatSession transcriptLoading', () => { }) try { await act(async () => { - renderer = create(createElement(Harness, { client, sessionId })) + renderer = create(createElement(Harness, { operations, sessionId })) }) } finally { consoleSpy.mockRestore() @@ -536,7 +541,10 @@ describe('useMobileNativeChatSession transcriptLoading', () => { // `status` starts at 'idle', so on its own it would tell the launch-draft // seed that an empty transcript is this session's real history. const subscribe: RpcClient['subscribe'] = vi.fn(() => () => {}) - await mountAt({ subscribe } as unknown as RpcClient, 'session-a') + const operations = nativeHostSessionNativeChatOperations({ + subscribe + } as unknown as RpcClient) + await mountAt(operations, 'session-a') expect(renders[0]).toMatchObject({ transcriptLoading: true, ids: [] }) }) @@ -551,16 +559,19 @@ describe('useMobileNativeChatSession transcriptLoading', () => { return () => {} }) const client = { subscribe } as unknown as RpcClient - await mountAt(client, 'session-a') + const operations = nativeHostSessionNativeChatOperations(client) + await mountAt(operations, 'session-a') expect(renders.at(-1)).toMatchObject({ status: 'ready', transcriptLoading: false }) // Toggle out to the terminal view, then back. await act(async () => - renderer?.update(createElement(Harness, { client, sessionId: 'session-a', agent: null })) + renderer?.update(createElement(Harness, { operations, sessionId: 'session-a', agent: null })) ) renders.length = 0 await act(async () => - renderer?.update(createElement(Harness, { client, sessionId: 'session-a', agent: 'claude' })) + renderer?.update( + createElement(Harness, { operations, sessionId: 'session-a', agent: 'claude' }) + ) ) expect(renders[0]).toMatchObject({ @@ -579,19 +590,17 @@ describe('useMobileNativeChatSession transcriptLoading', () => { return () => {} }) const client = { subscribe } as unknown as RpcClient - await mountAt(client, 'session-a') + const operations = nativeHostSessionNativeChatOperations(client) + await mountAt(operations, 'session-a') expect(renders.at(-1)).toMatchObject({ status: 'ready' }) - let emitFresh: (frame: unknown) => void = () => {} - const reconnected = { - subscribe: vi.fn((_method: string, _params: unknown, onData: (frame: unknown) => void) => { - emitFresh = onData - return () => {} - }) - } as unknown as RpcClient + const reconnected = { subscribe: vi.fn(() => () => {}) } as unknown as RpcClient + const reconnectedOperations = nativeHostSessionNativeChatOperations(reconnected) renders.length = 0 await act(async () => - renderer?.update(createElement(Harness, { client: reconnected, sessionId: 'session-a' })) + renderer?.update( + createElement(Harness, { operations: reconnectedOperations, sessionId: 'session-a' }) + ) ) expect(renders[0]).toMatchObject({ @@ -647,9 +656,10 @@ describe('useMobileNativeChatSession transcriptLoading', () => { return () => {} }) const client = { subscribe } as unknown as RpcClient - await mountAt(client, 'session-a') + const operations = nativeHostSessionNativeChatOperations(client) + await mountAt(operations, 'session-a') await act(async () => - renderer?.update(createElement(Harness, { client, sessionId: 'session-b' })) + renderer?.update(createElement(Harness, { operations, sessionId: 'session-b' })) ) // The effect that resets the list lands a commit later, so `messages` still diff --git a/mobile/src/session/use-mobile-native-chat-session.ts b/mobile/src/session/use-mobile-native-chat-session.ts index 528d9cd23e1..166335e999d 100644 --- a/mobile/src/session/use-mobile-native-chat-session.ts +++ b/mobile/src/session/use-mobile-native-chat-session.ts @@ -81,10 +81,7 @@ export function useMobileNativeChatSession(args: { // Without this a toggle out of chat view and back (agent null, then the same // identity again) would resurface a settled 'ready' over an emptied list. let current = read - if ( - current !== null && - (current.identity !== identity || current.operations !== operations) - ) { + if (current !== null && (current.identity !== identity || current.operations !== operations)) { current = null setRead(null) } @@ -307,6 +304,7 @@ export function useMobileNativeChatSession(args: { hasMore, loadingEarlier, loadEarlier + } } function nativeChatTarget( diff --git a/mobile/src/session/web-host-session-native-chat-deadlines.test.ts b/mobile/src/session/web-host-session-native-chat-deadlines.test.ts index bd9186e7ab1..6a5cece3a38 100644 --- a/mobile/src/session/web-host-session-native-chat-deadlines.test.ts +++ b/mobile/src/session/web-host-session-native-chat-deadlines.test.ts @@ -34,7 +34,7 @@ describe('hosted native-chat deadlines', () => { } as unknown as MobileWebBridgeClient const operations = webHostSessionNativeChatOperations(client) - await expect(operations.sendMessage(TARGET, 'hello', 20_000)).resolves.toBe('accepted') + await expect(operations.sendMessage(TARGET, 'hello', 20_000, true)).resolves.toBe('accepted') await expect(operations.respond(TARGET, '1', false, 20_000)).resolves.toBe('accepted') await expect(operations.stop(TARGET, 20_000)).resolves.toBe('accepted') @@ -43,7 +43,8 @@ describe('hosted native-chat deadlines', () => { workspaceId: 'workspace', sessionId: 'native_chat_session', text: 'hello', - deadline: 20_000 + deadline: 20_000, + clearInputFirst: true }, { timeoutMs: 10_000 } ) diff --git a/mobile/src/session/web-host-session-native-chat-operations.ts b/mobile/src/session/web-host-session-native-chat-operations.ts index 33bca0a52fc..9b5743eb697 100644 --- a/mobile/src/session/web-host-session-native-chat-operations.ts +++ b/mobile/src/session/web-host-session-native-chat-operations.ts @@ -44,7 +44,7 @@ export function webHostSessionNativeChatOperations( return { error: 'Transcript read failed' } } }, - async sendMessage(target, text, deadline) { + async sendMessage(target, text, deadline, clearInputFirst) { const budget = bridgeBudget(deadline) if (!budget) { return 'rejected' @@ -52,7 +52,11 @@ export function webHostSessionNativeChatOperations( try { return ( await client.nativeChat.sendMessage( - bridgeTarget(target, { text, deadline: budget.deadline }), + bridgeTarget(target, { + text, + deadline: budget.deadline, + ...(clearInputFirst ? { clearInputFirst: true } : {}) + }), { timeoutMs: budget.timeoutMs } ) ).outcome diff --git a/mobile/src/storage/mobile-persisted-state-inventory.test.ts b/mobile/src/storage/mobile-persisted-state-inventory.test.ts index 1f798faf603..e9f8b8eebc3 100644 --- a/mobile/src/storage/mobile-persisted-state-inventory.test.ts +++ b/mobile/src/storage/mobile-persisted-state-inventory.test.ts @@ -10,7 +10,6 @@ const storageImportPattern = const EXPECTED_PERSISTED_STATE_SOURCES = [ 'mobile/app/index.tsx', 'mobile/src/cache/home-snapshot-cache.ts', - 'mobile/src/hybrid-prototype/mobile-web-prototype-cache.ts', 'mobile/src/mobile-web/mobile-web-cold-resume-route.ts', 'mobile/src/notifications/notification-reconnect-catchup.ts', 'mobile/src/session/session-last-visited-worktree.ts', diff --git a/src/main/runtime/mobile-rpc-allowlist.test.ts b/src/main/runtime/mobile-rpc-allowlist.test.ts index 76bcd78ff5e..e5db71b515e 100644 --- a/src/main/runtime/mobile-rpc-allowlist.test.ts +++ b/src/main/runtime/mobile-rpc-allowlist.test.ts @@ -15,10 +15,6 @@ const MOBILE_DYNAMIC_RPC_METHODS = [ 'github.updatePRState', 'gitlab.updateIssue', 'gitlab.updateMR', - // Prototype asset requests pass through a downloader callback, so they are - // not visible to the literal sendRequest scan. - 'mobileWeb.prototype.chunk', - 'mobileWeb.prototype.manifest', // Production asset requests will also pass through the native package // downloader rather than literal feature call sites. 'mobileWeb.package.asset', diff --git a/src/main/runtime/rpc/methods/index.ts b/src/main/runtime/rpc/methods/index.ts index a50fcdc5ca5..c1e24f83747 100644 --- a/src/main/runtime/rpc/methods/index.ts +++ b/src/main/runtime/rpc/methods/index.ts @@ -39,7 +39,6 @@ import { EMULATOR_METHODS } from './emulator' import { PAIRING_METHODS } from './pairing' import { UPDATER_METHODS } from './updater' import { AGENT_SESSION_METHODS } from './agent-session' -import { MOBILE_WEB_PROTOTYPE_METHODS } from './mobile-web-prototype' import { MOBILE_WEB_PACKAGE_METHODS } from './mobile-web-package' import { MOBILE_FILE_WRITE_METHODS } from './mobile-file-write-if-unchanged' @@ -89,6 +88,5 @@ export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [ ...EMULATOR_METHODS, ...PAIRING_METHODS, ...UPDATER_METHODS, - ...MOBILE_WEB_PACKAGE_METHODS, - ...MOBILE_WEB_PROTOTYPE_METHODS + ...MOBILE_WEB_PACKAGE_METHODS ] diff --git a/src/main/runtime/rpc/methods/mobile-web-prototype.ts b/src/main/runtime/rpc/methods/mobile-web-prototype.ts deleted file mode 100644 index fdb792f65db..00000000000 --- a/src/main/runtime/rpc/methods/mobile-web-prototype.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { z } from 'zod' -import { defineMethod, InvalidArgumentError, type RpcMethod } from '../core' -import { - getMobileWebPrototypeChunk, - getMobileWebPrototypeManifest -} from '../mobile-web-prototype-assets' - -const MobileWebPrototypeChunkParams = z.object({ - buildId: z.string().regex(/^[a-f0-9]{64}$/), - offset: z.number().int().nonnegative() -}) - -export const MOBILE_WEB_PROTOTYPE_METHODS: RpcMethod[] = [ - defineMethod({ - name: 'mobileWeb.prototype.manifest', - params: null, - handler: () => getMobileWebPrototypeManifest() - }), - defineMethod({ - name: 'mobileWeb.prototype.chunk', - params: MobileWebPrototypeChunkParams, - handler: ({ buildId, offset }) => { - try { - return getMobileWebPrototypeChunk(buildId, offset) - } catch (error) { - const message = error instanceof Error ? error.message : 'mobile_web_prototype_unavailable' - throw new InvalidArgumentError(message) - } - } - }) -] diff --git a/src/main/runtime/rpc/mobile-web-prototype-assets.test.ts b/src/main/runtime/rpc/mobile-web-prototype-assets.test.ts deleted file mode 100644 index 5a9e9359941..00000000000 --- a/src/main/runtime/rpc/mobile-web-prototype-assets.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { createHash } from 'node:crypto' -import { describe, expect, it } from 'vitest' -import { - MOBILE_WEB_PROTOTYPE_CHUNK_BYTES, - MOBILE_WEB_PROTOTYPE_MAX_BYTES -} from '../../../shared/mobile-web-prototype-contract' -import { - getMobileWebPrototypeChunk, - getMobileWebPrototypeManifest -} from './mobile-web-prototype-assets' - -describe('mobile web prototype assets', () => { - it('serves a content-addressed document in bounded chunks', () => { - const manifest = getMobileWebPrototypeManifest() - const chunks: Buffer[] = [] - - for (let offset = 0; offset < manifest.byteLength; offset += manifest.chunkBytes) { - const chunk = getMobileWebPrototypeChunk(manifest.buildId, offset) - expect(chunk.offset).toBe(offset) - expect(chunk.byteLength).toBeLessThanOrEqual(MOBILE_WEB_PROTOTYPE_CHUNK_BYTES) - chunks.push(Buffer.from(chunk.dataBase64, 'base64')) - } - - const document = Buffer.concat(chunks).toString('utf8') - expect(document).toContain('Content-Security-Policy') - expect(document).toContain('window.ReactNativeWebView.postMessage') - expect(document).toContain('id="run-probe"') - expect(manifest.byteLength).toBeGreaterThan(320 * 1024) - expect(manifest.byteLength).toBeLessThan(MOBILE_WEB_PROTOTYPE_MAX_BYTES) - expect(document).toContain( - `const packageKiB=Number("${String(Math.ceil(manifest.byteLength / 1024)).padStart(6, '0')}")` - ) - expect(Buffer.byteLength(document)).toBe(manifest.byteLength) - }) - - it('rejects stale build identities and invalid offsets', () => { - const manifest = getMobileWebPrototypeManifest() - expect(() => getMobileWebPrototypeChunk('0'.repeat(64), 0)).toThrow( - 'mobile_web_prototype_build_changed' - ) - expect(() => getMobileWebPrototypeChunk(manifest.buildId, manifest.byteLength)).toThrow( - 'mobile_web_prototype_offset_invalid' - ) - }) - - it('binds the exact inline code to a network-disabled content policy', () => { - const document = Buffer.from( - getMobileWebPrototypeChunk(getMobileWebPrototypeManifest().buildId, 0).dataBase64, - 'base64' - ).toString('utf8') - const style = document.match(/ - - -
-

Desktop-served prototype

-

Paired host

-

This workspace list is rendered by web code delivered through the paired Orca connection.

-
Connecting
-
Waiting for the native bridge...
-
-

Performance lab

Synthetic terminal churn and a large diff, isolated from host data.

- -
-
-
- - - -` -} diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index 50ee549ceed..188130f06bc 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -352,8 +352,6 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'linear.updateIssue', 'markdown.readTab', 'markdown.saveTab', - 'mobileWeb.prototype.chunk', - 'mobileWeb.prototype.manifest', 'mobileWeb.package.asset', 'mobileWeb.package.manifest', 'notifications.getMissedSince', diff --git a/src/mobile-web/src/native-shell-channel.test.tsx b/src/mobile-web/src/native-shell-channel.test.tsx index 1c9f4d3fc0c..0c88c8538fa 100644 --- a/src/mobile-web/src/native-shell-channel.test.tsx +++ b/src/mobile-web/src/native-shell-channel.test.tsx @@ -69,6 +69,7 @@ describe('mobile web native shell channel', () => { workspaceId: 'opaque-workspace', workspaceName: 'Feature' }) + expect(hook.result.current.navigationRoute).toEqual(hook.result.current.resumeRoute) expect(posted).toContainEqual({ version: MOBILE_WEB_BRIDGE_PROTOCOL_VERSION, type: 'ready', @@ -78,6 +79,7 @@ describe('mobile web native shell channel', () => { expect(hook.result.current.rememberRoute({ kind: 'workspaceList' })).toBe(true) }) expect(hook.result.current.resumeRoute).toEqual({ kind: 'workspaceList' }) + expect(hook.result.current.navigationRoute).toEqual({ kind: 'workspaceList' }) expect(hook.result.current.routeRevision).toBe(1) expect(posted).toContainEqual({ version: MOBILE_WEB_BRIDGE_PROTOCOL_VERSION, @@ -101,16 +103,17 @@ describe('mobile web native shell channel', () => { }) ) ) - expect(hook.result.current.resumeRoute).toEqual({ + expect(hook.result.current.navigationRoute).toEqual({ kind: 'session', workspaceId: 'notification-target', workspaceName: 'Notification target' }) + expect(hook.result.current.resumeRoute).toEqual({ kind: 'workspaceList' }) expect(hook.result.current.routeRevision).toBe(2) act(() => dispatchShellMessage(navigationMessage(2, { kind: 'workspaceList' }))) act(() => dispatchShellMessage(navigationMessage(1, { kind: 'workspaceList' }))) - expect(hook.result.current.resumeRoute).toEqual({ + expect(hook.result.current.navigationRoute).toEqual({ kind: 'session', workspaceId: 'notification-target', workspaceName: 'Notification target' diff --git a/src/mobile-web/src/native-shell-channel.ts b/src/mobile-web/src/native-shell-channel.ts index 894189b34a4..4f45a8a1789 100644 --- a/src/mobile-web/src/native-shell-channel.ts +++ b/src/mobile-web/src/native-shell-channel.ts @@ -14,6 +14,7 @@ import { parseMobileWebBridgeShellMessage, type MobileWebBridgeMessageContext, type MobileWebBridgePageMessage, + type MobileWebNavigationRoute, type MobileWebResumeRoute } from '../../shared/mobile-web/bridge-contract' import { MobileWebBridgeClient } from './mobile-web-bridge-client' @@ -33,6 +34,7 @@ export type MobileWebNativeShellState = { connection: 'connecting' | 'connected' | 'offline' | 'recovering' reconnectAttempts: number lastConnectedAt: number | null + navigationRoute: MobileWebNavigationRoute resumeRoute: MobileWebResumeRoute routeRevision: number rememberRoute: (route: MobileWebResumeRoute) => boolean @@ -60,6 +62,7 @@ function useMobileWebNativeShellChannel(): MobileWebNativeShellState { connection: 'connecting', reconnectAttempts: 0, lastConnectedAt: null, + navigationRoute: { kind: 'workspaceList' }, resumeRoute: { kind: 'workspaceList' }, routeRevision: 0, rememberRoute: () => false @@ -72,6 +75,7 @@ function useMobileWebNativeShellChannel(): MobileWebNativeShellState { reconnectAttempts: 0, lastConnectedAt: null } + let navigationRoute: MobileWebNavigationRoute = { kind: 'workspaceList' } let resumeRoute: MobileWebResumeRoute = { kind: 'workspaceList' } let routeRevision = 0 let lastNavigationSequence = -1 @@ -89,13 +93,17 @@ function useMobileWebNativeShellChannel(): MobileWebNativeShellState { metrics = nextMobileWebShellConnectionMetrics(metrics, init, retainsContext) if (!retainsContext) { resumeRoute = init.resumeRoute ?? { kind: 'workspaceList' } + navigationRoute = resumeRoute routeRevision += 1 lastNavigationSequence = -1 } rememberRoute = (route) => { resumeRoute = route + navigationRoute = route setState((current) => - sameContext(current.context, nextContext) ? { ...current, resumeRoute: route } : current + sameContext(current.context, nextContext) + ? { ...current, navigationRoute: route, resumeRoute: route } + : current ) return postPageMessage({ version: MOBILE_WEB_BRIDGE_PROTOCOL_VERSION, @@ -109,6 +117,7 @@ function useMobileWebNativeShellChannel(): MobileWebNativeShellState { client, context, connection: init.connection, + navigationRoute, resumeRoute, routeRevision, rememberRoute, @@ -133,6 +142,7 @@ function useMobileWebNativeShellChannel(): MobileWebNativeShellState { client, context, connection: init.connection, + navigationRoute, resumeRoute, routeRevision, rememberRoute, @@ -159,11 +169,11 @@ function useMobileWebNativeShellChannel(): MobileWebNativeShellState { return } lastNavigationSequence = parsed.value.sequence - resumeRoute = parsed.value.route + navigationRoute = parsed.value.route routeRevision += 1 setState((current) => sameContext(current.context, activeContext) - ? { ...current, resumeRoute, routeRevision, rememberRoute } + ? { ...current, navigationRoute, routeRevision, rememberRoute } : current ) return @@ -175,6 +185,7 @@ function useMobileWebNativeShellChannel(): MobileWebNativeShellState { client, context, connection: parsed.value.state, + navigationRoute, resumeRoute, routeRevision, rememberRoute, diff --git a/src/shared/mobile-web-prototype-contract.ts b/src/shared/mobile-web-prototype-contract.ts deleted file mode 100644 index 4f46e8c075c..00000000000 --- a/src/shared/mobile-web-prototype-contract.ts +++ /dev/null @@ -1,53 +0,0 @@ -export const MOBILE_WEB_PROTOTYPE_PROTOCOL_VERSION = 1 -export const MOBILE_WEB_PROTOTYPE_CHUNK_BYTES = 48 * 1024 -export const MOBILE_WEB_PROTOTYPE_MAX_BYTES = 512 * 1024 - -export type MobileWebPrototypeManifest = { - protocolVersion: typeof MOBILE_WEB_PROTOTYPE_PROTOCOL_VERSION - buildId: string - sha256: string - byteLength: number - chunkBytes: number - contentType: 'text/html; charset=utf-8' -} - -export type MobileWebPrototypeChunk = { - buildId: string - offset: number - byteLength: number - sha256: string - dataBase64: string -} - -export type MobileWebPrototypeWorkspace = { - id: string - name: string - repo: string - branch: string - isActive: boolean - liveTerminalCount: number -} - -export type MobileWebPrototypeRequest = - | { v: 1; type: 'ready' } - | { v: 1; type: 'workspace.list'; id: string } - | { v: 1; type: 'haptic.selection'; id: string } - -export type MobileWebPrototypeResponse = - | { - v: 1 - type: 'init' - buildId: string - host: { id: string; name: string } - connection: string - capabilities: readonly ['workspace.list', 'haptic.selection'] - } - | { v: 1; type: 'connection'; state: string } - | { - v: 1 - type: 'response' - id: string - ok: true - result: { workspaces: MobileWebPrototypeWorkspace[] } | null - } - | { v: 1; type: 'response'; id: string; ok: false; error: string } diff --git a/src/shared/mobile-web/bridge-contract.test.ts b/src/shared/mobile-web/bridge-contract.test.ts index 91af40f7317..a3ab070288c 100644 --- a/src/shared/mobile-web/bridge-contract.test.ts +++ b/src/shared/mobile-web/bridge-contract.test.ts @@ -304,12 +304,35 @@ describe('mobile web bridge shell contract', () => { } } expect(MobileWebBridgeShellMessageSchema.safeParse(navigation).success).toBe(true) + expect( + MobileWebBridgeShellMessageSchema.safeParse({ + ...navigation, + route: { kind: 'tasks', taskSource: 'gitlab' } + }).success + ).toBe(true) + expect( + MobileWebBridgeShellMessageSchema.safeParse({ + ...navigation, + route: { kind: 'tasks', taskSource: 'jira' } + }).success + ).toBe(false) expect( MobileWebBridgeShellMessageSchema.safeParse({ ...navigation, route: { ...navigation.route, hostWorkspaceId: '/private/orca' } }).success ).toBe(false) + expect( + MobileWebBridgeShellMessageSchema.safeParse({ + version: MOBILE_WEB_BRIDGE_PROTOCOL_VERSION, + type: 'init', + shellSessionId: SHELL_SESSION_ID, + buildId: BUILD_ID, + connection: 'connected', + grants: [operationGrant()], + resumeRoute: { kind: 'accounts' } + }).success + ).toBe(false) expect( MobileWebBridgeShellMessageSchema.safeParse({ ...navigation, sequence: -1 }).success ).toBe(false) diff --git a/src/shared/mobile-web/bridge-contract.ts b/src/shared/mobile-web/bridge-contract.ts index c77018e1aae..7b0002e7c68 100644 --- a/src/shared/mobile-web/bridge-contract.ts +++ b/src/shared/mobile-web/bridge-contract.ts @@ -73,15 +73,31 @@ const PageHealthSchema = PageEnvelopeSchema.extend({ state: z.literal('interactive') }).strict() +const MobileWebWorkspaceListRouteSchema = z.object({ kind: z.literal('workspaceList') }).strict() +const MobileWebSessionRouteSchema = z + .object({ + kind: z.literal('session'), + workspaceId: MobileWebWorkspaceIdSchema, + workspaceName: z.string().max(240) + }) + .strict() + export const MobileWebResumeRouteSchema = z.discriminatedUnion('kind', [ - z.object({ kind: z.literal('workspaceList') }).strict(), + MobileWebWorkspaceListRouteSchema, + MobileWebSessionRouteSchema +]) + +export const MobileWebNavigationRouteSchema = z.discriminatedUnion('kind', [ + MobileWebWorkspaceListRouteSchema, + MobileWebSessionRouteSchema, z .object({ - kind: z.literal('session'), - workspaceId: MobileWebWorkspaceIdSchema, - workspaceName: z.string().max(240) + kind: z.literal('tasks'), + taskSource: z.enum(['github', 'gitlab', 'linear']).optional() }) - .strict() + .strict(), + z.object({ kind: z.literal('accounts') }).strict(), + z.object({ kind: z.literal('newWorkspace') }).strict() ]) const PageRouteStateSchema = PageEnvelopeSchema.extend({ @@ -173,7 +189,7 @@ const ShellConnectionSchema = ShellEnvelopeSchema.extend({ const ShellNavigationSchema = ShellEnvelopeSchema.extend({ type: z.literal('navigation'), sequence: SequenceSchema, - route: MobileWebResumeRouteSchema + route: MobileWebNavigationRouteSchema }).strict() const ShellSuccessResponseSchema = ShellEnvelopeSchema.extend({ @@ -209,6 +225,7 @@ export const MobileWebBridgeShellMessageSchema = z.union([ export type MobileWebBridgeErrorCode = z.infer export type MobileWebBridgePageMessage = z.infer export type MobileWebBridgeShellMessage = z.infer +export type MobileWebNavigationRoute = z.infer export type MobileWebResumeRoute = z.infer export type MobileWebBridgeMessageContext = { diff --git a/src/shared/mobile-web/native-chat-operation-contract.test.ts b/src/shared/mobile-web/native-chat-operation-contract.test.ts index ec5d2b1f93e..095954c9966 100644 --- a/src/shared/mobile-web/native-chat-operation-contract.test.ts +++ b/src/shared/mobile-web/native-chat-operation-contract.test.ts @@ -24,7 +24,8 @@ describe('mobile web native-chat operation contract', () => { MobileWebNativeChatSendMessagePayloadSchema.safeParse({ ...TARGET, text: 'hello', - deadline + deadline, + clearInputFirst: true }).success ).toBe(true) expect( @@ -41,6 +42,14 @@ describe('mobile web native-chat operation contract', () => { expect( MobileWebNativeChatSendMessagePayloadSchema.safeParse({ ...TARGET, text: 'hello' }).success ).toBe(false) + expect( + MobileWebNativeChatSendMessagePayloadSchema.safeParse({ + ...TARGET, + text: 'hello', + deadline, + clearInputFirst: 'true' + }).success + ).toBe(false) expect( MobileWebNativeChatStopPayloadSchema.safeParse({ ...TARGET, diff --git a/src/shared/mobile-web/native-chat-operation-contract.ts b/src/shared/mobile-web/native-chat-operation-contract.ts index 5781a4e904f..3f5c13cbe72 100644 --- a/src/shared/mobile-web/native-chat-operation-contract.ts +++ b/src/shared/mobile-web/native-chat-operation-contract.ts @@ -144,7 +144,8 @@ export const MobileWebNativeChatSendMessagePayloadSchema = z text: z .string() .min(1) - .max(64 * 1024) + .max(64 * 1024), + clearInputFirst: z.boolean().optional() }) .strict() export const MobileWebNativeChatRespondPayloadSchema = z