docs/mobile-chat/05-pr-roadmap.md
Status: active · Task: mobile-chat · Source plan: 04-implementation-plan.md
How to use this. Each PR below is an independently-mergeable slice (~500-700 LOC incl. tests) that leaves
mainbuilding and the app usable. Before implementing any PR, open a fresh session and run the "Before you start (grill on)" checklist for that PR with the owner — confirm the key decisions and re-read the relevant web files — THEN code. This roadmap is intentionally high-level; the deep detail for each slice is produced in that per-PR session, not here. Product is pre-production, so no feature flags are required; the chat entry point is added in PR 1 butsendstays disabled until PR 3.
⚠️ DECISION OVERRIDE (2026-06-29) — chat logic is NOT shared. The owner decided not to extract any chat-domain code into
@onyx-ai/shared, to keep that design-system/Opal package free of ~500–1000 lines of chat business logic. This reverses Approach C's shared-seam plan for chat only. Consequences, applied everywhere below: (1) Mobile owns its own copies of the NDJSON parser, message tree, packet/chat/file contracts, andprocessRawChatHistoryundermobile/src/chat/. Wherever this roadmap or03-detailed-design.mdsays@onyx-ai/shared/contracts/*or@onyx-ai/shared/utils/*for chat code, read it asmobile/src/chat/*. (2) Web is left completely untouched — no import re-points, no shims, no web parity step. The accepted tradeoff: the parser/tree logic now lives in both web and mobile and can drift. (3)@onyx-ai/sharedcontinues to receive only cross-platform design primitives (tokens, typography, interactive contracts) per the unchanged design-token policy.
| PR | Title | Est. LOC | Depends on | Key deliverable |
|---|---|---|---|---|
| 0 | chore(mobile): chat streaming + markdown spikes | ~150 (throwaway) | — | Prove expo/fetch streaming on device + react-native-streamdown on RN 0.85; pick fallbacks. Hard gate for PR 3. |
| 1 | feat(mobile): authed chat shell + sessions history | ~550 | PR 0 | (app) route group, history list (real data), chat screen scaffold; no streaming. |
| 2 | feat(mobile): native chat data layer ✅ | ~550 | PR 1 | Mobile-native NDJSON parser + core packet/chat/file contracts + message tree + processRawChatHistory in mobile/src/chat/; jest unit-tested. Web untouched; nothing shared. |
| 3 | feat(mobile): core chat — send, stream, markdown | ~700 | PR 2 | Headline slice — working streaming chat vs default agent. |
| 4 | feat(mobile): resume in-flight run + history pagination | ~450 | PR 3 | Reopen/resume live runs; paginate older messages; auto-name. |
| 5 | feat(mobile): agent selection | ~550 | PR 3 | Browse + pick an agent; starter prompts; implicit persona_id. |
| 6 | feat(mobile): projects — list, select, chat-within | ~550 | PR 3 | Browse projects, open one, chat scoped to it. |
| 7 | feat(mobile): project file management | ~650 | PR 6 | Add/remove project files via pickers + streaming upload + status. |
| 8 | feat(mobile): input-bar attachments | ~550 | PR 7 | Attach documents/photos to a message; send-gating on indexing. |
| 9a | feat(mobile): citations & sources | ~500-700 | PR 3 | (deferred rich-chat) |
| 9b | feat(mobile): agentic reasoning timeline | ~500-700 | PR 3 | (deferred rich-chat) |
| 9c | feat(mobile): regenerate / edit / feedback | ~500-700 | PR 3 | (deferred rich-chat) |
| 9d | feat(mobile): follow-up suggestions | ~400 | PR 3 | (deferred rich-chat) |
| 9e | feat(mobile): image generation rendering | ~500 | PR 3 | (deferred rich-chat) |
PR0 spike ─► PR1 shell+history ─► PR2 mobile-native chat data layer (parser+contracts+tree+history) ─► PR3 CORE CHAT (walking skeleton)
│
┌──────────────────────┬────────────────────────────────────────────────────────────┼───────────────┐
▼ ▼ ▼ ▼
PR4 resume/paginate PR5 agents PR6 projects PR9a–9e rich-chat
│ (each independent,
▼ any order after PR3)
PR7 project files
│
▼
PR8 input attachments
PR 3 is the spine; PR 4/5/6 and all of PR 9 fan out from it independently. PR 7→8 is the only deeper chain (attachments reuse the project uploader). PR 2 is the mobile-native pure data layer (NDJSON parser + core contracts + message tree + history) — written independently in mobile; web keeps its own copies, nothing is shared.
/api/chat/send-chat-message via expo/fetch and logs parsed packets from response.body.getReader() on a physical device; (2) renders streamed markdown via react-native-streamdown. Record outcomes + chosen fallbacks.streamdown fails (react-native-marked) — confirm.expo/fetch lacks response.body.getReader() on device, switch PR 3's transport to XHR-progress feeding the same shared buffer — re-confirm before PR 2 finalizes the parser seam.Decisions (grilled 2026-06-25): iOS Simulator (localhost backend); in-app temporary dev screen reusing real session/token; streaming proven first, streamdown config deferred to Step 2.
Scaffold (throwaway, delete after PR 3): mobile/src/app/dev-stream.tsx (the probe screen) + a temporary "Dev: streaming spike (PR0)" button in mobile/src/app/index.tsx. No new deps, no native config. Reuses apiFetch for create-chat-session and swaps in expo/fetch only for send-chat-message; body mirrors web/src/app/app/services/lib.tsx sendMessage().
What it reports on screen: base URL · HTTP status · response.body present (Y/N) · getReader() present (Y/N) · packets parsed · duration · accumulated answer · last-40 packet types · any error.
Results (run 1, 2026-06-25 — HTTP 422, body-contract findings before streaming reached):
expo/fetch POST + secure-store bearer + create-chat-session (via apiFetch) all work — a real backend response came back through expo/fetch.parent_message_id: null accepted for a first message (not flagged).MessageOrigin enum has no "mobile" value — allowed: webapp | chrome_extension | api | slackbot | widget | discordbot | unknown | unset. Spike now sends origin: "unknown". PR 3 decision: add a "mobile" origin to the backend enum (small change, better analytics) vs keep "unknown".origin fix) HTTP 200; response.body present YES; getReader() present YES → PR 3 transport = expo/fetch, no XHR fallback needed.message_start → N×message_delta → stop.message_start/message_delta — rendered "Hello, Subash! 👋 Hope you're having a great day!"✅ Step 1 verdict: GO. expo/fetch streams NDJSON on the iOS sim; the shared-parser design holds. Two carry-forward notes:
{placement, obj:{type}} wrappers with top-level control objects ({type: ...} at root, e.g. message-id-info — surfaced as <<no-type>> in the probe). PR 2 parser/types must handle both (web already does).Scaffold removed: the throwaway dev-stream.tsx + the temporary home-screen button were deleted after the run (findings captured above); index.tsx is back to its committed state.
Step 2 (react-native-streamdown build/render on RN 0.85) NOT run — moved into PR 3 pre-work (its drift checkpoint / "before you start" gate), since PR 1 and PR 2 are markdown-independent. Fallback react-native-marked if it won't build.
PR 0 status: streaming spike COMPLETE (GO). Markdown build-check carried into PR 3 pre-work. PR 1 and PR 2 are unblocked.
(app) expo-router group under AuthGate; new-chat home (empty state); chat/[id] scaffold (static input shell, send disabled); history list via a TanStack Query hook over the sessions-list endpoint; chatSessions/chatSession query keys; sidebar wired to sessions; add chat session/message query keys to the dehydrateOptions PII-exclusion list (mobile/src/query/client.ts) before any history persists to MMKV — required, not optional.mobile/src/app/(app)/_layout.tsx (new), app/(app)/index.tsx (new), app/(app)/chat/[id].tsx (new, scaffold), app/(app)/history.tsx (new), app/_layout.tsx (modified: mount group), api/chat/sessions.ts (new, list only), api/query-keys.ts (modified), sidebar (modified).send disabled until PR 3.chat/[id] works; empty state shows. Provably working: real history list on device.get-user-chat-sessions vs project-scoped). Navigation model — stack vs tabs vs drawer; where do new-chat / history / projects live; what's the sidebar's role vs a tab bar? What does the empty chat/[id] scaffold show?RecentsSection), so no separate history.tsx screen. The demo mobile/src/app/index.tsx was replaced: the authed home moved into (app)/index.tsx (route groups are path-transparent, so (app)/index.tsx = /), and the sidebar (components/chat/AppSidebar.tsx) is mounted in (app)/_layout.tsx to overlay every authed screen. Data: api/chat/sessions.ts useChatSessions = useInfiniteQuery over GET /chat/get-user-chat-sessions?page_size=50[&before=<last.time_updated>]&only_non_project_chats=true (mirrors web's useSWRInfinite cursor pagination); name-null rows fall back to web's "New Chat". Landing mirrors web's WelcomeMessage (Onyx logo + random greeting from ["How can I help?", "Let's get started."]); the chat/[id] scaffold + landing share a disabled InputBar shell (send lands in PR 3). New files: (app)/{_layout,index,chat/[id]}.tsx, components/chat/{AppSidebar,ChatSessionList,ChatHeader,InputBar,WelcomeMessage}.tsx, api/chat/sessions.ts, lib/greetings.ts; modified api/query-keys.ts + query/client.ts (PII exclusion). Web untouched. Verified: mobile tsc clean, lint clean, 83 jest tests pass (6 new — ChatSessionList + useChatSessions). Device run vs a live backend is the remaining manual check.Decision (2026-06-26, revised — no shared chat code). The chat pure layer — NDJSON parser, message tree,
processRawChatHistory, and all chat/streaming/file contracts — is written natively in mobile, while web keeps its own existing copies. Nothing chat-related enters@onyx-ai/shared. We first considered sharing the whole pure layer, then narrowed it to just the ~40-line NDJSON parser, and ultimately dropped even that: the shared-package machinery (a@onyx-ai/sharedutil + a webstreamingUtils.tsre-point + a jest module-mapper + dist/build coupling) is more moving parts than the ~200 lines of duplication it removes. Pre-production, the backend NDJSON framing + message-threading are stable, so drift risk is low and cheap to fix if it ever bites (re-extract then). Web is completely untouched by the mobile chat port. (@onyx-ai/sharedstays design tokens + the existing interactive/typography contracts +numbers/formatutils.)
@onyx-ai/shared; web is untouched.mobile/src/chat/:
streamingModels.ts — minimal core packet contracts: PacketType (subset: message_start/delta/end, stop, section_end, error), MessageStart/Delta/End, Stop/StopReason, SectionEnd, PacketError, ChatHeartbeat, Placement, Packet, ObjTypes, root MessageResponseIDInfo. No OnyxDocument/rich types.interfaces.ts — minimal Message (no documents/citations/multi-model/toolCall), MessageType, ChatState (4-member core), FileDescriptor, ChatFileType, and the minimal session-snapshot input types BackendMessage + BackendChatSession.ndjson.ts — createNdjsonBuffer<T>() (pushChunk/flush), the pure NDJSON line-buffer split out of web's handleSSEStream (brace-recovery + trailing-flush preserved; no reader/decoder/abort).messageTree.ts — upsertMessages/getLatestMessageChain/getMessageByMessageId/getLastSuccessfulMessageId/setMessageAsLatest/buildEmptyMessage/buildImmediateMessages + SYSTEM_NODE_ID/MessageTreeState, ported ~verbatim from web (typed on the minimal Message).chatHistory.ts — processRawChatHistory(BackendMessage[], Packet[][]) → tree, ported from web; mobile-local it just emits minimal Messages (no rich types), aligning packets to assistant messages by ordinal.__tests__/{ndjson,messageTree,chatHistory}.test.ts — 31 jest unit tests.expo/fetch generator) and the send/create/session request contracts (transport-consumed).mobile/src/chat/* files above (all new). No shared-package or web changes.bunx jest src/chat) — NDJSON buffering (partial lines, brace-recovery, heartbeat passthrough, trailing flush, mixed wrapped/root shapes) + upsertMessages/getLatestMessageChain/getMessageByMessageId/getLastSuccessfulMessageId/setMessageAsLatest/builders + processRawChatHistory (nodeId reuse, child sort, packet alignment, error mapping, chain traversal). All 31 passing.PacketType core subset = message_start/delta/end · stop · section_end · error (+chat_heartbeat interface, +root message_id_info). handleSSEStream split = pure createNdjsonBuffer (buffer + split + JSON.parse + flat-brace recovery + trailing flush) vs platform reader/decoder/abort in PR 3. Minimal Message keeps only the ~10 structural/core fields (drops toolCall/documents/citations/multi-model). No dist/relink wiring needed (mobile-local).expo/fetch + getReader()); createNdjsonBuffer is fed decoded text by whatever transport PR 3 uses, so it accepts either a getReader() stream or an XHR-progress feed unchanged.buildEmptyMessage's -Date.now()-offset temp ids (collision-safe via send-gating) and getLastSuccessfulMessageId returning the synthetic -3 are intentional, matching web. PR 3 carry-forward: mobile's send flow must port web's call-site -3 → null guard (useChatController.ts: parentId === SYSTEM_MESSAGE_ID ? null : parentId) so the synthetic id is never sent as parent_message.mobile/src/api/chat/stream.ts (expo/fetch generator + the PR 2 mobile-native NDJSON parser + AbortController); state/chatSessionStore.ts (zustand, not persisted); hooks/useChatController.ts (create session at persona_id=0, optimistic nodes via the PR 2 builders, drive stream, ~50ms batched flush, stop); the packet-renderer foundation — components/chat/renderers/registry.ts (MessageRenderer contract + findRenderer dispatch, mirroring web renderMessageComponent) with only renderers/MessageTextRenderer.tsx registered + hooks/usePacketDisplay.ts (group + dispatch); components/chat/{MessageList,MessageRow,StreamingMarkdown,InputBar}.tsx; hydrate existing sessions via GET get-chat-session + the PR 2 processRawChatHistory; enable send.AgentTimeline composition layer (built in PR 9b). Build the dispatch seam, not the rich renderers.components/chat/renderers/{registry.ts,MessageTextRenderer.tsx}) + chat/[id].tsx (modified: real screen) + create-chat-session call in api/chat/sessions.ts (modified).StreamingMarkdown + perf memoization into a follow-up slice.expo/fetch + getReader()); still owed: run the deferred react-native-streamdown build/render spike on RN 0.85 here (fallback react-native-marked). Use origin: "unknown" (or decide whether to add a "mobile" value to the backend MessageOrigin enum). Exact send-body fields for the minimal core (origin value; which fields null/omitted: internal_search_filters, deep_research, allowed_tool_ids, forced_tool_id, llm_override). Optimistic node-id scheme + parent_message_id semantics (-1 vs null vs id). Stop semantics (immediate UI vs wait for STOP). Markdown styling → NativeWind token mapping. Where the chosen markdown lib's dev-build config lives. Renderer contract shape — confirm the MessageRenderer<TPacket,TState> interface (study web's messageComponents/interfaces.ts + renderMessageComponent.tsx) so PR 9's renderers slot in cleanly; decide whether packet-grouping is mobile-only or a shared pure helper.stream.ts shape before coding the controller.hooks/useChatSessionController.ts resume tail (resume-stream?cursor=) with a stillCurrent guard; onStartReached older-message pagination (guard short-list); rename-on-first-message + history refresh.useChatSessionController.ts (new), MessageList.tsx (modified: pagination), useChatController.ts (modified: rename hook).resume-stream cursor semantics + current_run shape; how to detect a live run on open. Auto-name endpoint + trigger timing. Which endpoint/params page older messages.mobile/src/chat/contracts/agents.ts (mobile-native MinimalAgent subset — per the PR 2 minimal-sharing decision, not shared); api/chat/agents.ts (GET /api/persona); components/chat/AgentPicker.tsx (bottom sheet: avatar/name/description); selection sets persona_id at create; starter prompts on the empty screen. No creation/editing.mobile/src/chat/contracts/agents.ts (new, mobile-native), api/chat/agents.ts (new), components/chat/AgentPicker.tsx (new), empty-state screen (modified), useChatController.ts (modified: pass persona_id).persona_id into create-chat-session; starter prompt submits./api/persona vs paginated /agents) + filtering (is_listed/builtin/featured). Avatar rendering: icon_name mapping + uploaded_image_id fetch URL (needs bearer). Picker UX (sheet vs screen; launch point). Default agent (id 0) + disable_default_assistant handling.mobile/src/chat/contracts/projects.ts (mobile-native Project, ProjectFile, UserFileStatus); api/chat/projects.ts (list + detail/files, read-only); app/(app)/projects/{index,[id]}.tsx; "new chat in project" passes project_id to create-chat-session; sidebar surfaces projects.mobile/src/chat/contracts/projects.ts (new, mobile-native), api/chat/projects.ts (new), projects/index.tsx + projects/[id].tsx (new), sidebar (modified), useChatController.ts (modified: project_id).project_id.project_id vs the chat_sessions[] in the snapshot). Show project instructions? token-count? Navigation placement (sidebar vs tab). Read-only confirmation (no CRUD).api/files/upload.ts (expo-file-system createUploadTask, MULTIPART, field files, bearer, onProgress); state/uploadStore.ts + 3s status polling; expo-document-picker + expo-image-picker entry points + asset normalization; link/unlink; file list UI with status chips. app config plugin entries (photo permission strings).api/files/upload.ts (new), state/uploadStore.ts (new), picker helpers (new), projects/[id].tsx (modified: files section), app.json/config plugin (modified).temp_id_map usage; status-poll cadence + terminal states (COMPLETED/FAILED/SKIPPED). Picker config: allowed MIME types, multiple selection, size-limit source. Config-plugin entries (NSPhotoLibraryUsageDescription etc.; microphonePermission:false). Link/unlink vs delete semantics (delete cascades?). Fact-check nuance: use createUploadTask for progress/large files; FormData-with-URI is fine for small — confirm the threshold/approach.components/chat/AttachmentChips.tsx; mobile/src/chat/fileDescriptors.ts (mobile-native projectFilesToFileDescriptors + type detection — per the PR 2 minimal-sharing decision, not shared; web keeps its own fileUtils.ts); build file_descriptors[] on send; gate send until indexed (token_count != null); image preview via GET /api/chat/file/{file_id}. Camera deferred.mobile/src/chat/fileDescriptors.ts (new, mobile-native), components/chat/AttachmentChips.tsx (new), InputBar.tsx (modified), useChatController.ts (modified: attach descriptors). Web fileUtils.ts untouched.file_descriptors[] built correctly; image preview renders.FAILED. file_descriptors field mapping (file_id→id, chat_file_type→type, id→user_file_id). Image-preview auth (GET /api/chat/file/{id} needs bearer in <Image> — header vs signed URL). Reuse vs minor duplication with PR 7's uploader.mobile/src/chat/contracts/ packet types, register a new MessageRenderer into the PR 3 dispatch (components/chat/renderers/), and add the RN UI. The dispatch seam from PR 3 means none of these touch the core display path. 9b (agentic timeline) additionally builds the AgentTimeline composition layer (mirrors web's AgentTimeline/TimelineRendererComponent); 9b's reasoning/search/tool sub-renderers and any later timeline renderers plug into it. Web's usePacketProcessor stays web-only.