* fix(mobile): phone server rows follow the desktop's other-client and sleeping filters A phone created through a desktop now records whom it acted for, so the desktop's Hide other-client workspaces filter keeps the user's own phone workspaces (locally and on servers) and hides only other people's. The desktop relay marks each server row its sidebar would hide, and the rows its own browser or chat tabs keep awake; the phone applies both. * test(e2e): dedupe sidebar row ids in the visibility spec * fix(mobile): address review: owner naming, old-server relayed phones, scope trim * refactor: move the sidebar's project section grouping into shared The project sections the desktop sidebar draws under Group by Project are built from the project/setup projection, the per-host catalog fold, the endpoint host stamp, and the section labelling and ordering rules. Move the pure parts of each into src/shared (no behaviour change) so the paired phone can call the same code instead of guessing the grouping. * fix(mobile): group by Project with the desktop sidebar's own sections The phone grouped Project sections by repo display name, so it merged checkouts the desktop keeps apart (several local stablyai/orca checkouts, or a server's copy) and split what the desktop merges. The phone now reads each host's repo.list, project.list and projectHostSetup.list (the desktop's own and each server's through the relay), folds them with the renderer's shared catalog fold and projection merge, and builds sections with the desktop's shared grouping, labelling and ordering code. Keys, labels and Recent order equal the sidebar's; a host that predates the project lists gets the repo-derived projection, as the desktop does. Header colour and icon come from the section's anchor repo and rows from their own repo by id, as on the desktop. The repo filter lists every catalog repo by id per host instead of merging same-named repos. Group-by labels read Project and None. project.list and projectHostSetup.list are routed for paired phones (read-only, workspace permission). * refactor(mobile): record a phone's owner as creator and fold desktop tab activity into the host flag A phone now records its owner as the creator (this host for a directly paired phone, the relaying desktop for a delegated one), so the shared rule is unchanged and onBehalfOf is gone. The relay ORs the desktop's saved browser/chat tabs into hasHostSidebarActivity, so the phone's status dot follows too, and local worktree.ps counts the same signals. The old-server fallback is removed. * test(e2e): poll the phone and the sidebar together in the visibility spec * fix(mobile): one Project-section builder for desktop and phone, with the desktop's repo order Review fixes. The sidebar's buildOrderedGroups now calls the shared buildProjectSections for Group by Project (notice and pending repos are optional inputs), so desktop and phone run one function. The phone ranks manual header order by its folded catalog order, which is each host's repo.list order with the desktop's own repos first, as the renderer loads them; only the desktop's cross-host manualRepoOrder (pairing-local) is unknown to it. The projection selector is now a memo over the shared resolver. A transient project-list failure keeps that host's last catalog; only forbidden or method_not_found falls back to the repo-derived projection. Catalog rows are checked on every member grouping reads, and the parity test answers the renderer's real catalog fetch, includes a server's own project rows, and fixes Manual and Recent orders that differ. * fix(mobile): use the repo-derived catalog until a host's project lists first answer * fix(mobile): the desktop serves its sidebar's Project catalog instead of the phone fetching it The phone fetched repo.list, project.list and projectHostSetup.list from the desktop and every relay-ready server each minute and folded them itself, so rows from offline or older servers fell back to repo sections and keys moved on cold start. The desktop renderer now publishes the inputs its own Project headers use (state.repos in sidebar order, the projection selector's projects and setups, projectOrderBy) on the runtime window graph it already sends to main, only when they change. Main keeps the last one and answers it on a new paired-desktop read, mobileRelay.projectSections. The phone reads it with its repo metadata and groups with the shared builder; a desktop that predates the read (forbidden or method_not_found) leaves the phone grouping rows by repo from the desktop's repo.list, and a transient failure keeps the last catalog. The payload carries only grouping and label fields, no hooks, credentials or uploaded icons. The phone's own catalog fetching and its project.list/projectHostSetup.list routes are removed. * fix(mobile): keep uploaded repo icons on the phone, and isolate the snapshot tests * test(e2e): the desktop window serves its sidebar's Project catalog to the phone * chore(i18n): regenerate the runtime English catalog (drift already on main) * fix(mobile): read the Project catalog as its own request, outside the recorded repo-metadata flow The extra read inside the repo-metadata refresh stalled its recorded RPC sequence. It now runs beside it with its own throttle. The repo-metadata goldens are re-recorded for the intended state change: the name-keyed colour, icon and id maps became desktopRepos. * refactor(sidebar): one shared Workspace options filter and sort evaluator The sidebar, the jump palette and the options badge each applied the Workspace options filters with their own copy of the rules. They now call one pure evaluator in src/shared/workspace-view, which reads each row through a small fact reader so the phone can call it next with the facts its host serves. The sort comparator moves there too, with each surface supplying its own Agent activity rank. No behaviour change: a parity test runs the old sidebar chain and the new evaluator over every toggle combination, and the old comparator against the new one for each sort mode. * wip: workspace view descriptors * refactor(sidebar): address review of the shared Workspace options evaluator One predicate builder instead of a per-row scope argument; one exemption-narrowing function taking the hide flag; consumers import the shared predicates directly; Agent activity ties fall to the label in the shared comparator. The activation filter lift reuses the shared predicates. The parity test now covers other-client rows, live agent attention classes and the sort-label map. * fix(mobile): fetch the Project catalog at once when the screen moves to another host The refresh window is now kept per client, so a host screen reused for another desktop doesn't wait out the previous host's 60s window before showing its Project headers. * wip: desktop option items from shared choices * refactor(sidebar): the Workspace options menu renders from one shared option list src/shared/workspace-view/workspace-view-options.ts lists every Workspace options control (Hosts, Projects, Group by, Sort by, Project order, Card layout, Show properties or Card display, and each filter) with its section, its English copy, its choices and when it shows. The desktop menu walks that list and renders each option through a table keyed on the option key, so a new option fails to compile until the desktop has a control for it; the phone sheet will do the same. The choice lists (sort modes, project order, card layout, agent activity layout, card properties) are derived from the shared ids, and a test checks the desktop's English copy matches the shared copy. The unused classic branch of the card property list is removed. No visible change. * wip: row facts * refactor(sidebar): address review of the shared Workspace options list The show rules get their own tests with fixed expectations (the cases the old filter-section test covered, plus the rest), and the menu test checks each option's own control rather than any matching text. Card display rows toggle one property each, with the folder-path copy in shared. The unused Status and Tasks copy left by the removed classic card list is deleted from the catalogs. Filter rows write through the store directly; files are named for what they hold. * feat(runtime): worktree.ps carries the Workspace options row facts Each worktree.ps row now says whether it is the project's default-branch workspace, created by an automation, created by the CLI, or on a detached HEAD, answered by the same shared predicates the desktop sidebar filters with. The phone has none of these facts today, so its copies of the filters either guess (default branch) or are missing. Additive and inert: optional fields that nothing reads yet. An older host omits them; readers must treat absence as 'not that kind', so the row stays visible. Server rows relayed through the desktop pass them through unchanged. * feat(mobile): the desktop's own phone reads and writes its other-client filter ui.get and ui.set strip the pairing-local UI fields for every paired client. That rule exists for clients with their own host catalog (a desktop paired to a server, a web client). A phone has none: it shows its desktop's view, so 'Hide other-client workspaces' must be one shared value. For the host's own phone (clientKind mobile, no relaying parent) ui.get now always answers the field and ui.set persists it; delegated phones, paired desktops and web clients keep the stripping rule. The phone's view-settings schema reads the rest of the Workspace options values it does not read yet (the automation, CLI and detached-HEAD filters, project order, card properties, agent activity layout), each salvaged to absent so an older desktop keeps the phone's local value. * feat(runtime): address review of the worktree.ps row facts Name the fact defaultBranch, and say what absence means for it: a reader keeps its own guess, because 'hide default branch' must not start hiding or showing rows an older host never described. Folder workspace rows get the facts too, so every row from a newer host carries them. The desktopOtherClient comment now says it is a verdict, not a fact. The test covers the rows where the host disagrees with the phone's current guess. * wip: phone uses the shared evaluator * feat(mobile): address review of the phone's other-client filter mirror One isHostOwnPhone predicate decides both who shares the filter value (ui.get/ui.set) and who gets the other-client row marks, so the two cannot drift. The pairing-local field list notes the exception. Tests cover a paired desktop still being stripped, and a phone write that leaves the field out not touching the host value. * feat(mobile): the phone filters and sorts with the desktop's shared Workspace options rules The phone's own filter chain and comparator are replaced by the shared evaluator, reading the facts the host stamps on each row. Phone filter state takes the desktop's setting names and carries every filter; the badge and Clear filters match the desktop's. The desktop's other-client marks become facts the phone filters with the shared value. A parity test runs the same workspaces through the desktop's reading and the phone's (facts stamped as the host stamps them) over every filter combination, folder projects included. * wip: phone view state carries project order and card props * fix(mobile): host scope in shared filters, patch-key writes, live view push Folds the phone's host filter into the shared filters so Clear and the badge agree, writes back only the touched filter keys, and pushes workspaceViewChanged so an open phone list re-reads desktop Workspace options. * fix(mobile): Clear sends only changed filters; skip a view re-read older than a local edit An older host's strict ui.set refuses a key it lacks, so Clear names only what differs. A ui.get that started before a local edit no longer overwrites it; the edit's own change event re-reads. The change publisher's field list now lives with it and derives the filter fields. * feat(mobile): the phone's Workspace options sheet, rendered from the desktop's shared option list The Filter drawer becomes the desktop's Workspace options: hosts, projects with search, group, sort, project order, card layout and properties, card display, and every filter. Each option renders through a table keyed on the shared option keys, so a new desktop option fails to compile until the phone has a control. Sort and group pickers use the desktop's words; Repo reads Project. * fix(mobile): Workspace options sheet review fixes Hosts read as on the desktop (none checked while all show; the last shown host is disabled). Project order falls back to the served value until the shared one is read. Card layout and card style changes now push to an open phone. Pick-one rows read as radio buttons. Simpler wiring: flat card state, the preset flag rides the patch, shared choices used directly. * fix(mobile): reset project order on host switch; simpler preset-flag handling * fix(workspace-options): all hosts and clear reach the desktop; one filter key list drives every surface Host visibility: the legacy host scope fallback moves to shared code; the phone reads it, and the host resets the scope whenever a client writes the host list, so All hosts and Clear stick. An older desktop that keeps the other-client filter to itself has its marks treated as hide. Badge counts, is-anything-filtered, Clear and reveal derive from the shared filter keys on the desktop sidebar and the kanban filter menu. The phone uses the desktop's group and sort values, holds its view in one state value, and reads its UI fields from one shared list. * feat(workspace-options): Status and PR grouping read the shared tables The phone groups by status with the desktop's status helpers and default catalog (its own copy had the defaults in reverse order) and by PR with a shared lane table, so its labels now read In review / In progress as the desktop's do. The desktop's PR lane decision ends in the same shared state-to-lane step. * feat(mobile): phone rows follow Card layout, Show properties, Card display and agent activity One shared helper (getWorktreeCardDetails) decides which card details show for the chosen properties and layout; the desktop card's flags and the phone row both read it. The phone row hides the PR badge, note/issue/Linear glyphs, host badge and agent rows the desktop turns off, and the Full list agent layout shows every agent row. * test(sidebar): folder rows survive restrictive filters; create grace ignores non-finite times * fix(mobile): write the host scope with the host list, and wait for the first read before forcing the other-client filter An older desktop narrows back to its legacy single-host scope when the phone writes only a null host list, so the phone now also writes the scope for a null or one-host list. The other-client fallback waits for the first ui.get so a new desktop's marks don't hide rows before its filter value arrives. Inline the clear-filters key list and note the PR state asymmetry. * test(mobile): re-record view-settings goldens for the unknown other-client state * fix(mobile): let a tall bottom drawer scroll on the page The drag wrapper around the drawer's ScrollView had no flex limit, so it grew to its content and the sheet clipped it instead of scrolling. The full Workspace options sheet is the first drawer tall enough to hit this. * fix(sidebar): reuse the palette's default host instead of reading the focus setting again * fix(sidebar): read the host options once in Workspace options and pass them to the Hosts item * fix: parse the ui.get reply into a named type, fix the row memo test's filters, prune the focus-read baseline * test(mobile): give the project catalog fixture its project order * feat(mobile): name the options chip Workspace options, as the desktop does The chip keeps the active-filter count and reads to screen readers with the desktop trigger's label. The pluralized filter count moves to shared code so both surfaces word it the same way. * fix(mobile): show the Workspace options count as a corner badge so the chip row fits "Workspace options (2)" squeezed the Sort and Group chips to ellipses at phone width. The count moves to a corner badge, as on the desktop trigger, and the chip drops its icon, which the desktop trigger does not show either. * refactor(sidebar): one switch-to-kind table, and fold the leftover helper files back - The four kind switches come from WORKSPACE_KIND_SWITCH in workspace-kinds; the shared evaluator and main's phone-push check both apply it, and the filter type derives its switch fields from it. - git-identity.ts folds back into git-identity-display.ts, as on main. - SidebarFilterState moves into sidebar-filter-actions.ts; visible-worktree-kinds.ts is gone. - buildWorkspaceRowPredicate is module-private. * feat(mobile): Workspace options sheet in the desktop menu's shape The sheet showed every choice of every option, so the filters sat screens down. It now follows the desktop menu: Hosts and Projects rows with their value, Group by as a segmented control, Sort by, Project order, Card layout and Show properties as rows that open a page of choices, and the filters as switches. Each option's control (an inline row, or a value plus a page) still comes from one table keyed by the shared option list. The segmented control is the voice settings one, extracted so both screens share it. * fix(mobile): Workspace options review fixes - A page whose option stops showing is forgotten, so it cannot reopen by itself. - Only the switch takes the tap on a filter row, so the web page writes once. - Sections split their rows in one pass; unused heading styles go. * fix(mobile): neutral filter switches on the page react-native-web paints an on-switch thumb teal unless given activeThumbColor, which React Native's types omit. MobileSwitch applies the app's switch colors, and its web sibling also sets that prop. * refactor(mobile): one MobileSwitch file, adding the web-only thumb color on web * fix(mobile): Show properties reads Hover under Compact, as the desktop does The row's value now comes from getWorkspaceCardPropertiesValue in the shared options, which the desktop trigger uses too: Hover under the compact layout, otherwise how many properties show. Card display shows no value, as on the desktop. * test(mobile): find the renamed Workspace options chip in the page drawer render check * feat(mobile): drop the Sort and Group chips; the Workspace options chip covers both The desktop has only its options button, and the sheet now holds Sort by and Group by. The chips' pickers, their screen state and the sort label go with them. * feat(mobile): option pages slide in from the right and keep the sheet's height The main sheet stays laid out under an open page, so the drawer keeps its height going in and out. The page slides in over 200 ms and back out on back (the row, or Android's back key), instantly under reduced motion. The reduced-motion hook moves to platform/ now that two screens use it. * feat(mobile): Compact rows fold into one title line like the desktop compact card The phone's Compact layout now moves repo identity into a title-row icon chip and drops the identity meta row (repo, host, branch or folder path, child badge), matching the desktop's compact card. The rule lives next to getWorktreeCardDetails as getWorktreeCardLayout, and the desktop presentation reads it too so both surfaces share one decision. * fix(mobile): keep a remote host glyph on compact rows; pass the card layout from the list Review follow-ups: compact rows show an icon-only host glyph for remote projects as the desktop compact card does, the list computes the layout once next to the card details, and a new-card-style row test. * fix(mobile): option pages fill the sheet and open at its top - The page layer grows to the pager's height, so no part of the main sheet shows below a short page. - The drawer takes a scrollResetKey; the sheet passes the open page, so a page opened from a row low on a scrolled sheet starts at the top. - The main sheet's height is measured under a page too, and the pager state lives in the sheet so it resets when the drawer closes. - A misplaced doc comment goes back to its function. * fix(mobile): phone rows show the Jira, automation and CLI glyphs; the options pager forgets a gone page in an effect * fix(mobile): Jira glyph sits before Linear as on the desktop; Jira reader tests beside it * feat(mobile): long-press a workspace to see its details The phone's long-press sheet now lists the workspace's linked PR and issues (tap to open), who created it, its notes and live ports (Copy), the phone's take on the desktop sidebar card's hover card. Folder workspaces get the sheet too, with details only. The desktop lets phones run workspacePorts.scanHost (read only). * fix(mobile): details read links the desktop's way and stay one-off Links come through the shared getWorkspaceAttachments, so references the desktop keeps apart stay apart, in the host's order with reviews first. The branch review read no longer takes the host's active-branch slot, each read re-runs only for its own inputs, a reopened sheet reads afresh, and Copy's confirmation never outlives the sheet. * fix(mobile): details clear on close without an effect; lint findings * fix(mobile): the phone leaves Ports out of Show properties and Card display * refactor(mobile): clearer names and tests for the phone's hidden card properties * feat(mobile): details sheet heading carries the origin; no ports As Jinwoo approved: the sheet heads with the workspace (primary text), its short branch, and a quiet origin line (automation name, or Orca CLI). The group below holds only linked reviews and tasks, then notes. Pin uses the pin icon. Ports are left out of the phone, so the desktop change that let phones scan them is reverted. * test(mobile): details section render helper matches its props * fix(mobile): details review read asks by branch; review merge by number; comments * fix(mobile): details rows share the action rows' text column * refactor(mobile): details sheet reads only the row The sheet no longer fetches. The host stamps two optional row fields: automationName beside automationCreated, and the branch PR's title, url and checks on linkedPR (from the GitHub cache it already reads). Older hosts omit them and the sheet shows what they send. Links match by provider, type and number; the reference label is one shared helper the desktop card uses too. Folder rows keep no long-press. One sheet file plus the pure model. * fix: review nits on the row-only details sheet * refactor(mobile): one view state and one settings read for the phone's Workspace options; tighter card glyph wiring - The phone's Card layout and card style now live in its view state; one parallel settings.get reads them with pinned placement beside ui.get. - The Card layout preset flag is a write option, no longer a view field or a change-event field. - getWorktreeCardDetails reads the raw desktop settings and resolves the layout once; the desktop card computes it once in its foundation. - The row computes its glyph presence in one place; Ports is hidden through one set. - Drops the vanished-page effect, the native-only a11y props and the web-only switch branch. * fix(mobile): the list settings read applies each half on its own, with tests; pinned placement changes announce themselves * fix(mobile): compact host glyph tests for older rows and the new card style; truer comments * fix(mobile): Card layout writes the setting before its preset; Card display names folder paths when the desktop has folder workspaces * fix(mobile): match the branch PR by repo and link, not number alone
Orca Mobile
React Native companion app for Orca. Monitor worktrees, view terminal output, and send commands from your phone.
Local development uses two processes:
- Orca desktop/Electron from the repo root. This hosts the mobile WebSocket RPC server on port
6768. - Expo Metro from
mobile/. This serves the React Native app on port8081.
Unless a command says otherwise, run mobile app commands from the mobile/ directory.
The Node commands pnpm rpc:diff and pnpm rpc:record use a development dependency linked to ../src/packages/process-host. Mobile's install supplies the link, and tsx runs its source with the orca-source condition, so these command wrappers do not require a desktop install or package build. Keep that source directory in sparse checkouts. The recording suites still need root dependencies when they exercise desktop modules. The React Native app does not import this host package.
Prerequisites
- Node.js 24+
- pnpm
- Xcode and/or Android Studio tooling for simulator or device builds
- Expo Go on your phone, or a development client build when native modules are needed
- Phone and desktop on the same LAN when testing a physical phone
Start Desktop Orca
From the repository root:
pnpm install
pnpm dev
Confirm the mobile RPC server is listening:
lsof -nP -iTCP:6768 -sTCP:LISTEN
Restart pnpm dev after changing Electron main-process code. Metro hot reload only applies to the mobile JavaScript bundle.
Start The Mobile App
cd mobile
pnpm install
pnpm start
Scan the Expo QR code with your phone's camera on iOS, or Expo Go on Android.
For a native dev-client build:
pnpm exec expo run:android
pnpm exec expo run:ios
pnpm start --dev-client
Pair With Desktop Orca
- Open Orca desktop.
- Go to Settings > Mobile.
- Scan the pairing QR code from the mobile app.
- Confirm the mobile host endpoint is
ws://<desktop-ip>:6768.
For the Android emulator, use ws://10.0.2.2:6768. For a physical phone, use the desktop LAN IP, for example ws://192.168.0.179:6768.
If the phone has a stale host entry, remove it from the app and pair again.
Development Paths
Android Phone
- Install Expo Go from Google Play
- Run
pnpm start, scan QR with Expo Go - For native modules:
pnpm exec expo run:android - Run with
pnpm start --dev-client
iOS Simulator
- Install Xcode from the App Store
- Run
pnpm start --iosto open in iOS Simulator
Physical Phone Debugging
The phone can be inspected through the connected device tooling:
orca snapshot --json
orca click --element @e3 --json
orca fill --element @e1 --value "ls" --json
orca screenshot --json
Use snapshot first to find the current element refs, then click/fill those refs. After mobile file edits, Metro usually hot reloads automatically, but navigating out of and back into the session screen can be useful because it re-runs terminal.subscribe.
Terminal Streaming Repro Without A Phone
Use this when terminal output does not render on device and you need to split server streaming bugs from WebView/UI bugs:
cd mobile
ORCA_MOBILE_WS_URL=ws://127.0.0.1:6768 pnpm exec tsx scripts/test-subscribe.ts <deviceToken> <serverPublicKeyB64>
You can pass a worktree selector as the third argument:
pnpm exec tsx scripts/test-subscribe.ts <deviceToken> <serverPublicKeyB64> "id:<worktreeId>"
pnpm exec tsx scripts/test-subscribe.ts <deviceToken> <serverPublicKeyB64> "path:/absolute/worktree/path"
pnpm exec tsx scripts/test-subscribe.ts <deviceToken> <serverPublicKeyB64> "name:my-worktree"
The expected result includes:
streamSawMarker: true
readSawMarker: true
If this repro fails, debug the desktop runtime/PTY path before the mobile WebView. If it passes but the phone is blank, debug the session screen or TerminalWebView readiness/queueing path.
Terminal Color Repro Without A Phone
Use this when terminal colors disappear after switching tabs. Open a Claude Code terminal and at least one other terminal in the target worktree, then run:
cd mobile
ORCA_MOBILE_WS_URL=ws://127.0.0.1:6768 pnpm exec tsx scripts/repro-terminal-colors.ts \
<deviceToken> <serverPublicKeyB64> "id:<worktreeId>"
The script captures terminal.subscribe snapshots in an A → B → A sequence and writes raw snapshots to mobile/terminal-color-repro/. If the two A snapshots have different sgrColor counts, the desktop snapshot changed during the switch. If they match, the ANSI color data is still present and the bug is in mobile replay/rendering.
Validation
Run these checks before committing mobile terminal changes:
cd mobile
pnpm exec tsc --noEmit
pnpm run check:tests-typecheck
pnpm lint
cd ..
pnpm typecheck:node
tsc --noEmit reads tsconfig.json, which excludes test files so Metro never bundles them.
tsconfig.test.json puts them back, and pnpm run typecheck:tests shows their errors in full.
check:tests-typecheck is the gate over it: a ratchet against tests-typecheck-baseline.txt, the
127 test files that do not typecheck yet. It fails when a file that checks today stops checking,
and when a baseline entry starts checking (prune it with
node scripts/check-tests-typecheck-ratchet.mjs --prune). The list may only shrink.
The same gate censuses the program first: every *.test.ts(x) on disk must be in it, or named in
the script's TESTS_OUTSIDE_PROGRAM with a reason. Without that, a test excluded from
tsconfig.test.json — or a Foo.test.tsx shadowed by a Foo.test.ts beside it, which a wildcard
include drops for the higher-priority extension — would leave the ratchet silently.
Protocol Version Compatibility
Mobile and desktop talk over a versioned protocol. Because mobile updates lag desktop by 24-48h via the App Store, both sides exchange version numbers on status.get so a genuinely incompatible combo can hard-block instead of silently misbehaving.
Constants live in two files (Metro can't resolve outside mobile/):
src/shared/protocol-version.ts—DESKTOP_PROTOCOL_VERSION,MIN_COMPATIBLE_MOBILE_VERSIONmobile/src/transport/protocol-version.ts—MOBILE_PROTOCOL_VERSION,MIN_COMPATIBLE_DESKTOP_VERSION
Today all four are set so evaluateCompat always returns { kind: 'ok' } — nothing blocks. The wire format is in place to flip a switch when needed.
When to bump
Bump DESKTOP_PROTOCOL_VERSION (and the mobile mirror MOBILE_PROTOCOL_VERSION when relevant) for breaking changes:
- Removed RPC method or required parameter that mobile uses
- Changed meaning (units, nullability) of an existing field mobile reads
- Changed encryption, framing, or auth handshake
Do not bump for additive changes:
- New RPC methods
- New optional fields on existing methods
- New event types in
terminal.subscribe
Set MIN_COMPATIBLE_MOBILE_VERSION (kill-switch) when desktop ships a change that requires a minimum mobile version to function safely. Same for MIN_COMPATIBLE_DESKTOP_VERSION from the mobile side.
When a verdict is blocked, mobile/src/components/ProtocolBlockScreen.tsx renders a screen pointing the user at the update that clears it. When mobile is too old it opens the newest release if the installed app's update check knows one, otherwise the App Store (iOS) or GitHub Releases (Android). When desktop is too old it opens GitHub Releases.
To exercise the block screen locally: set MIN_COMPATIBLE_DESKTOP_VERSION = 999 in mobile/src/transport/protocol-version.ts, rebuild, pair to any desktop. Revert before merging.
Mock Server
Develop the mobile app without a running Orca desktop instance:
pnpm mock-server # starts mock WebSocket server on port 6768
Connect from the app using endpoint ws://localhost:6768 and token mock-device-token.
Environment variables
MOCK_NATIVE_CHAT=1— serve the native-chat scenario (one live agent tab, empty transcript, image upload) instead of the default terminal fixtures.MOCK_CHAT_AGENT=omp— withMOCK_NATIVE_CHAT=1, present an OMP tab and four decoded transcript messages, including a tool call and result, instead of the default Claude scenario. It deliberately omitstranscriptPathto exercise legacy-hook readability discovery; current OMP hooks may report a path.MOCK_SERVER_KEY_FILE— persist the server keypair across restarts so a paired device keeps its public-key pin. A missing or invalid file is re-keyed with a warning, which forces a re-pair.
Scenario control files
Read on every request, so behaviour can be flipped mid-session without a restart (a restart would re-key E2EE and force a re-pair). Write the mode into the file, or delete it for the default.
MOCK_SEND_MODE_FILE(defaultorca-mock-send-modein the system temporary directory) —accept(default) accepts the send,errorfails it withmobile_input_floor_unavailable, anything else reports the send as rejected.MOCK_TERMINAL_LIST_MODE_FILE(defaultorca-mock-terminal-list-modein the system temporary directory) —omitreturns an empty terminal list,otherreturns a list that omits the chat handle, anything else lists it.MOCK_TERMINAL_STREAM_MODE_FILE(defaultorca-mock-terminal-stream-modein the system temporary directory) —deadanswers a subscribe withsubscribedthenend(a gone PTY), which is what exercises the rearm bound and terminal prune; anything else streams normally.
Connecting to Real Orca
- Start Orca desktop with WebSocket transport enabled
- In Orca, go to Settings > Mobile and scan the QR code with this app
- The QR encodes the connection endpoint, device token, and TLS fingerprint
Project Structure
mobile/
├── app/ # Expo Router screens (file-based routing)
│ ├── _layout.tsx # Root layout with navigation stack
│ ├── index.tsx # Home screen — paired hosts list
│ └── pair-scan.tsx # QR code scanning screen
├── src/
│ ├── terminal/ # Terminal WebView and xterm bridge
│ └── transport/ # WebSocket RPC client
├── scripts/
│ ├── test-subscribe.ts # Desktop streaming repro without a phone
│ └── mock-server.ts # Standalone mock WebSocket server
└── assets/ # App icons and splash screen