docs/mobile-chat/02-high-level-design.md
Status: active · Task: mobile-chat · Approach: C — Hybrid Seams
Brings the Onyx chat experience to the native mobile app: a user opens the app, picks an agent (or uses the default), and has a streaming conversation grounded in the company's knowledge — optionally inside a project, optionally with documents/photos attached to a message. It mirrors the web product's behavior and talks to the same backend, unchanged; only the client is new.
The mobile app already boots through AuthGate into an authenticated shell with a working sidebar and an apiFetch HTTP layer that injects the user's bearer token and resolves the server URL. We add an authed chat route group beside the existing (auth) group.
When the user sends a message, a mobile orchestration hook does three things: (1) if there's no chat session yet, it creates one on the backend (POST /api/chat/create-chat-session) carrying the selected agent's persona_id and the active project_id; (2) it optimistically drops a user bubble and an empty assistant bubble into an in-memory message tree; (3) it opens the streaming request.
The stream is the heart of it. We POST the message to /api/chat/send-chat-message using expo/fetch (the only HTTP call in the app that doesn't go through apiFetch, because it needs a readable byte stream). The backend replies with newline-delimited JSON — one packet per line. A mobile-native pure parser (mirroring the web app's line-buffering logic) turns the byte chunks into typed packet objects. The mobile hook reads those packets, ignores heartbeats, and — for the core experience — only cares about the message text packets (MESSAGE_START/DELTA/END) plus STOP/ERROR. It appends streamed text onto the assistant bubble, flushing updates to the UI in ~50ms batches so the screen doesn't thrash.
The chat screen renders the message tree with FlashList v2 (non-inverted, auto-pinned to the bottom while streaming). The streaming assistant bubble renders its accumulating text through a React Native markdown component. When the STOP packet arrives, the conversation returns to idle and the assistant bubble gets its real server message-id.
Reopening a past chat fetches its history from the backend and rebuilds the message tree with a mobile-native processRawChatHistory (mirroring web's logic). Listing chats, agents, and projects all go through TanStack Query (already wired and persisted to MMKV), keyed by server URL so switching backends never serves stale data.
Agents, projects, and attachments layer on top of this core without changing it: agent selection just sets the persona_id used at session-create time; projects set the project_id; attachments upload files (via expo-file-system's streaming upload task) and attach their file_descriptors[] to the send request, gating the send button until the files finish indexing.
┌─────────────────────────────────────────────┐
│ mobile/src/app/(app)/ (expo-router group) │
│ new-chat · chat/[id] · history · projects │
└───────────────┬───────────────────────────────┘
│ renders
┌───────────────▼───────────────┐ ┌──────────────────────┐
│ RN UI (mobile-only) │ │ TanStack Query │
│ MessageList (FlashList v2) │◄────┤ sessions · agents · │
│ StreamingMarkdown · InputBar │ │ projects · files │
└───────────────┬───────────────┘ │ (apiFetch + MMKV) │
subscribes│ └──────────┬─────────────┘
┌───────────────▼───────────────┐ │ apiFetch (JSON)
│ chatSessionStore (zustand) │ ▼
│ per-session messageTree, │ ┌─────────────┐
│ chatState, AbortController │ │ Onyx │
└───────────────┬───────────────┘ │ backend │
drives ▲ │ updates │ (unchanged) │
┌──────────┴─────▼───────────────┐ └──────▲──────┘
│ useChatController (mobile) │ │ expo/fetch
│ onSubmit · drain stream · flush│─────────────────┘ (NDJSON stream)
└───────────────┬─────────────────┘
│ calls
┌─────────────────────▼──────────────────────┐
│ mobile/src/chat/ (pure TS, mobile-native) │
│ ndjson parser · contracts (chat/streaming/ │
│ files/agents/proj) · messageTree · │
│ processRawChatHistory · fileDescriptors │
└─────────────────────────────────────────────┘
(web keeps its own copies — nothing shared)
(app) route group — authed chat screens (new-chat, chat/[id], history, projects). (new, mobile)useChatController / useChatSessionController — mobile orchestration: submit, drive the stream, batch flushes, stop, load/resume history. (new, mobile)chatSessionStore (zustand) — ephemeral per-session state: message tree, chat state, abort controller. Not persisted. (new, mobile)MessageList (FlashList v2), StreamingMarkdown, InputBar (keyboard-sticky), agent picker, project screens, attachment chips. (new, mobile)mobile/src/chat/) — NDJSON parser + packet/chat/file types + message-tree math + history rebuild + file-descriptor helper; ported from web, nothing shared (web keeps its own copies). (new, mobile)AuthGate lands them in the (app) chat home; sidebar shows their recent chats (TanStack Query over the sessions list).useChatController creates a session (persona_id = chosen agent) → gets chat_session_id, navigates to chat/[id].chatState='loading').expo/fetch; the backend streams NDJSON packets.MESSAGE_DELTA text to the assistant bubble, flushing every ~50ms (chatState='streaming').STOP arrives → chatState='input', assistant bubble gets its server message-id, sessions list refetches so history reflects the new turn.AuthGate / sessionManager).persona_id, project_id) → chat_session_id.expo/fetch POST stream with bearer + JSON body.MESSAGE_* packets into the assistant node; batch-flush to zustand (~50ms).STOP/ERROR/abort: settle chat state, release the reader, capture message-id, refetch sessions.processRawChatHistory → tree; if a run is live, tail resume-stream.expo/fetch for streaming, apiFetch for everything else — RN's legacy fetch has no readable body; expo/fetch (SDK 56 default) exposes response.body.getReader(), letting us reuse the web's exact NDJSON parsing. JSON list calls stay on the existing apiFetch choke-point (bearer + error normalization).mobile/src/chat/, with web keeping its own copies. The shared-package machinery (a @onyx-ai/shared util + a web re-point + jest/dist coupling) is more moving parts than the ~200 lines of duplication it removes; pre-production the backend protocol is stable, so drift is low and cheap to re-extract later if it bites. Web stays untouched. (Approach C dropped to "no shared chat code" per the PR 2 Decision (2026-06-26).)AbortController and must not be persisted, so it lives in a separate, ephemeral zustand store.maintainVisibleContentPosition — the modern v2 pattern for chat; pins to the bottom while streaming without yanking a user who scrolled up.