Files
orca/mobile
Brennan Benson b41e813cb5 fix(native-chat): surface draft launch context in desktop and mobile chat composers (#9802)
* fix(native-chat): surface draft launch context in chat composers

Creating a workspace from a GitHub issue delivers the issue link only into
the agent TUI's input buffer (argv prefill or startup paste), so the chat
view showed no trace of it on desktop or mobile.

Desktop: draft launches now seed an in-memory launch draft keyed by tab id
(direct work-item launches, background GitHub work-item creates, quick-create
composer, and new-tab draft deliveries). The chat composer adopts the seed
once as its editable draft, declines permanently if the composer already has
text, and drops an untouched copy when any user turn lands (the one-line TUI
input means the prefill was submitted or deliberately cleared) or on its own
send, whose existing input pre-clear retires the TUI copy.

Mobile: the host publishes the draft as an optional launchDraft field on the
mobile terminal tab snapshot (additive, no protocol bump) and the mobile
composer adopts it with the same once-only/decline/resolve semantics. Mobile
chat sends now also pre-clear the TUI input line (Ctrl+U, desktop parity) so
a pending prefill cannot concatenate with the sent message.

Completion seeding resolves the launch tab from the synced store tabs when
the backend spawned the terminal and activation reports no primaryTabId.

Split the Windows shell-quoting tests into their own file to stay within the
max-lines budget.

* revert(mobile): drop incidental pnpm-lock churn from the launch-draft branch

The libc binding fields and the @typescript-eslint peer re-resolution came from
a local install, not from this change; mobile/package.json is untouched.

* fix(native-chat): resolve launch drafts without trusting cross-host clocks

The rule required a user turn stamped at or after the seed. Grok omits row
timestamps, so a Grok launch draft never resolved; and the seed time is a
renderer clock while the stamp comes from the executing host's JSONL, so a
remote workspace whose clock trailed never resolved either. Both left the
composer adopting an already-submitted prefill, which re-sends it as a
duplicate turn.

Resolve on any user turn that is not PROVABLY older than the seed (a launch
draft's session starts with zero user turns), with the existing cross-host
skew slack, plus a timestamp-free backstop for wider skew: a new tail user
turn since the draft was first observed. "Load earlier" prepends, so it
cannot move the tail and cannot over-resolve.

Split out of native-chat-pending.ts to stay under the max-lines ratchet.

* fix(worktrees): seed the launch draft on the agent's own tab, never on tabs[0]

Two defects in the completion seed:

- The tab was resolved by array position. buildStartupOpt returns undefined on
  the backend-spawn path, so applyDefaultTerminalTabs stamps launchAgent on no
  tab and the launchAgent guard was dead there. A repo with default terminal
  tabs ("dev server", "logs", ...) got the draft on a tab that runs no agent,
  and then published it to mobile as THAT tab's launchDraft. Correlate on the
  backend startup tab, then on a launchAgent-stamped tab, then on primaryTabId
  (which is the agent tab whenever the renderer owns startup); never tabs[0].

- Runtime-owned worktrees mirror their session tabs async, so tabsByWorktree
  was empty at seed time and the seed was silently dropped for that whole host
  class. Defer to the first mirrored tab via the existing delayed-delivery
  queue, which now holds every pending delivery for a worktree instead of one
  (setup/issue commands and the seed both wait on the same first tab).

* fix(store): evict nativeChatLaunchDraftByTabId on every teardown path

The new map was absent from all four paths its sibling
nativeChatLaunchPromptByTabId participates in: tab close, the orphan terminal
sweep, the bulk worktree purge, and the removeWorktree teardown. A stranded
entry is worse than a plain leak here because sync-runtime-graph keeps
publishing it to mobile as that tab's launchDraft.

* fix(native-chat): only seed single-line unsubmitted launch drafts

The unsubmitted-delivery branch seeded on every draft delivery, which also
caught the agent-session-fork path whose prompt is multi-line scraped context.
The chat send pre-clears the TUI with Ctrl+U (kill-to-start-of-LINE), so a
multi-line prefill cannot be fully cleared and its earlier lines would glue
onto the next message. The GitHub work-item draft this feature targets is a
bare issue URL, so narrowing costs it nothing.

Also assert the composer retires the seed after a send — deleting that call
previously failed no test.

* fix(mobile): stop the chat pre-clear from wiping a just-pasted image

The text write set clearInputFirst unconditionally. On the image path that
Ctrl+U lands AFTER pasteMobileNativeChatImagePaths already pasted the image,
so the agent receives the text alone while acceptSend still renders the
thumbnail on the sent bubble — silent image loss.

Desktop's image path clears exactly once, before the paste, and never again;
mobile now matches: pre-clear only when nothing was deliberately pasted first.
The image paste already leads with its own Ctrl+U, so a launch-draft prefill
parked on the input line still cannot glue onto the message.

Pinned at both levels: the controller test drives the real send hook and
asserts clearInputFirst per branch, and the send module asserts the wire text
carries no leading \x15. The image-attachments test injects its own baseSend,
so it structurally could not observe this.

* fix(mobile): hold the launch-draft prefill until the transcript settles

session.tabs delivers launchDraft before the transcript read resolves, so the
seed effect could run against an empty in-flight message list and miss the
user-turn decline. Launching from an issue, submitting the prefill in the TUI,
and never opening desktop chat (nothing else clears the host seed) then
prefilled the mobile composer with the already-sent issue link — a send tapped
before it retracted duplicated it to the agent.

Thread the session's loading state through and skip the seed while the read is
in flight. idle/waiting-session still seed: no session means no user turns.

* fix(runtime): publish a launch draft to mobile only for the tab's own agent

The publish had no agent check while the desktop consumer declines on
mismatch. The seed is keyed by tab id, which survives a pane's agent switch, so
mobile could adopt a draft desktop refuses — seed for claude, never open
desktop chat, switch the pane to Codex, and mobile prefills the Codex chat with
the Claude-era issue link. Align publish with the consumer.

* fix(native-chat): take the launch-draft baseline only after the transcript loads

The timestamp-free backstop snapshotted the transcript's user turns on first
observation of the draft, which can happen while the read is still in flight and
`messages` is []. A pane bound to a session that already had user turns then
backfilled above that zero baseline with a different tail id, so clause 2
resolved and silently dropped the seed — the launch context never appeared, and
the feature no-oped for exactly the panes it was meant to serve. Clause 1 was
already correct there (that history is provably older than the seed).

Gate baseline capture and resolution on the transcript read settling, the same
shape mobile's drafts hook uses. Clause 1 is unchanged; while loading the merged
list is empty anyway, and a pane with live appends is never reported 'loading'.

Also restore clause 1's short-circuit: it scans with .some() again and only
allocates the user-turn list when falling through to the backstop.

NativeChatView sat at exactly the 400-line cap, so the composer's two
launch-draft props are now spread from the hook result they already mirror.

* fix(native-chat): reject multi-line launch drafts inside the seed helper

The single-line guard lived in deliverLaunchPromptToAgentTab, so the two
other seeding entry points (worktree create, direct work-item launch)
bypassed it — and every Linear launch is multi-line by construction
("Linked Linear issue: STA-…" + url). The chat send pre-clears the TUI
with Ctrl+U, which kills to start of LINE, so those earlier lines stay
parked to glue onto the next message.

* fix(worktrees): keep the deferred agent seed off ambiguous mirrored tabs

The runtime-owned deferred path fell back to tabs[0], which the module's
own docstring forbids: with repo default tabs ("dev server", "logs") the
seed lands on a tab running no agent, where mobile withholds it and
desktop's agent check ignores it — the feature is silently dead for that
create and the entry leaks until tab close.

The queue entry is consumed before delivery, so there is no retry to fall
back on; accept the first mirrored tab only when it is the worktree's
only one and so unambiguously the agent's.

* fix(mobile): treat a launch-draft-only session-tab frame as a change

mobileSessionTabEqual's terminal branch never compared launchDraft, and
the route keeps `prev` when tabs compare equal — so a publish whose only
delta is the draft appearing or retracting was discarded and never
reached the composer. Live QA passed only because agentStatus happened to
change in the same frame.

MobileSessionTab's terminal variant did not declare the field either
(the controller read it through the structurally wider
MobileNativeChatTab), which is why TypeScript never flagged it.

* fix(mobile): judge a launch prefill only from its own settled transcript

Two ways the drafts hook was reading a transcript that was not the active
chat's:

- transcriptLoading came from `status`, a plain useState written by a
  passive effect declared before the drafts hook. On the commit where the
  tab identity changes it still holds the previous tab's value, so the
  guard was off on exactly the render that seeds: first entry saw
  status 'idle' with an empty list and seeded an already-submitted link,
  and a tab switch declined the new tab's prefill from the old tab's
  turns. The session hook now tracks the identity its messages describe
  and reports transcriptLoading until they agree; the retire effect gates
  on it too.
- Leaving chat view nulled launchDraft while draftKey stayed the same,
  which the hook could not tell from a host retraction — it declined the
  prefill permanently, so peeking at the terminal dropped the context.
  The controller now passes the raw field plus an explicit chatActive
  flag, and both effects hold their state when the tab is not on chat.

The controller wiring was previously unasserted: replacing both props
with constants left all 795 mobile session tests green.

* fix(native-chat): keep the launch-draft baseline across a transcript reload

baselineKey went null whenever the transcript was loading, and the null
branch DISCARDED an already-valid baseline taken from a settled read. It
was then re-taken from the fuller list, swallowing the very user turn
that resolves the draft — so a stale prefill gets re-adopted as a
duplicate turn. Key the baseline on draft identity alone and gate only
the capture.

session.status is also not a truthful read-in-flight signal: a live
'working' hook outranks 'loading', so the guard could be off over an
in-flight empty list. Expose the read phase itself and gate on that.

* test: cover the launch-draft reducers and the sync-key skip gate

Every consumer test injects the three launch-draft reducers as bare
vi.fn()s, so reducing markNativeChatLaunchDraftAdopted to a no-op left
2609 tests green — while in the app the composer would resurrect the
prefill after every manual clear.

canSkipRuntimeMobileSessionSyncKeyBuild had no launch-draft case either:
when it skips, the sync key is never even built, so the existing
getRuntimeMobileSessionSyncKey case cannot catch its removal.

* fix(native-chat): hold the launch-draft baseline in state, not a render-mutated ref

react-compiler rejects reading or writing a ref during render. Adjust the held
baseline with the sanctioned render-time setState instead, keeping the local
copy so the render that first sees a settled transcript resolves against it.

* fix(mobile): carry the transcript identity in the session read state

react-doctor flags the separate loadedIdentity state as an extra render for a
derivable value. Hold status alongside the identity it describes in one state
written by the subscription effect, so transcriptLoading derives from it.

* test(native-chat): assert the readPhase contract without the hook-status race

The test asserted status === 'working', which depends on liveStatusOverride
winning over ambient transcript state — green locally, red under CI load. The
contract is that readPhase stays 'loading' once live content unmasks status,
so assert exactly that; it still fails if readPhase derives from status.

* fix(mobile): derive pre-read chat status instead of writing it from the effect

react-doctor's no-derived-state-effect flags idle/waiting-session/loading being
set in the subscription effect: all three are pure functions of the props. Derive
them during render and keep state only for the genuinely async outcome, tagged
with the identity it describes.

The tag now gates `messages` too, so a just-switched tab never sees the previous
tab's transcript at all rather than seeing it behind a loading flag.

* fix(mobile): drop a settled chat read once its subscription is torn down

The settled outcome was only ever replaced by a newly arriving frame, so any
effect re-run that landed back on an already-settled identity resurfaced it over
a list the same effect had just cleared: 'ready' with no messages and
transcriptLoading false. Toggling out of chat view and back hit this every time
(the agent goes null, then returns), flashing the "start a chat" empty state over
a real conversation and opening the launch-draft seed's decline check on an empty
transcript. A reconnect did the same via the client dep.

Identity and client are the effect's only inputs, so tagging the read with both
and dropping it during render when either moves covers every re-run.
2026-07-28 13:15:31 -07:00
..

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 port 8081.

Unless a command says otherwise, run mobile app commands from the mobile/ directory.

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

  1. Open Orca desktop.
  2. Go to Settings > Mobile.
  3. Scan the pairing QR code from the mobile app.
  4. 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

  1. Install Expo Go from Google Play
  2. Run pnpm start, scan QR with Expo Go
  3. For native modules: pnpm exec expo run:android
  4. Run with pnpm start --dev-client

iOS Simulator

  1. Install Xcode from the App Store
  2. Run pnpm start --ios to 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 lint
cd ..
pnpm typecheck:node

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.tsDESKTOP_PROTOCOL_VERSION, MIN_COMPATIBLE_MOBILE_VERSION
  • mobile/src/transport/protocol-version.tsMOBILE_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 either the App Store (mobile too old) or GitHub Releases (desktop too old).

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_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 (default orca-mock-send-mode in the system temporary directory) — accept (default) accepts the send, error fails it with mobile_input_floor_unavailable, anything else reports the send as rejected.
  • MOCK_TERMINAL_LIST_MODE_FILE (default orca-mock-terminal-list-mode in the system temporary directory) — omit returns an empty terminal list, other returns a list that omits the chat handle, anything else lists it.
  • MOCK_TERMINAL_STREAM_MODE_FILE (default orca-mock-terminal-stream-mode in the system temporary directory) — dead answers a subscribe with subscribed then end (a gone PTY), which is what exercises the rearm bound and terminal prune; anything else streams normally.

Connecting to Real Orca

  1. Start Orca desktop with WebSocket transport enabled
  2. In Orca, go to Settings > Mobile and scan the QR code with this app
  3. 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