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. (This is about NOT sharing chat logic — it is unrelated to visual parity, which is required; see the next callout.)
🎯 WEB-PARITY PRINCIPLE (2026-06-30) — applies to every component from PR 4 onward (PR 0–3 done). Each mobile component/screen must look and behave like its web counterpart as closely as the platform allows — layout, spacing (remember mobile spacing is pixel-valued, so translate web Tailwind steps, don't copy class numbers), sizing, color, and interaction. Pixel/behaviour-exact is the goal; small platform-driven differences are fine. Concretely, in order:
- Reuse the parity primitives first.
@/components/ui/textTextand@/components/ui/buttonButtonare already built to full web parity — compose them (and the othercomponents/ui/*:text-input,icon,separator) instead of hand-rolling raw RNText/Pressable/TextInput.- Before building anything new, check what already exists — scan
components/ui/*,components/chat/*, the@/icons/*set, and the shared design tokens for something that already covers it (or is close).- If web parity needs a primitive mobile doesn't have yet, STOP and ASK the owner whether to port it — use the
port-web-component-to-mobileskill for a pixel/behaviour-exact RN port. Never hand-roll a divergent lookalike to avoid the ask.- Document the divergences. Every PR's "As built" note must explicitly list what mobile renders differently from web and why (intentional simplification, platform constraint, deferred-to-a-later-PR feature), so reviewers and the owner can see the gaps at a glance.
(Owner is handling sidebar parity separately, out of the chat PRs.)
| 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.react-native-streamdown (worklet Bundle Mode; adds react-native-enriched-markdown+remend, babel worklets:false+explicit react-native-worklets/plugin {bundleMode,workletizableModules:['remend']}, metro getBundleModeMetroConfig compose, enriched-markdown config plugin); keyboard = react-native-keyboard-controller (KeyboardProvider in _layout, KeyboardStickyView in ChatScreen); origin = "mobile" (added MessageOrigin.MOBILE to the backend enum — the port now touches backend by choice); renderer contract = web-faithful (a MessageRenderer = {matches(packets), Component} + findRenderer(packets) in components/chat/renderers/registry.ts, NOT the 03-detailed-design reduce sketch). New files: api/chat/stream.ts (expo/fetch NDJSON generator over the PR2 createNdjsonBuffer + AbortController), state/chatSessionStore.ts (ephemeral zustand, Map-per-session, never persisted), hooks/useChatController.ts (module-scope runChatStream so the stream survives the landing→/chat/[id] navigation; optimistic nodes via PR2 builders; ~50ms batched flush; -3→null parent guard; create-at-persona_id=0), hooks/usePacketDisplay.ts, components/chat/renderers/{registry,MessageTextRenderer}, components/chat/{StreamingMarkdown,MessageRow,MessageList,ChatConversation}.tsx, icons/stop-circle.tsx; modified api/chat/sessions.ts (create/get/stop), components/chat/{InputBar,ChatScreen}.tsx, app/(app)/{index,chat/[id]}.tsx, app/_layout.tsx, babel.config.js, metro.config.js, app.json. Send body (minimal): {message, chat_session_id, parent_message_id, file_descriptors:[], deep_research:false, origin:"mobile"}. Hydrate via GET get-chat-session/{id} → PR2 processRawChatHistory (guarded against clobbering a live stream). Stop = client abort + POST stop-chat-session/{id} + immediate UI flip. ~1000 source LOC (over the ~700 band — kept whole as one cohesive skeleton rather than split mid-feature). Verified: mobile tsc clean, lint clean, 129 jest pass (10 new: store + controller flow [incremental tokens / new-session create / stop-aborts / hydrate] + stream discriminators). Adversarial multi-agent review = 7 findings, 6 false-positives (FlashList/memo/re-render "concerns" are intended streaming behavior), 1 nit fixed (ChatHeader off-token py-3→py-12). HARD GATE STILL OWED: the react-native-streamdown on-device render + a clean expo prebuild --clean + run:ios/run:android dev-build (adds native enriched-markdown + keyboard-controller) — the agent can't run a device build; owner must verify. Fallback if streamdown won't build: react-native-marked (pure-JS, swap only StreamingMarkdown.tsx).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.get-chat-session snapshot and lets virtualization handle long chats; there is no older-messages endpoint to page against), and mobile already gets the full conversation in one call + FlashList already virtualizes. Implementing onStartReached would have been a mobile-only divergence, so MessageList.tsx is unchanged. The session-list cursor (sidebar recents, built in PR 1) keeps its before=time_updated cursor as-is — the owner chose to leave the rare same-timestamp tie rather than add a (time_updated, id) compound cursor to the shared backend route (the chat port stays backend-free; comment updated in api/chat/sessions.ts). So PR 4 = resume in-flight run + auto-name only (~190 source LOC + tests, under the ~450 est).
hooks/useChatSessionController.ts — a module-scope runResumeStream (survives re-renders, like PR 3's runChatStream) + a module resumingRuns: Set<number> dedupe. The hook is a read-only observer (useQuery enabled:false) of the get-chat-session snapshot that useChatController fetches (same query key → React Query dedups; no second network call). When the snapshot's current_run.run_id maps to an assistant node (single-model only — a multi-model run_id is the user message and fails the node-type check, matching web), it re-attaches: resumeChatMessage(sessionId, cursor=0, signal) (full buffer replay + live tail), ~50 ms batched flush, a stillCurrent = currentSessionId===sessionId && !aborted guard (drops writes once the user switches away). Heartbeats pass through on the resume path (web does the same via resumeStream, unlike send) so the loop re-checks focus during quiet phases and releases the connection promptly on navigate-away; they're excluded from render via isHeartbeat. On finish/error (404 = nothing to resume, swallowed by the bare catch) it settles from get-chat-session (processRawChatHistory → hydrateSession) and setQueryDatas the fresh snapshot so a remount can't re-resume a finished run. abortController is reused (not a second field): send and resume are mutually exclusive per session (resume only fires on cold-open hydration, guarded by data.abortController == null), so PR 3's existing stop() aborts a resume for free. The settle-block guards hydrateSession/setQueryData/controller-release on stillOurs() — the resume's own AbortController object used as an ownership token held through the settle await — so a send that races in (and even completes) during the get-chat-session fetch is not clobbered by the now-stale snapshot (a completed send leaves a null controller, which ≠ the resume's token).runChatStream now takes an AutoNameContext | null (set only when sessionId == null at submit — a brand-new session). In its finally, when the run produced an answer (!signal.aborted && sawStreaming && !hadError), it fires nameNewSession: a 200 ms delay (mirrors web's handleNewSessionNaming — backend needs a beat to persist the row), then renameChatSession → PUT /chat/rename-chat-session {name:null} (backend LLM-generates the title), then invalidates chatSessions so the sidebar + the header title (which reads from useChatSessions()) update and the new chat appears. api/chat/sessions.ts gains renameChatSession; stream.ts refactors the NDJSON reader into a shared readNdjson used by both send + resume, adds resumeChatMessage (GET, bearer) + StreamHttpError.useChatSessionController.ts newMessageHistory.length >= 2 && !description); mobile defers that minor recovery path; (3) mobile reuses the single abortController for resume (web uses a dedicated one) — safe because the two are mutually exclusive per session on mobile.hooks/useChatSessionController.ts (new), hooks/__tests__/useChatSessionController.test.tsx (new, 9 tests); modified hooks/useChatController.ts (auto-name), api/chat/stream.ts (resumeChatMessage + readNdjson refactor + keepHeartbeats + exported isHeartbeat), api/chat/sessions.ts (renameChatSession + cursor comment), components/chat/ChatConversation.tsx (mounts the resume hook), hooks/__tests__/useChatController.test.tsx (+3 auto-name tests). Web untouched; no backend change. Verified: mobile tsc clean, lint clean (2 pre-existing _layout.tsx warnings), 141 jest pass (12 new). Two adversarial multi-agent reviews (each: 3 dimensions × independent verification). Review 1 (19 findings) surfaced 2 confirmed-real bugs, FIXED — (1) the settle-refetch could clobber a send that completed during its await → fixed with the stillOurs() ownership-token guard above; (2) heartbeats were filtered on the resume path, dropping web's quiet-phase liveness re-check → fixed with keepHeartbeats passthrough (a stop-during-resume setQueryData "uncertain" was resolved by the same guard). Review 2 (post-fix) confirmed both fixes correct (0 fix-correctness findings) and the comment cut behavior-neutral; its only actionable items were code-quality nits — applied: dropped an unused readNdjson(signal) param, tightened the auto-name comment (in-band error packets still name, web-faithfully), and added the observer post-mount-reactivity test. A GitHub-bot review round (Greptile + cubic, 4 P2 nits) followed: authHeaders no longer sends Content-Type on the bodyless resume GET (added only on the send POST); FLUSH_INTERVAL_MS extracted to chat/constants.ts (shared by both stream hooks); the observer queryFn uses skipToken instead of a non-null assertion; and the resume catch now logs non-404 failures (StreamHttpError reintroduced so the expected 404 stays quiet). 143 jest pass. On-device resume/auto-name against a live backend is the remaining manual check.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.icon_shape/icon_color — they're a fixed stroke octagon (no color-from-id hashing) with a 4-branch fallback: id 0 → Onyx logo · uploaded_image_id → circular expo-image (bearer header on /persona/{id}/avatar) · icon_name → one of 18 mapped -Small icons each in its web theme color · single ASCII letter → monogram · else two-line glyph. Colors match web exactly via semantic classes (text-theme-blue-05 … resolved by the vars() provider). (2) StarterMessage is {name,message} only. (3) GET /persona is unsorted and web does no client-sort — mobile trusts server order; per-surface ordering is pinned/featured (rail) and featured/all-descending-id (gallery). Owner picks (AskUserQuestion ×7): BOTH a full-screen gallery route ((app)/agents.tsx, Featured+All sections + search) and a sidebar pinned-agent rail (web-exact, AgentSidebarSection); auto-pin on select (PATCH /user/pinned-assistants) so the rail grows (manual pin/unpin deferred); avatars at full parity now (added expo-image — native rebuild is an owner gate); starter prompts auto-send; honor disable_default_assistant (new GET /settings hook). Selection carried via the / route param agentId; agent bound at create-chat-session(personaId) (switch = new session), mirroring web's liveAgent precedence (resolveLiveAgent). New files: chat/agents.ts (types + pure resolvePinnedAgents/resolveLiveAgent/buildAgentRail/splitAgentsForGallery), api/chat/agents.ts, api/settings.ts, hooks/{useLiveAgent,useAuthToken}.ts, components/avatars/{AgentAvatar,AgentImage,agentAvatarIconMap}, components/chat/{AgentSidebarSection,ChatEmptyState,Suggestions}.tsx, components/agents/AgentCard.tsx, app/(app)/agents.tsx, 20 ported icons/* (18 mapped + octagon + two-line); modified useChatController (persona + starter override), ChatConversation, WelcomeMessage, AppSidebar, SidebarTab (+leading node slot), api/{query-keys,types,chat/sessions}. Divergences from web (documented): picker is a screen+rail not web's card-modal (touch, no hover); enterprise custom-logo for id 0 not ported (no enterprise-settings fetch); starters render above the keyboard-pinned input, not below; gallery card tap starts a chat directly (no viewer modal); no per-agent edit/share/stats/pin-toggle. ~1770 LOC. Gallery chrome (resolved): the mobile SettingsLayout primitive (components/settings/, landed on main via #12587) is now used by the gallery — Root / Header{icon=octagon, title="Agents", description, children=search} / Body{Featured/All}, mirroring web. A fixed close row supplies the dismiss the headerless (app) stack lacks (the primitive omits a back button by design); added an optional keyboardShouldPersistTaps passthrough to SettingsRoot so search-then-tap works in one tap. Verified: tsc clean, lint clean, 151 jest pass (+22: pure agent logic, avatar branches, controller persona/starter threading). HARD GATE (owner-run): on-device dev-build after expo prebuild to compile the new native expo-image module + verify avatars/gallery/rail render. Fallback if expo-image is a problem: RN core Image with source.headers (no native rebuild), swapping only AgentImage.tsx.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).(app)/projects/[id].tsx detail screen; no standalone projects/index.tsx list (web has none). (2) detail = full read-only parity — folder title + instructions + files-with-indexing-status + input bar (starts a project-scoped chat) + the project's chats. (3) chats read from the embedded chat_sessions[] on GET /user/projects/{id}/details (no separate paginated fetch). (4) MMKV — all project query keys excluded (their embedded chat titles are PII). New files: chat/contracts/projects.ts (mobile-native Project/ProjectFile/UserFileStatus/ProjectDetails; chat_sessions reuses ChatSessionSummary), api/chat/projects.ts (useProjects plain useQuery over the unpaginated /user/projects; useProjectDetails over /details), app/(app)/projects/[id].tsx, components/chat/{ProjectView,ProjectContextPanel,ProjectChatSessionList,ProjectList,FileCard}.tsx, icons/file-text.tsx, lib/time.ts (timeAgo). Modified: components/chat/AppSidebar.tsx (Projects section + useSegments() disambiguation of the shared id param between /chat/[id] and /projects/[id]), hooks/useChatController.ts (new projectId param → createChatSession(DEFAULT_PERSONA_ID, projectId); invalidates the project queries after a project-scoped create; push (not replace) into /chat/[id] from a project so Back returns to it), api/chat/sessions.ts (export DEFAULT_PERSONA_ID), api/query-keys.ts (userProjects/userProject), query/client.ts (exclude both project key heads). ~528 source LOC (in band). Web-parity divergences (intentional): no project/file/chat CRUD or move/delete menu (create/rename/delete = later; file add/remove = PR 7); input bar sticks to the bottom (web keeps it mid-page — platform norm); single folder glyph, no inline folder expand in the sidebar; file rows are read-only pills with a spinner while indexing — no image thumbnails (auth-image <Image> bearer is PR 8); extension-less files show "File" where web shows a blank type line. Verified: mobile tsc clean, lint clean, 145 jest pass (16 new: ProjectList + ProjectChatSessionList + projects hooks + controller project-scope/push + timeAgo boundary + MMKV project exclusion). Adversarial multi-agent review = 7 findings, 6 confirmed & fixed (timeAgo "0y ago" year-boundary gap; replace→navigate from a project; NaN-id enabled guard; missing list loader; +1 dup) / 1 accepted nit (the "File" label). HARD GATE STILL OWED: on-device run vs a live backend (agent can't run a device build) — owner must verify the sidebar Projects section, project detail render, and starting a project-scoped chat.unify-chat-input (structural; affects PR 7–9 targets)After PR 6, the forked ChatConversation + ProjectView were collapsed into one persistent ChatSurface (mounted in (app)/_layout, driven by deriveFocus(pathname); full spec in 06-unified-chat-surface.md). The route files (index, chat/[id], projects/[id]) now render null — the chat/project UI is drawn by the overlay, and the composer is a single persistent InputBar in ChatSurface. This shifts the targets below:
components/chat/ProjectContextPanel.tsx (rendered by ChatSurface in project focus), not projects/[id].tsx (PR 7).ChatSurface's composer, not per-screen (PR 8).ChatSurface never remounts across conversations, so any per-conversation draft state must reset on [sessionId, projectId] change. The input draft already does (a useEffect in ChatSurface); PR 8 attachments must follow the same rule or they leak into the wrong conversation.MessageList via the PR 3 registry; the refactor changed the screen shell, not the message display path.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), components/chat/ProjectContextPanel.tsx (modified: add/remove files section — the project detail renders inside ChatSurface, so projects/[id].tsx is now a null route and is not touched), 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.AskUserQuestion ×4): both device pickers and a recent/library-files picker (link existing, like web's FilePickerPopover); createUploadTask universally; unlink-only remove; add a client size pre-check from GET /settings. Endpoints confirmed against backend/onyx/server/features/projects/api.py: upload POST /user/projects/file/upload (multipart files + Form project_id + temp_id_map; response {user_files, rejected_files}, partial success normal); status POST /user/projects/file/statuses; unlink DELETE /user/projects/{id}/files/{fileId} (204); link POST same path; recent GET /user/files/recent. The file key for the temp_id echo is ${size}|${name[:50]} (build_hashed_file_key) — mirrored in buildFileKey; but reconciliation is done by refetch (one request per file, native uploader takes a single file), not the echo. Enum fix: backend UserFileStatus has INDEXING that mobile's (and web's) enum omitted — added it + isProcessingStatus() so an indexing file shows a spinner, not a "done" chip. New files: api/files/{upload,files,pickers}.ts (native new File(uri).upload() on the SDK-56 object API — legacy createUploadTask also present; manual bearer; non-2xx resolves so status is checked), state/uploadStore.ts (ephemeral, project-keyed, never persisted), hooks/useProjectFiles.ts (orchestration: size pre-check, optimistic → reconcile-via-refetch, 3s /statuses polling that patches the cached ProjectDetails in place, link/unlink), components/chat/FilePickerSheet.tsx (RN Modal bottom sheet). Modified: FileCard (+INDEXING spinner, remove X, upload %), ProjectContextPanel (add-files ContentAction + sheet + inline error banner + projectId prop), ChatSurface (passes projectId), chat/contracts/projects.ts (+INDEXING/temp_id/RejectedFile/CategorizedFiles/isProcessingStatus), api/settings.ts (+user_file_max_upload_size_mb), api/query-keys.ts (+userRecentFiles), query/client.ts (recent-files key MMKV-excluded — file names are PII), app.json (+expo-document-picker, +expo-image-picker plugin with photosPermission, microphonePermission:false, cameraPermission:false). Deps added via expo install: [email protected], [email protected], [email protected]. Divergences from web (documented): no full UserFilesModal (search/select/global-delete) — the sheet just scrolls all recent files; global delete not exposed (backend refuses it whenever a file is linked to a project, so it would no-op from inside one — unlink is the only web-faithful remove); no image thumbnails (PR 8); errors surfaced inline (mobile has no toast primitive — composed Text, not a ported toast); picker is a bottom sheet, not web's hover popover. ~900 source LOC. Verified: tsc clean, lint clean (2 pre-existing _layout warnings), 228 jest pass (+30: uploadStore · upload file-key/field-mapping/non-2xx · useProjectFiles size-precheck/reconcile/rejected-reasons/link/unlink/polling/partial-batch/refetch-fail/link-fail · FileCard · FilePickerSheet · recent-files PII exclusion**). Adversarial multi-agent review = 4 dimensions × skeptic verification, 10 findings → 3 refuted, 7 confirmed & fixed (size-rejections now surface immediately in a partial batch; refetch-failure no longer strands optimistic chips — try/finally + error; link/unlink errors caught + surfaced; poll-patch keeps all polled fields; +3 test-fidelity/PII). 1 medium (transient "file in both lists" race) accepted benign — re-link is idempotent, the picker closes on tap, and it's mitigated by the new link error handling. HARD GATE STILL OWED: native rebuild (expo prebuild --clean + run:ios/android) to compile the three native modules and verify pick→upload→progress→indexing→unlink on device (agent can't build a device app). Device-verify risk: Android content:// picker URIs through the new File() uploader.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.InputBar.tsx here to web's composer shape/layout — the rounded, auto-growing multi-line container with the control/toolbar row and the send/stop affordance positioned as in web's BaseInputBar/AppInputBar, attachment chips slotted in. The mobile input must look and behave like web's composer (small platform diffs OK), per the WEB-PARITY PRINCIPLE. Re-read web/src/sections/input/{BaseInputBar,AppInputBar}.tsx first; if a needed piece isn't a mobile primitive yet, ask before porting.mobile/src/chat/fileDescriptors.ts (new, mobile-native), components/chat/AttachmentChips.tsx (new), InputBar.tsx (modified, web-parity restyle), useChatController.ts (modified: attach descriptors + reset the attachment draft on [sessionId, projectId] change, mirroring the input-draft clear — the composer is one persistent instance in ChatSurface, so unreset attachments leak across conversations). 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.AskUserQuestion ×4): (1) send-gating = hard-disable Send until every attachment reaches a terminal status; a small status line under the chips; a FAILED file shows an error + stays removable (removal unblocks send). (2) image thumbnails NOW via bearer expo-image (GET /chat/file/{file_id}). (3) pickers = documents + photos + recent-files (reuses FilePickerSheet); no camera. (4) composer = full three-row restyle incl. multiline auto-grow. The upload "blocker" was closed by evidence, not grilled: per-message attachments POST to the same /user/projects/file/upload with project_id omitted — backend already declares project_id: int | None = Form(None) (backend/onyx/server/features/projects/api.py:132) and web does exactly this (beginUpload(files, null)). PR 8 is backend-free.
uploadProjectFile → uploadUserFile(asset, projectId: number|null, …) (omits the project_id Form param when null; the mobile analog of web's beginUpload(files, projectId?)); pickers (pickDocuments/pickImages), FilePickerSheet, and getUserFileStatuses reused as-is; the optimistic-file builder + status-label + isFailedFile extracted to shared helpers in lib/files.ts (buildOptimisticFile/attachmentStatusLabel/isFailedFile), consumed by both the project and message flows. useProjectFiles was not generalized — a leaner sibling useMessageAttachments was written because the project hook is coupled to the project query cache (invalidate/patch userProject) + link/unlink, none of which apply to a per-message draft. (Web co-locates both in one ProjectsContext; mobile never ported that context, so a focused hook is the mobile-native equivalent of web's currentMessageFiles slice.)FileCard for every surface (web-faithful, post-grill decision). Rather than a separate composer chip component, mobile's single FileCard renders image files as a square thumbnail (bearer expo-image) and everything else — plus any failed upload — as a bordered pill, and is used by the composer strip, a sent message's attachments, and the project panel (mirrors web, where one FileCard serves AppInputBar + ProjectContextPanel + the agent viewer). Each surface just wraps FileCards in its own layout (flex-wrap strip vs the panel list). Remove is gated on !uploading uniformly (web's doneUploading).chat/fileDescriptors.ts (pure projectFilesToFileDescriptors — file_id→id, chat_file_type→type, id→user_file_id, mirroring web's projectsFileToFileDescriptor; + the inverse fileDescriptorToDisplayFile for rendering a sent message's files); hooks/useMessageAttachments.ts (local-state draft: pick → uploadUserFile(…, null, …) → reconcile-by-temp_id → 3s /statuses poll → descriptors/hasBlockingFiles/clear, self-resets on a resetKey = ${sessionId}:${projectId}); components/chat/AttachmentImage.tsx (bearer expo-image, mirrors AgentImage); icons/paperclip.tsx (faithful port of web's SVG).FileCard.tsx became the unified image-thumbnail-or-pill card; InputBar.tsx restyled to the web composer shape (rounded container → FileCard strip → auto-grow multiline TextInput → control row: paperclip-left opening FilePickerSheet + send/stop-right; blocking status line + inline error banner); useChatController.submit(overrideMessage?, files?) threads real descriptors into both buildImmediateMessages (optimistic user node) and the send body's file_descriptors; ChatSurface mounts useMessageAttachments, and onSend captures descriptors before clear() so the clear can't race the async send; MessageRow renders a sent user message's files read-only; ProjectContextPanel renders the same FileCard in a flex-wrap strip.resetKey (a keyRef) and patch optimistic entries by temp_id via functional setState — a late upload/poll for a conversation the user already left is dropped, never resurrected. Verified by test (switch conversations mid-upload → the reconcile is dropped).onPickRecent). (4) errors are an inline banner (no toast primitive on mobile). (5) no camera; no deep-research/actions/voice toolbar controls (deferred). (6) expo-image cachePolicy="none" (bearer, per AgentImage). (The earlier "dedicated AttachmentChips" divergence was removed — mobile now uses one FileCard like web.)tsc clean, lint clean (2 pre-existing _layout warnings), 250 jest pass (fileDescriptors mapping/inverse · useMessageAttachments upload/reconcile/size-precheck/reject/recent/clear/resetKey-reset/late-write-guard/poll · FileCard image-vs-doc/failed/remove-gating/read-only · useChatController file_descriptors threading · uploadUserFile project-less path). Adversarial multi-agent review = 4 dimensions × skeptic verification, 10 findings → 8 refuted → 2 confirmed (both code-quality, applied: removed a dead isUploading; extracted the duplicated isFailedFile) — 0 correctness/race defects survived, validating the ownership-key guards. Real bugs caught in self-review and fixed: a doc card stuck INDEXING/FAILED was un-removable (would have wedged send with no escape) and a duplicated optimisticFile (now the shared buildOptimisticFile). HARD GATE STILL OWED: native rebuild (expo prebuild --clean + run:ios/run:android) to verify pick→upload→thumbnail→send-gating→send on device, multiline auto-grow keyboard behavior, and the bearer image thumbnails (the agent can't build a device app).rework-userfiles-hooks (planned; owner-driven)useProjectFiles is project-keyed and PR 8's useMessageAttachments is a per-conversation local draft — two forked, ephemeral file layers. Target (owner, 2026-07-09): a single file-keyed source of truth (generalize uploadStore from byProject → Map<fileId, entry> + one shared status poller), with three hooks over it — a core useUserFiles (upload/delete/status) and two lean lenses useMessageAttachments (per-conversation draft = id refs) and useProjectFiles (link/unlink). Session/project become links applied on top, never the file's identity, so an upload survives navigation instead of being trapped in the conversation it started in. Mobile keeps the store pattern (not web's ProjectsContext): state must outlive the morphing ChatSurface and be written from detached async callbacks, and selectors avoid re-rendering every file consumer on each progress tick. Full LLD produced in that PR's grill session.ChatSurface.sendWithAttachments clears attachments right after void submit(...); if send setup fails (e.g. new-session create), the draft is gone and unretryable. Fix needs submit to return an accepted signal so the clear only fires on success — natural once the file lives in the central store (it survives regardless of the send). (components/chat/ChatSurface.tsx.)partitionBySize treats a null/0 upload-size setting as "unlimited." When user_file_max_upload_size_mb is missing/0/invalid the client precheck is skipped, so oversized files only fail server-side. Decide a finite fallback (owner call — a constant or a real server config) vs. keep deferring to the server. (mobile/src/lib/files.ts.)hooks/useMessageAttachments.ts.)BearerImage primitive so AttachmentImage + AgentImage stop duplicating the auth'd-image + cachePolicy="none" cache-safety invariant.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.AgentTimeline composition (9b) must match web's look AND structure, not just its data. Port web's renderer/timeline component layout — step containers, headers, icons, indentation/connector lines, spacing, and collapse/expand behaviour — so a mobile reasoning/search/tool step reads like its web counterpart (web/src/app/app/message/messageComponents/** incl. timeline/**). Mirror the structure; do not invent a new mobile timeline shape. Document any platform-driven divergence in the As-built note, per the WEB-PARITY PRINCIPLE.