feat(mobile): complete gated hybrid cutover seam

This commit is contained in:
OrcaWin
2026-08-09 18:34:59 -04:00
committed by Jinwoo-H
parent 976a9bca46
commit 0a91c04a49
52 changed files with 821 additions and 1612 deletions
@@ -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. |
@@ -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,
@@ -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.
@@ -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.
+6 -2
View File
@@ -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() {
/>
<Stack.Screen name="settings" options={{ headerShown: false }} />
<Stack.Screen name="hybrid" options={{ headerShown: false }} />
<Stack.Screen name="hybrid-prototype" options={{ headerShown: false }} />
<Stack.Screen name="terminal-settings" options={{ headerShown: false }} />
<Stack.Screen name="native-chat-settings" options={{ headerShown: false }} />
<Stack.Screen name="browser-settings" options={{ headerShown: false }} />
-346
View File
@@ -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<WebView>(null)
const webReadyRef = useRef(false)
const activeHostIdRef = useRef<string | undefined>(undefined)
const [hosts, setHosts] = useState<PrototypeHost[]>([])
const [hostsLoading, setHostsLoading] = useState(true)
const [hostLoadError, setHostLoadError] = useState(false)
const [selectedHostId, setSelectedHostId] = useState<string>()
const [prototypePackage, setPrototypePackage] =
useState<VerifiedMobileWebPrototypePackage | null>(null)
const [packageLoading, setPackageLoading] = useState(false)
const [packageWarning, setPackageWarning] = useState<string>()
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 (
<View style={[styles.container, { paddingTop: insets.top }]}>
<View style={styles.header}>
<Pressable
accessibilityLabel="Back"
hitSlop={8}
style={styles.headerButton}
onPress={handleBack}
>
<ChevronLeft size={22} color={colors.textSecondary} />
</Pressable>
<View style={styles.headerCopy}>
<Text numberOfLines={1} style={styles.heading}>
{selectedHost?.name ?? 'Hybrid UI prototype'}
</Text>
<Text style={styles.headerMeta}>Experimental desktop-served UI</Text>
</View>
{selectedHost ? (
<Pressable style={styles.hostsButton} onPress={() => setSelectedHostId(undefined)}>
<Text style={styles.hostsButtonText}>Hosts</Text>
</Pressable>
) : null}
</View>
{!selectedHost ? (
<MobileWebHostPicker
hosts={hosts}
loading={hostsLoading}
failed={hostLoadError}
onRetry={() => void refreshHosts()}
onSelect={selectHost}
/>
) : prototypePackage ? (
<View style={styles.webContainer}>
{packageWarning ? <Text style={styles.warning}>{packageWarning}</Text> : null}
<WebView
key={prototypePackage.manifest.buildId}
ref={webViewRef}
source={{ html: prototypePackage.html, baseUrl: DOCUMENT_URL }}
originWhitelist={[DOCUMENT_ORIGIN, 'about:blank']}
javaScriptEnabled
domStorageEnabled={false}
allowFileAccess={false}
allowUniversalAccessFromFileURLs={false}
mixedContentMode="never"
setSupportMultipleWindows={false}
javaScriptCanOpenWindowsAutomatically={false}
onLoadStart={() => {
webReadyRef.current = false
}}
onMessage={(event) => void handleWebMessage(event)}
onShouldStartLoadWithRequest={isAllowedNavigation}
onContentProcessDidTerminate={() => webViewRef.current?.reload()}
onRenderProcessGone={() => webViewRef.current?.reload()}
style={styles.webView}
/>
</View>
) : (
<View style={styles.loadingState}>
{packageLoading ? (
<ActivityIndicator color={colors.textSecondary} />
) : (
<MonitorSmartphone size={26} color={colors.textMuted} />
)}
<Text style={styles.loadingTitle}>
{packageLoading ? 'Loading desktop UI…' : 'Prototype unavailable'}
</Text>
{packageWarning ? <Text style={styles.loadingBody}>{packageWarning}</Text> : null}
</View>
)}
</View>
)
}
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'
}
})
+104 -36
View File
@@ -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() {
<>
<Text style={[styles.sectionHeading, styles.sectionHeadingTightTop]}>Resume</Text>
<Pressable
disabled={!resumeCard.actionable}
style={({ pressed }) => [
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
}
})
}
>
<View style={styles.resumeIcon}>
<Terminal size={18} color={colors.textSecondary} />
@@ -843,11 +876,40 @@ export default function HomeScreen() {
{renderTaskHomeCard()}
{/* ─── Quick actions ─── */}
<MobileHomeQuickActions
connectedHosts={connectedHosts}
onPairDesktop={() => router.push('/pair-scan')}
onCreateWorkspace={(hostId) => router.push(hostNewWorktreeRoute(hostId))}
/>
<Text style={[styles.sectionHeading, { marginTop: spacing.xl }]}>Quick Actions</Text>
<View style={styles.quickActions}>
<Pressable
style={({ pressed }) => [styles.quickAction, pressed && styles.hostCardPressed]}
onPress={() => router.push('/pair-scan')}
>
<View style={styles.quickActionIcon}>
<QrCode size={16} color={colors.textSecondary} />
</View>
<Text style={styles.quickActionLabel}>Pair Desktop</Text>
</Pressable>
<Pressable
disabled={!primaryConnectedHost}
style={({ pressed }) => [
styles.quickAction,
!primaryConnectedHost && styles.quickActionDisabled,
pressed && styles.hostCardPressed
]}
onPress={() => {
if (primaryConnectedHost) {
navigateFromMobileHome({
router,
hostId: primaryConnectedHost.id,
target: { kind: 'newWorkspace' }
})
}
}}
>
<View style={styles.quickActionIcon}>
<Plus size={16} color={colors.textSecondary} />
</View>
<Text style={styles.quickActionLabel}>New Workspace</Text>
</Pressable>
</View>
{/* ─── 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 ? (
<Text style={styles.accountsHostLabel} numberOfLines={1}>
+2 -1
View File
@@ -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(() => {
@@ -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
}
@@ -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')
})
})
@@ -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<string, unknown> {
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
}
@@ -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<string, string>()
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('<p>cached</p>')
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('<p>trusted</p>')
await saveMobileWebPrototypePackage('host', cached)
const packageEntry = [...values.entries()].find(([key]) => key.includes(':package:'))
expect(packageEntry).toBeDefined()
values.set(
packageEntry![0],
JSON.stringify({ ...cached, html: '<p>modified after caching</p>' })
)
await expect(loadCachedMobileWebPrototypePackage('host')).resolves.toBeNull()
})
it('keeps the previous package if publishing the new pointer fails', async () => {
const previous = prototypePackage('<p>previous</p>')
const next = prototypePackage('<p>next</p>')
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)
})
})
@@ -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<VerifiedMobileWebPrototypePackage | null> {
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<void> {
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(() => {})
}
}
@@ -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<RpcResponse> => {
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 = '<!doctype html><title>Orca</title>'
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('<p>tamper test</p>', true))
).rejects.toThrow('chunk integrity')
})
it('rejects oversized and unsupported manifests before requesting chunks', async () => {
const request = async (): Promise<RpcResponse> =>
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'
)
})
})
@@ -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<RpcResponse>
function sha256Hex(bytes: Uint8Array): string {
return Buffer.from(sha256(bytes)).toString('hex')
}
function isRecord(value: unknown): value is Record<string, unknown> {
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<MobileWebPrototypeManifest>
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<MobileWebPrototypeChunk>
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<unknown> {
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<VerifiedMobileWebPrototypePackage> {
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
}
}
@@ -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([])
})
})
@@ -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,
@@ -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())
@@ -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}` : ''
}
@@ -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' }
},
@@ -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<MobileNativeChatSendOutcome> {
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' }
},
@@ -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)
})
})
@@ -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)
}
@@ -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)
})
})
@@ -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}`
@@ -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
}
@@ -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({
@@ -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<MobileWebNavigationRoute> {
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'
}
@@ -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',
@@ -49,7 +49,8 @@ export type HostSessionNativeChatOperations = {
sendMessage(
target: HostSessionNativeChatTarget,
text: string,
deadline?: number
deadline?: number,
clearInputFirst?: boolean
): Promise<MobileNativeChatSendOutcome>
prepareCommit(target: HostSessionNativeChatTarget, deadline?: number): Promise<boolean>
respond(
@@ -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<boolean>
/** Resolved agent for the active chat tab (names the empty-state copy). */
nativeChatAgent: string | null
chatComposerText: string
setChatComposerText: Dispatch<SetStateAction<string>>
chatPending: MobileNativeChatPendingMessage[]
chatImagePreviewsByMessageId: Record<string, string[]>
nativeChatSession: ReturnType<typeof useMobileNativeChatSession>
nativeChatAgentWorking: boolean
nativeChatTargetRef: MutableRefObject<HostSessionNativeChatTarget | null>
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<typeof detectAgentPermission>
nativeChatQuestion: ReturnType<typeof parseAgentQuestion>
/** The pending ask, already null while dismissed (dismissal lives here so it
* survives the chat-view subtree unmounting on a view toggle). */
nativeChatAsk: ReturnType<typeof parseAskFromStatus>
/** 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<boolean>
handleNativeChatOpenFile: (relativePath: string) => void
handleNativeChatAnswerAsk: MobileNativeChatAnswerSend['answerAsk']
handleNativeChatCancelAsk: () => Promise<boolean>
handleNativeChatRespondPermission: (text: string) => Promise<boolean>
handleNativeChatStop: () => void
@@ -46,19 +33,9 @@ export type MobileNativeChatController = {
loadNativeChatFiles: (query: string) => void
handleNativeChatQuestionAnswer: (text: string) => Promise<boolean>
handleNativeChatSend: (text: string, images?: string[]) => Promise<boolean>
/** 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<MobileNativeChatSendOutcome>
/** 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
}
@@ -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<MobileNativeChatSendOutcome> {
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 } } : {})
})
@@ -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<boolean>
/** Resolved agent for the active chat tab (names the empty-state copy). */
nativeChatAgent: string | null
chatComposerText: string
setChatComposerText: Dispatch<SetStateAction<string>>
chatPending: MobileNativeChatPendingMessage[]
nativeChatSession: ReturnType<typeof useMobileNativeChatSession>
nativeChatAgentWorking: boolean
nativeChatTargetRef: MutableRefObject<HostSessionNativeChatTarget | null>
nativeChatStreamingText?: string
nativeChatPermission: ReturnType<typeof detectAgentPermission>
nativeChatQuestion: ReturnType<typeof parseAgentQuestion>
nativeChatAsk: ReturnType<typeof parseAskFromStatus>
handleNativeChatOpenFile: (relativePath: string) => void
handleNativeChatAnswerAsk: MobileNativeChatAnswerSend['answerAsk']
handleNativeChatCancelAsk: () => Promise<boolean>
handleNativeChatRespondPermission: (text: string) => Promise<boolean>
handleNativeChatStop: () => void
nativeChatFilePaths: string[]
loadNativeChatFiles: (query: string) => void
handleNativeChatQuestionAnswer: (text: string) => Promise<boolean>
handleNativeChatSend: (text: string, images?: string[]) => Promise<boolean>
/** 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<MobileNativeChatSendOutcome>
}
/** 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: {
@@ -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<void> {
async function mountAt(
operations: HostSessionNativeChatOperations | null,
sessionId: string | null
): Promise<void> {
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
@@ -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(
@@ -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 }
)
@@ -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
@@ -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',
@@ -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',
+1 -3
View File
@@ -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
]
@@ -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)
}
}
})
]
@@ -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(/<style>([\s\S]*?)<\/style>/)?.[1]
const script = document.match(/<script>([\s\S]*?)<\/script>/)?.[1]
expect(style).toBeDefined()
expect(script).toBeDefined()
const hash = (value: string) => createHash('sha256').update(value, 'utf8').digest('base64')
expect(document).toContain(`style-src 'sha256-${hash(style!)}'`)
expect(document).toContain(`script-src 'sha256-${hash(script!)}'`)
expect(document).toContain("connect-src 'none'")
expect(document).not.toContain('fetch(')
})
})
@@ -1,67 +0,0 @@
import { createHash } from 'node:crypto'
import {
MOBILE_WEB_PROTOTYPE_CHUNK_BYTES,
MOBILE_WEB_PROTOTYPE_PROTOCOL_VERSION,
type MobileWebPrototypeChunk,
type MobileWebPrototypeManifest
} from '../../../shared/mobile-web-prototype-contract'
import { buildMobileWebPrototypeDocument } from './mobile-web-prototype-document'
type MobileWebPrototypePackage = {
bytes: Buffer
manifest: MobileWebPrototypeManifest
}
let cachedPackage: MobileWebPrototypePackage | null = null
function sha256Hex(value: Uint8Array): string {
return createHash('sha256').update(value).digest('hex')
}
function getPackage(): MobileWebPrototypePackage {
if (cachedPackage) {
return cachedPackage
}
const bytes = Buffer.from(buildMobileWebPrototypeDocument(), 'utf8')
const sha256 = sha256Hex(bytes)
cachedPackage = {
bytes,
manifest: {
protocolVersion: MOBILE_WEB_PROTOTYPE_PROTOCOL_VERSION,
buildId: sha256,
sha256,
byteLength: bytes.byteLength,
chunkBytes: MOBILE_WEB_PROTOTYPE_CHUNK_BYTES,
contentType: 'text/html; charset=utf-8'
}
}
return cachedPackage
}
export function getMobileWebPrototypeManifest(): MobileWebPrototypeManifest {
return getPackage().manifest
}
export function getMobileWebPrototypeChunk(
buildId: string,
offset: number
): MobileWebPrototypeChunk {
const prototypePackage = getPackage()
if (buildId !== prototypePackage.manifest.buildId) {
throw new Error('mobile_web_prototype_build_changed')
}
if (offset < 0 || offset >= prototypePackage.bytes.byteLength) {
throw new Error('mobile_web_prototype_offset_invalid')
}
const bytes = prototypePackage.bytes.subarray(
offset,
Math.min(offset + MOBILE_WEB_PROTOTYPE_CHUNK_BYTES, prototypePackage.bytes.byteLength)
)
return {
buildId,
offset,
byteLength: bytes.byteLength,
sha256: sha256Hex(bytes),
dataBase64: bytes.toString('base64')
}
}
@@ -1,113 +0,0 @@
import { createHash } from 'node:crypto'
const PROTOTYPE_STYLES = `
:root{color-scheme:light dark;--background:#fff;--foreground:#0a0a0a;--card:#fff;--card-foreground:#0a0a0a;--muted:#f5f5f5;--muted-foreground:#737373;--accent:#f5f5f5;--accent-foreground:#171717;--border:#e5e5e5;--ring:#a1a1a1;--status-success:#15803d;--status-error:#dc2626}
@media(prefers-color-scheme:dark){:root{--background:#0a0a0a;--foreground:#fafafa;--card:#171717;--card-foreground:#fafafa;--muted:#262626;--muted-foreground:#a1a1a1;--accent:#404040;--accent-foreground:#fafafa;--border:rgb(255 255 255/.07);--ring:#737373;--status-success:#86efac;--status-error:#fca5a5}}
*{box-sizing:border-box}
body{margin:0;background:var(--background);color:var(--foreground);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;font-size:14px;line-height:1.5}
.shell{min-height:100dvh;padding:max(18px,env(safe-area-inset-top)) 16px max(24px,env(safe-area-inset-bottom))}
.eyebrow{margin:0 0 6px;color:var(--muted-foreground);font-size:11px;font-weight:600;letter-spacing:.05em;text-transform:uppercase}
h1{margin:0;font-size:20px;line-height:1.2}
.subhead{margin:8px 0 18px;color:var(--muted-foreground);font-size:12px}
.status{display:flex;align-items:center;gap:7px;margin-bottom:16px;color:var(--muted-foreground);font-size:12px}
.dot{width:8px;height:8px;border-radius:999px;background:var(--ring)}
.dot.connected{background:var(--status-success)}
.list{display:grid;gap:8px}
.row{width:100%;padding:13px 14px;border:1px solid var(--border);border-radius:8px;background:var(--card);color:var(--card-foreground);font:inherit;text-align:left}
.row:active,.row:focus-visible{background:var(--accent);outline:2px solid var(--ring);outline-offset:1px}
.row-title{display:flex;justify-content:space-between;gap:12px;font-size:13px;font-weight:600}
.branch,.empty,.error{margin-top:4px;color:var(--muted-foreground);font-size:12px}
.badge{flex:none;color:var(--muted-foreground);font-size:11px;font-weight:500}
.empty,.error{padding:18px;border:1px solid var(--border);border-radius:8px;background:var(--card)}
.error{color:var(--foreground)}
.probe{margin-top:18px;padding:14px;border:1px solid var(--border);border-radius:8px;background:var(--card)}
.probe-head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px}
.probe-title{margin:0;font-size:13px;font-weight:600}
.probe-copy{margin:3px 0 0;color:var(--muted-foreground);font-size:11px}
.probe-button{flex:none;padding:8px 10px;border:1px solid var(--border);border-radius:6px;background:var(--foreground);color:var(--background);font:inherit;font-size:12px;font-weight:600}
.probe-button:disabled{opacity:.55}
.probe-result{margin-top:12px;padding:10px;border-radius:6px;background:var(--muted);font:11px/1.55 ui-monospace,SFMono-Regular,Menlo,monospace;white-space:pre-wrap}
.probe-stage{display:grid;grid-template-rows:120px 180px;gap:8px;margin-top:10px}
.terminal-probe,.diff-probe{overflow:auto;margin:0;border:1px solid var(--border);border-radius:6px;background:var(--background);font:10px/1.35 ui-monospace,SFMono-Regular,Menlo,monospace}
.terminal-probe{padding:8px;white-space:pre}
.diff-line{min-width:max-content;padding:0 8px;white-space:pre}
.diff-line.added{background:color-mix(in srgb,var(--status-success) 14%,transparent)}
.diff-line.deleted{background:color-mix(in srgb,var(--status-error) 13%,transparent)}
`.trim()
const PROTOTYPE_SCRIPT = `
(()=>{"use strict";
const root=document.getElementById("workspace-list");
const host=document.getElementById("host");
const connection=document.getElementById("connection");
const dot=document.getElementById("dot");
const probeButton=document.getElementById("run-probe");
const probeResult=document.getElementById("probe-result");
const terminalProbe=document.getElementById("terminal-probe");
const diffProbe=document.getElementById("diff-probe");
const packageKiB=Number("000000");
let sequence=0;
const send=(message)=>window.ReactNativeWebView.postMessage(JSON.stringify({v:1,...message}));
const text=(value,fallback="")=>typeof value==="string"?value:fallback;
const renderError=(message)=>{root.replaceChildren();const node=document.createElement("div");node.className="error";node.textContent=message;root.append(node)};
const renderWorkspaces=(items)=>{root.replaceChildren();if(!Array.isArray(items)||items.length===0){const empty=document.createElement("div");empty.className="empty";empty.textContent="No workspaces returned by this host.";root.append(empty);return}for(const item of items){const row=document.createElement("button");row.type="button";row.className="row";const title=document.createElement("div");title.className="row-title";const name=document.createElement("span");name.textContent=text(item.name,"Workspace");const badge=document.createElement("span");badge.className="badge";badge.textContent=item.isActive?"Active":String(Number(item.liveTerminalCount)||0)+" live";title.append(name,badge);const branch=document.createElement("div");branch.className="branch";branch.textContent=text(item.repo,"Repository")+" · "+text(item.branch,"No branch");row.append(title,branch);row.addEventListener("click",()=>send({type:"haptic.selection",id:"haptic-"+(++sequence)}));root.append(row)}};
const setConnection=(state)=>{const value=text(state,"disconnected");connection.textContent=value;dot.classList.toggle("connected",value==="connected")};
const nextFrame=()=>new Promise((resolve)=>requestAnimationFrame(resolve));
const terminalLine=(frame,line)=>"["+String(frame).padStart(3,"0")+"] task-"+String(line%24).padStart(2,"0")+" | building mobile web surface | "+"#".repeat((line+frame)%48);
const runTerminalProbe=async()=>{const lines=[];const frameTimes=[];const started=performance.now();for(let frame=0;frame<120;frame++){const frameStarted=performance.now();for(let line=0;line<8;line++)lines.push(terminalLine(frame,line));if(lines.length>640)lines.splice(0,lines.length-640);terminalProbe.textContent=lines.join("\\n");await nextFrame();frameTimes.push(performance.now()-frameStarted)}return{total:performance.now()-started,max:Math.max(...frameTimes),slow:frameTimes.filter((value)=>value>24).length}};
const runDiffProbe=async()=>{diffProbe.replaceChildren();const fragment=document.createDocumentFragment();const started=performance.now();for(let index=0;index<4000;index++){const line=document.createElement("div");const kind=index%11===0?"deleted":index%7===0?"added":"context";line.className="diff-line "+kind;line.textContent=(kind==="added"?"+":kind==="deleted"?"-":" ")+String(index+1).padStart(5," ")+" const workspace_"+index+" = reconcileHostCapability(manifest, pairedIdentity, cachedDocument);";fragment.append(line)}diffProbe.append(fragment);await nextFrame();return{total:performance.now()-started,height:diffProbe.scrollHeight}};
const runProbe=async()=>{probeButton.disabled=true;probeButton.textContent="Running...";probeResult.hidden=false;probeResult.textContent="Running 120 terminal frames...";terminalProbe.textContent="";diffProbe.replaceChildren();try{const terminal=await runTerminalProbe();probeResult.textContent="Rendering 4,000 diff rows...";const diff=await runDiffProbe();probeResult.textContent="terminal 120 frames / "+terminal.total.toFixed(0)+" ms\\nlongest "+terminal.max.toFixed(1)+" ms / "+terminal.slow+" over 24 ms\\ndiff 4,000 rows / "+diff.total.toFixed(0)+" ms\\nlayout "+diff.height.toLocaleString()+" px\\npackage "+packageKiB+" KiB"}catch{probeResult.textContent="The performance probe did not complete."}finally{probeButton.disabled=false;probeButton.textContent="Run probe"}};
probeButton.addEventListener("click",()=>void runProbe());
const receive=(event)=>{let message;try{message=JSON.parse(event.data)}catch{return}if(!message||message.v!==1)return;if(message.type==="init"){host.textContent=text(message.host?.name,"Paired host");setConnection(message.connection);send({type:"workspace.list",id:"workspace-"+(++sequence)});return}if(message.type==="connection"){setConnection(message.state);return}if(message.type==="response"&&typeof message.id==="string"&&message.id.startsWith("workspace-")){if(message.ok)renderWorkspaces(message.result?.workspaces);else renderError(text(message.error,"Workspace request failed."))}};
window.addEventListener("message",receive);
document.addEventListener("message",receive);
send({type:"ready"})})();
`.trim()
const PROTOTYPE_PACKAGE_PADDING = 'x'.repeat(320 * 1024)
const PACKAGE_KIB_PLACEHOLDER = '000000'
function contentHash(value: string): string {
return createHash('sha256').update(value, 'utf8').digest('base64')
}
export function buildMobileWebPrototypeDocument(): string {
const placeholderDocument = renderDocument(PROTOTYPE_SCRIPT)
const packageKiB = Math.ceil(Buffer.byteLength(placeholderDocument, 'utf8') / 1024)
const script = PROTOTYPE_SCRIPT.replace(
PACKAGE_KIB_PLACEHOLDER,
String(packageKiB).padStart(PACKAGE_KIB_PLACEHOLDER.length, '0')
)
return renderDocument(script)
}
function renderDocument(script: string): string {
const styleHash = contentHash(PROTOTYPE_STYLES)
const scriptHash = contentHash(script)
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'sha256-${styleHash}'; script-src 'sha256-${scriptHash}'; img-src data:; base-uri 'none'; form-action 'none'; frame-src 'none'; connect-src 'none'">
<title>Orca Hybrid Prototype</title>
<style>${PROTOTYPE_STYLES}</style>
</head>
<body>
<main class="shell">
<p class="eyebrow">Desktop-served prototype</p>
<h1 id="host">Paired host</h1>
<p class="subhead">This workspace list is rendered by web code delivered through the paired Orca connection.</p>
<div class="status"><span id="dot" class="dot"></span><span id="connection">Connecting</span></div>
<div id="workspace-list" class="list"><div class="empty">Waiting for the native bridge...</div></div>
<section class="probe">
<div class="probe-head"><div><p class="probe-title">Performance lab</p><p class="probe-copy">Synthetic terminal churn and a large diff, isolated from host data.</p></div><button id="run-probe" class="probe-button" type="button">Run probe</button></div>
<div id="probe-result" class="probe-result" hidden></div>
<div class="probe-stage"><pre id="terminal-probe" class="terminal-probe"></pre><div id="diff-probe" class="diff-probe"></div></div>
</section>
</main>
<script>${script}</script>
<!-- Package-transfer padding for the bounded multi-chunk prototype: ${PROTOTYPE_PACKAGE_PADDING} -->
</body>
</html>`
}
-2
View File
@@ -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',
@@ -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'
+14 -3
View File
@@ -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,
@@ -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 }
@@ -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)
+23 -6
View File
@@ -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<typeof MobileWebBridgeErrorCodeSchema>
export type MobileWebBridgePageMessage = z.infer<typeof MobileWebBridgePageMessageSchema>
export type MobileWebBridgeShellMessage = z.infer<typeof MobileWebBridgeShellMessageSchema>
export type MobileWebNavigationRoute = z.infer<typeof MobileWebNavigationRouteSchema>
export type MobileWebResumeRoute = z.infer<typeof MobileWebResumeRouteSchema>
export type MobileWebBridgeMessageContext = {
@@ -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,
@@ -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