Skip to content

feat(mobile): React Native (Expo) phone app for managing mairu agent - #10

Merged
enekos merged 23 commits into
masterfrom
feat/mairu-mobile
May 2, 2026
Merged

enekos merged 23 commits into
masterfrom
feat/mairu-mobile

Conversation

@enekos

@enekos enekos commented Apr 30, 2026

Copy link
Copy Markdown
Owner

Summary

  • New `mobile/` Expo TypeScript app: phone client that attaches to a desktop `mairu acp-bridge` over Tailscale and speaks ACP to manage any compatible agent (mairu, Claude Code, Gemini).
  • Single-screen UI: header with session picker + connection dot, scrolling timeline (user / assistant / tool-call / thinking renderers), composer with text + push-to-talk mic + active-turn-gated Stop, slide-up permission modal for `session/request_permission`.
  • ACP client lib (`src/acp/`): typed frames, WS transport with exponential-backoff reconnect + `Last-Event-ID` replay + dedup, JSON-RPC request/response correlation, server-initiated request handlers.
  • On-device STT via `@react-native-voice/voice` — no audio leaves the phone. Tailscale identity is the only auth boundary; no app-level password.

Implements the plan at `docs/superpowers/plans/2026-04-30-mairu-mobile.md` and the design at `docs/superpowers/specs/2026-04-28-mairu-mobile-design.md`.

State

  • 14 jest suites / 58 tests green
  • `bun run lint` clean (eslint-config-expo)
  • `bun run typecheck` clean (strict + noUncheckedIndexedAccess)
  • E2E (`bun run e2e`) against a real `mairu acp-bridge` exists; auto-skips when the bridge package isn't in tree, so it'll start passing once feat(acpbridge): tailnet-only ACP-over-WebSocket bridge daemon #9 merges and master has both pieces

Makefile

```
make mobile-install # bun install
make mobile-test # jest unit suite
make mobile-e2e # jest e2e suite (needs acpbridge in tree)
make mobile-lint # eslint
make mobile-typecheck # tsc --noEmit
make mobile-start # expo start (Metro)
```

Known deferrals

  1. Push notifications when backgrounded — explicit non-goal in spec. iOS suspends WS within ~30s. Documented in the Connect screen as a known limitation.
  2. Cloud STT — explicit non-goal. On-device only. Voice quality on code identifiers / jargon is acceptable for short commands ("stop", "approve", "run the tests"); poor for long dictation. Cloud STT is the documented v2 escape hatch.
  3. `session/update` field shape mapping — `sessionGlue.mapUpdate` assumes the `kind` values emitted by `mairu/internal/acp/`. If Claude Code / Gemini emit different shapes, those events fall back to a `system` event showing raw JSON. Acceptable; iterate as harnesses are tested.
  4. Manual phone smoke — no hardware in CI. `bun run start` and Expo Go on a physical device is the next step. Voice (`@react-native-voice/voice`) and haptics (`expo-haptics`) require real hardware to verify.

Depends on

`feat/acp-bridge` (#9). The app is non-functional without the bridge daemon running on the desktop. The branches are independent at CI level (mobile e2e auto-skips), but landing #9 first is the right merge order.

Test plan

  • `make mobile-test` (58 tests pass)
  • `make mobile-typecheck`
  • `make mobile-lint`
  • `make mobile-e2e` — once feat(acpbridge): tailnet-only ACP-over-WebSocket bridge daemon #9 is merged into master and master is rebased into this branch (or merged via "Update branch")
  • Manual: connect to a desktop bridge from Expo Go, exercise prompt → tool-call → permission round-trip on a real device

🤖 Generated with Claude Code

enekos and others added 23 commits April 28, 2026 22:38
Implementation plan for the React Native (Expo) phone app that attaches
to mairu acp-bridge over Tailscale, mirroring the design in the spec
(2026-04-28-mairu-mobile-design.md). 19 TDD tasks covering ACP types,
WS transport with replay, JSON-RPC client, /sessions API, Zustand store,
UI screens (Connect/Session picker/Timeline/Composer/Permission modal),
push-to-talk voice input, app shell, and an e2e against a real bridge.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Generated via bunx create-expo-app blank-typescript. tsconfig set to
strict + noUncheckedIndexedAccess with @/* path alias for src/. Adds
test/lint/typecheck scripts and jest-expo preset to package.json.
Root .gitignore extended for mobile/.expo/, web-build/, and the native
ios/android prebuild dirs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
parseFrame validates jsonrpc 2.0, lifts x-mairu-event-id (the bridge's
sibling stamp) onto a typed eventId field, and tolerates the rest of
the JSON-RPC envelope. Type guards distinguish responses, server
requests, and notifications. encodeFrame emits the wire-form envelope.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wraps the platform WebSocket. Outgoing frames queue while connecting and
flush on open. Incoming frames are parsed via parseFrame, deduped by
x-mairu-event-id (skip <=lastSeen), and fanned out to onFrame listeners.
On unexpected close, schedules an exponential-backoff reconnect (1s base,
30s cap, jittered) and re-attaches with last_event_id= so the bridge
replays the gap. disconnect() stops the loop. State transitions are
observable via onState.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wraps WSTransport with request/response correlation by JSON-RPC id.
Server-initiated requests dispatch to onServerRequest handlers and reply
on the same id. Missing handlers reply with -32601 (method-not-found)
so the bridge isn't left waiting. notify() sends fire-and-forget
notifications. Server-side notifications fan out to onNotification.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Thin fetch wrappers for the bridge's HTTP control plane: GET /sessions
returns the active session list, POST creates one (agent + optional
project), DELETE terminates. Errors surface the HTTP status and body
so the UI can show a useful message instead of an opaque failure.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Single store for host, connection state, selected session, per-session
event log, and pending permission queue. Event log caps each session at
1000 entries (oldest dropped) so an active turn can't grow memory
without bound. Spec-mandated cap; UI can offer a "show earlier" expander
later if the truncation gets in the way.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First-run gate: enter the bridge's tailnet host:port, validate by hitting
GET /sessions, persist via AsyncStorage. Errors surface inline so an
unreachable host is obvious rather than failing silently downstream.
loadStoredHost() rehydrates on cold start (used by App.tsx in Task 14).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lists sessions from GET /sessions, lets the user tap one to attach.
"+ New session" opens a sheet with agent choices (mairu, claude-code,
gemini) — picking one POSTs to /sessions and selects the new id. Each
row shows the agent + active flag so the user can tell live sessions
from idle ones at a glance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
FlatList over the selected session's event log. Each kind has its own
component: right-aligned blue bubble for user; markdown-rendered
assistant text; collapsible tool-call card showing name + JSON args +
result on tap; dimmed thinking block that expands on tap. Defaults to
collapsed so noisy turns stay scannable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
attachSession glues an ACPClient to the store: session/update
notifications become typed TimelineEvents (user_message, agent_text,
tool_call, thinking; unknown kinds fall back to a system event with
the raw JSON), and session/request_permission requests are queued in
pendingPermissions with a reply latch returned via resolveWith. The
field-shape mapper is the only place that knows the bridge's update
schema — change here if it drifts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Multi-line text input, blue Send button, red Stop button gated on the
active prop (Stop fires onCancel for session/cancel). Whitespace-only
sends are no-ops so a stray return key doesn't ship an empty prompt.
Push-to-talk mic button is added in Task 16; this commit is the keyboard
path only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wraps @react-native-voice/voice. start()/stop() are async passthroughs;
results from the native module land in onResult callbacks, errors in
onError. The component layer drives start on press-in and stop on
press-out (Task 16). On-device only — no audio leaves the phone, per
spec non-goal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Slide-up sheet that pops when an agent fires session/request_permission.
Shows the tool name and pretty-printed args, three primary buttons that
each resolve via the parent-supplied onResolve(id, {outcome}). Haptic
buzz on appearance so a permission request grabs attention even with
the app silent. Hardware back / dismiss is treated as deny so a stray
gesture never silently allows a tool call.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Routes between ConnectScreen (no host), SessionPicker (host but no
selection), and the timeline+composer (host + selection). Builds a
fresh WSTransport/ACPClient per (host, sessionId) pair via useMemo and
disconnects on unmount or selection change. Permission modal is
rendered above everything when pendingPermissions[0] exists.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
session/update {kind:turn_started|turn_ended} updates activeTurnsBySession
instead of being mapped to a timeline event (these are control signals,
not user-visible content). App.tsx subscribes to the selected session's
active flag and passes it to Composer so Stop appears only while a turn
is in flight.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Composer gains optional onStartRecord/onStopRecord callbacks driven by
press-and-hold on a 🎤 button (uses RN's onPressIn/onPressOut). Composer
also supports a controlled-draft prop pair so App.tsx can append voice
transcripts to whatever the user is already typing. App wires Recorder
once with useMemo and pumps results into the draft state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Small colored dot in the header — green when WS is open, yellow while
connecting/reconnecting, grey when idle or closed. Reads directly from
store.connection so it picks up state from WSTransport.onState wiring
in the App shell without an extra subscription chain.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Runs as a separate jest project (bun run e2e) so the unit suite stays
fast. Auto-skips when mairu/internal/acpbridge isn't present in the
checkout — feat/mairu-mobile lives without it for now and the suite
will activate once feat/acp-bridge merges into master. The test builds
mairu, spawns acp-bridge --no-tailscale on a free port, drives WSTransport
+ ACPClient through createSession + prompt + notification flow, and
asserts at least one server frame comes back.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds .eslintrc.js extending eslint-config-expo so bun run lint has a
config to pick up. Auto-fix pass switched private array fields from
Array<T> to T[] (preferred-style rule) and fixed an unused import in
App.test.tsx. No behavior change. Suite: 58/58, lint clean, tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
mobile-install / -test / -e2e / -lint / -typecheck / -start. Each
delegates to bun in mobile/. Listed in make help under a new "Mobile
(Expo)" section so the entry points are discoverable next to the
existing browser-extension targets.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@enekos
enekos merged commit b8a8f8d into master May 2, 2026
1 check failed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant