docs/mobile-chat/9b-timeline/01-research.md
Status: draft · Task: 9b-timeline · Parent:
../05-pr-roadmap.md(PR 9b) · Prior phase:../9a-citations/(shipped #13025)
Port web's chat agentic reasoning timeline (AgentTimeline — reasoning/search/tool sub-steps with step
containers, headers, icons, connector lines, collapse/expand) to the Onyx React Native + Expo mobile app
(mobile/), at web parity (match web's look AND structure), extending the 9a foundation and registering
into the PR-3 renderer dispatch. Backend is unchanged. This is the rich-chat phase that additionally builds the
AgentTimeline composition layer that every later phase (search/fetch/tool renderers, 9c–9e) plugs into.
AgentTimeline composition layer (real steps +
collapse/expand), (3) the reasoning step renderer only (reasoning_start/delta/done). Every tool
renderer is a separate follow-up phase: internal/web search, fetch/open_url, python/code, coding-agent+bash,
custom-tool, deep-research + nested (sub_turn_index) agents, memory. Image generation is a separate phase
(9e). Multi-model (model_index) is out (mobile is single-model) but the grouping must tolerate non-zero
model_index. Parallel-tool tabs (tab_index parallelism) are a follow-up, but the grouping key + data model
should not preclude them.../05-pr-roadmap.md PR 9a–9e callout): match web's look AND structure; port
the step-container/header/icon/connector/collapse shape, don't invent a new mobile timeline. Document any
platform-driven divergence.mobile/src/chat/, NOT @onyx-ai/shared) per the PR-2 decision. Web is untouched.
Backend is unchanged (reasoning packets already exist).The 9b seams already exist and are explicitly reserved:
mobile/src/chat/messageProcessor.ts — flat, cursor-incremental (nextPacketIndex) packet→state reducer.
ProcessedMessageState = {nodeId, nextPacketIndex, citationMap, citations, seenCitationDocIds, documentMap, isComplete, stopReason}. Header comment: "9b extends it with turn/tab grouping + timeline steps, so the
shape here stays deliberately flat (grouping-free)."mobile/src/components/chat/AgentTimeline.tsx — existing stub: 36px rail (w-36), 24px AgentAvatar,
reanimated shimmer ThinkingLabel, and a TimelineStep list primitive (TimelineStepData = {key, label, status:"running"|"done"|"error", icon?}; connector lines h-8 w-[1px] bg-border-01 with opacity-0 on
first/last; fallback node dot h-8 w-8 rounded-full bg-text-04; step Icon size=12). steps prop always
[] — nothing populates it. Rendered in MessageRow.AssistantMessage above the answer:
<AgentTimeline agent isLoading={!hasContent && !processed.isComplete} />.mobile/src/components/chat/renderers/registry.ts — MessageRenderer = {matches(packets), Component};
MessageRendererProps = {packets, processed}; first-match-wins RENDERERS; findRenderer. Simpler than
web's render-prop MessageRenderer<T,S>. Only MessageTextRenderer registered.mobile/src/hooks/usePacketDisplay.ts — processed = useMemo(() => processPackets(createInitialState(nodeId), packets), [nodeId, packets]) — full recompute per flush (array identity changes each flush).
Deliberately NOT a render-mutated ref — mobile's react-hooks/refs lint forbids ref.current access
during render, so web's ref-held usePacketProcessor pattern is illegal here.mobile/src/components/chat/MessageRow.tsx — AssistantMessage: usePacketDisplay(node) →
<Renderer packets processed/> inside <View className="px-12">; AgentTimeline above; CitedSources (9a)
below. hasContent = Renderer != null && packets.length > 0.mobile/src/chat/streamingModels.ts — PacketType currently: MESSAGE_START/DELTA/END, STOP,
SECTION_END, ERROR, CITATION_INFO, SEARCH_TOOL_DOCUMENTS_DELTA, OPEN_URL_DOCUMENTS. Placement
already carries all 4 fields (turn_index, tab_index, sub_turn_index, model_index). Must add
REASONING_START/DELTA/DONE (+ TOP_LEVEL_BRANCHING for grouping tolerance).mobile/src/chat/contracts/documents.ts — full SearchDoc/StreamingCitation/CitationMap (9a). Reused by
the deferred search/fetch renderers, not by reasoning.SourceRow/SourceIcon/openSource/CitedSources) — reusable by the deferred search/fetch
renderers.Backend (reasoning packets — already emitted, no backend change): ReasoningStart type="reasoning_start"
(no fields), ReasoningDelta type="reasoning_delta" {reasoning: str}, ReasoningDone type="reasoning_done",
from backend/onyx/chat/llm_step.py, carrying Placement.turn_index/tab_index. There is NO message_end on
the wire — the whole turn completes only via OverallStop (type="stop"). SECTION_END is usually
client-synthesized (web injects it into prior groups on a new turn_index and into all open groups on STOP —
this is how a step is marked "complete"). TopLevelBranching {num_parallel_branches} is pre-parallel metadata.
Web source-of-truth (parity target), all under web/src/app/app/message/messageComponents/:
timeline/hooks/packetProcessor.ts (group by ${turn_index}-${tab_index ?? 0}, synthesize
SECTION_END, categorize tool/display groups) → timeline/transformers.ts (GroupedPacket[] →
TransformedStep[] → TurnGroup[]; isParallel = >1 step share a turn_index).AgentMessage.tsx runs usePacketProcessor + usePacedTurnGroups (200ms staggered reveal) →
<AgentTimeline> above + final-answer RendererComponent below.AgentTimeline.tsx + useTimelineUIState (7 states: EMPTY / DISPLAY_CONTENT_ONLY / STREAMING_SEQUENTIAL / STREAMING_PARALLEL / STOPPED / COMPLETED_COLLAPSED / COMPLETED_EXPANDED) +
useTimelineExpansion (default collapsed; auto-collapse on stop/answer-start unless userHasToggled) +
useTimelineHeader (shimmer header text) + useTimelineMetrics.TimelineRendererComponent (per-step isExpanded, renderType = override ?? (isExpanded?FULL:COMPACT))
StepContainer (TimelineIconColumn rail + TimelineSurface tint + TimelineStepContent header+collapse+body);
Done/Stopped terminal step appended.interfaces.ts): render-prop MessageRenderer<T,S> — computes RendererResult[]
({icon, status, content, expandedText?, supportsCollapsible?, alwaysCollapsible?, timelineLayout?, noPaddingRight?, surfaceBackground?}) and calls children(results); the parent StepContainer owns the wrapper.timeline/renderers/reasoning/ReasoningRenderer.tsx — SvgCircle icon, status
"Thinking" (or extracted markdown heading), ExpandableTextDisplay of streamed reasoning markdown, 500ms
min-thinking gate.Mobile primitives + gotchas (from render-infra scan): available — View, Text (font+color enum), Icon,
Separator, Button, Card, Content/ContentAction, Spinner (RN Animated, jest-safe), StreamingMarkdown
(enriched-markdown; colors must resolve to concrete hex via varsLight/varsDark + textPresets from
@onyx-ai/shared/native — NativeWind classes don't apply inside the markdown lib), reanimated 4.3.1 (shimmer
today; ChatSurface uses LinearTransition/FadeIn/FadeOut). No Collapsible/Accordion primitive exists —
expand/collapse is net-new (ASK owner: port web UX vs compose reanimated). No chevron-up (rotate
chevron-down); no brain/reasoning icon and no plain circle glyph (web reasoning uses SvgCircle) —
adding an icon is a small owner-port decision. NativeWind spacing is pixel-valued (class number == px). No
hover on RN (drop web isHover branches). reanimated jest gotcha: keep pure logic (grouping / step
derivation / state machine) in reanimated-free modules; import leaf components directly. Streaming re-render
perf: memoize rows, coalesce updates; auto-collapse height animation can jank mid-stream (prefer fixed-height
summary + tap-to-expand). Accessibility: trigger = accessibilityRole="button" + accessibilityState={{expanded}};
collapsed children removed from AX/focus tree.
React.memo) + useCallback renderItem; don't hand every
row fresh object/function identities each tick; coalesce/throttle stream setState (don't setState per token).
— https://reactnative.dev/docs/optimizing-flatlist-configurationExtend exactly two existing seams and add one leaf. The grouping lives inside the already-pure
messageProcessor (emits a new steps: TimelineStep[] on ProcessedMessageState), so the whole
usePacketDisplay full-recompute-per-flush path stays untouched and jest-safe. Feed the existing
AgentTimeline stub real reasoning steps and add one ReasoningStep leaf (streamed StreamingMarkdown body +
tap-to-toggle collapse, leaf-local elapsed timer). The flat {packets, processed} renderer contract is preserved
verbatim — reasoning is timeline-resident, not a registry renderer, so findRenderer/RENDERERS and the 9a
citations path are undisturbed. No web machinery ported (no render-prop contract, no 7-state machine, no pacing).
streamingModels.ts (+4 enum, +4 interfaces), messageProcessor.ts (grouping →
steps/stepByKey), AgentTimeline.tsx (render TimelineStep[], switch(kind)), MessageRow.tsx (pass
steps; refine isLoading); NEW ReasoningStep.tsx, reasoning grouping unit tests.kind + a case (not
findRenderer), i.e. a bounded refactor of AgentTimeline when a real tool needs tint surfaces / per-step
collapse.kind + reducer cases + a leaf + one case; 9a's SourceRow drops
into search/fetch leaves; the heavier machinery (pacing, render-prop RendererResult) is introduced as a
bounded evolution of this seam when a real tool justifies it, not before.A pure turn/tab grouping module (chat/timeline/grouping.ts) runs alongside the existing flat
processPackets (both hosted via useMemo, honoring the refs-lint ban), producing TurnGroup[] of
TransformedStep[]. Each step is resolved by a priority-ordered step registry (mirroring web findRenderer)
to a step renderer that returns a mobile analog of web's RendererResult data contract (NOT JSX) — a plain
object return, simpler than web's render-prop but equally extensible. A single StepContainer (rail + tinted
surface + header + collapse body) owns all chrome; renderers never draw their own wrapper. Reasoning is renderer
#1 (and last-in-chain fallback). messageProcessor stays flat (grouping is a sibling, honoring its header
comment). Adds a lean useTimelineExpansion + lean useTimelineUIState, and a net-new Collapsible primitive +
reasoning icon (both owner-ASK).
chat/timeline/{grouping,stepContract,stepRegistry}.ts,
chat/timeline/renderers/reasoning/ReasoningRenderer.tsx, components/chat/timeline/StepContainer.tsx,
components/chat/timeline/{useTimelineExpansion,useTimelineUIState}.ts, components/ui/collapsible.tsx
(ASK), icons/reasoning-circle.tsx (ASK); MODIFY streamingModels.ts, usePacketDisplay.ts (+turnGroups),
AgentTimeline.tsx, MessageRow.tsx.RendererResult) with
zero edits to grouping/container/collapse/MessageRow — retrofit cost amortized to ~0; but ~2× the LOC of A and
it introduces contract fields (alwaysCollapsible, timelineLayout) that reasoning barely exercises (some
speculative until phase 2), plus 2 owner-gated artifacts add decision latency.model_index tolerated; nesting is the only phase that may extend
groupStepsByTurn.Port web's entire timeline shell 1:1 now — grouping + transformers + usePacedTurnGroups (200ms stagger) +
the full useTimelineUIState (7 states) + useTimelineExpansion + useTimelineHeader + useStreamingDuration
(live "Ns"/"Thought for {duration}") + useTimelineMetrics + the render-prop renderer contract + StepContainer
TimelineRendererComponent + Streaming/Stopped/Completed headers + Done/Stopped terminal step — wiring only
the reasoning renderer body. Keeps mobile's recompute-per-flush model (web's ref-held usePacketProcessor is
illegal here) by making grouping a pure function; usePacedTurnGroups must be restructured off web's
render-time ref reads to satisfy the refs lint. The shell is behaviorally indistinguishable from web with
reasoning-only content, so deferred phases only add renderer bodies.streamingModels, usePacketDisplay, MessageRow, registry).usePacedTurnGroups
(restructured off render-time refs) is the highest-bug-density file with behavioral-divergence risk.sub_turn_index/model_index tolerance built-in
and dormant; but that maximal extensibility is the entire justification for paying the shell cost up front.streamingModels packet types and the same grouping key (${turn_index}-${tab_index ?? 0}, model_index
ignored) — they diverge only on how much shell/contract is built now.usePacketProcessor ref; grouping
is a pure useMemo. C carries the most risk here (pacing hook restructure).AgentTimeline refactor later); B front-loads the data contract + registry (the genuinely
expensive-to-retrofit part) but not pacing/full-state; C front-loads everything (risk: dead code + jank +
porting a moving target).Collapsible primitive +
a reasoning icon decision before UI lands.Approach C — "Faithful Shell First" (Full parity). Selected at GATE 1 (owner, 2026-07-16).
Owner directive (verbatim intent): "I want something that matches web. I am going to build the renderers immediately — current PR goes in [first], then the renderers. I don't want any refactor later. I'm fine with any number of lines — I want everything, whatever way we need it. Just don't drift away from web."
What this means for 9b:
MessageRenderer<T,S> contract + RendererResult /
RenderType (HIGHLIGHT/FULL/COMPACT/INLINE), the pure grouping (packetProcessor → transformers),
usePacedTurnGroups (200ms staggered reveal), the full useTimelineUIState (7 states) + useTimelineExpansion
useTimelineHeader + useStreamingDuration + useTimelineMetrics, StepContainer +
TimelineRendererComponent + the rail/surface/content primitives, the Streaming/Stopped/Completed headers, and
the Done/Stopped terminal step. Adopt web's render-prop renderer contract exactly (not Approach B's
simplified data-object) so each future web renderer ports near-mechanically.isParallel,
STREAMING_PARALLEL, showParallelTabs) and sub_turn_index/model_index tolerance ship with the shell (as
in web) so parallelism / nesting / multi-model are later UI-only follow-ups with no seam change.The one accepted divergence from web (platform-forced, behavior-preserving): web's usePacketProcessor and
usePacedTurnGroups read stateRef.current during render (reset detection / bypass), which mobile's
react-hooks/refs lint forbids. These are restructured to a useMemo-recompute + effect-only-ref-write model
that preserves the same grouping output and the same 200ms reveal timing. This is an implementation-level
necessity, not a look/structure drift — the rendered timeline is web-faithful. Documented in 03-detailed-design.md.