Repository navigation
feat(mobile): React Native (Expo) phone app for managing mairu agent - #10
Merged
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
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
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
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
🤖 Generated with Claude Code