Back to Onyx

Mobile Chat 9b — Agentic Reasoning Timeline · Detailed Design

docs/mobile-chat/9b-timeline/03-detailed-design.md

4.6.0-cloud.030.6 KB
Original Source

Status: active · Task: 9b-timeline · Approach: C — Faithful Shell First

Mobile Chat 9b — Agentic Reasoning Timeline · Detailed Design

Faithful 1:1 port of web's agent timeline shell (web/src/app/app/message/messageComponents/**) to mobile/, wiring only the reasoning renderer. Line references below are to the web source (source of truth). Mobile spacing is pixel-valued (class number = px); web token rem strings are baked to px (rem×16), and web utility classes are translated (web Tailwind step N → mobile N×4 px). No backend/DB change.

Database design

N/A — no persistence change. The timeline is derived entirely from the already-streamed/hydrated Packet[] on each assistant message node. (One in-memory addition: a per-run streamingStartedAt timestamp for the live elapsed timer — see Important Notes §7.)

Class / interface design

1. Packet contracts — mobile/src/chat/streamingModels.ts (modified)

Add the full web PacketType enum values (so the ported grouping engine + packetHelpers + toolDisplay compile with zero future enum change), plus the interfaces the engine and shell dereference. Per-tool obj interfaces beyond these are added by each tool renderer's own phase.

ts
// New PacketType members (mirror web streamingModels.ts exactly; string values = backend wire types):
REASONING_START = "reasoning_start", REASONING_DELTA = "reasoning_delta", REASONING_DONE = "reasoning_done",
TOP_LEVEL_BRANCHING = "top_level_branching",
SEARCH_TOOL_START = "search_tool_start", SEARCH_TOOL_QUERIES_DELTA = "search_tool_queries_delta",
SEARCH_TOOL_FILTER_DELTA = "search_tool_filter_delta",
FETCH_TOOL_START = "open_url_start", OPEN_URL_URLS = "open_url_urls",
PYTHON_TOOL_START = "python_tool_start", PYTHON_TOOL_DELTA = "python_tool_delta",
TOOL_CALL_ARGUMENT_DELTA = "tool_call_argument_delta",
CUSTOM_TOOL_START = "custom_tool_start", CUSTOM_TOOL_ARGS = "custom_tool_args", CUSTOM_TOOL_DELTA = "custom_tool_delta",
FILE_READER_START = "file_reader_start", FILE_READER_RESULT = "file_reader_result",
MEMORY_TOOL_START = "memory_tool_start", MEMORY_TOOL_DELTA = "memory_tool_delta", MEMORY_TOOL_NO_ACCESS = "memory_tool_no_access",
IMAGE_GENERATION_TOOL_START = "image_generation_start", IMAGE_GENERATION_TOOL_DELTA = "image_generation_final",
DEEP_RESEARCH_PLAN_START = "deep_research_plan_start", DEEP_RESEARCH_PLAN_DELTA = "deep_research_plan_delta",
RESEARCH_AGENT_START = "research_agent_start",
INTERMEDIATE_REPORT_START = "intermediate_report_start", INTERMEDIATE_REPORT_DELTA = "intermediate_report_delta",
INTERMEDIATE_REPORT_CITED_DOCS = "intermediate_report_cited_docs",
CODING_AGENT_START = "coding_agent_start", CODING_AGENT_THINKING_DELTA = "coding_agent_thinking_delta",
CODING_AGENT_FINAL = "coding_agent_final", BASH_TOOL_START = "bash_tool_start", BASH_TOOL_DELTA = "bash_tool_delta",

// New obj interfaces the ENGINE/SHELL read now (others deferred to their renderer phase):
interface ReasoningStart  extends BaseObj { type: PacketType.REASONING_START }
interface ReasoningDelta  extends BaseObj { type: PacketType.REASONING_DELTA; reasoning: string }
interface ReasoningDone   extends BaseObj { type: PacketType.REASONING_DONE }
interface TopLevelBranching extends BaseObj { type: PacketType.TOP_LEVEL_BRANCHING; num_parallel_branches: number }
interface ToolCallArgumentDelta extends BaseObj { type: PacketType.TOOL_CALL_ARGUMENT_DELTA; tool_type: string; argument_deltas: Record<string, unknown> }
interface SearchToolStart extends BaseObj { type: PacketType.SEARCH_TOOL_START; is_internet_search?: boolean }
interface CustomToolStart extends BaseObj { type: PacketType.CUSTOM_TOOL_START; tool_name?: string; tool_id?: number | null }
interface ImageGenerationToolDelta extends BaseObj { type: PacketType.IMAGE_GENERATION_TOOL_DELTA; images?: { file_id: string }[] }
// extend existing MessageStart with: pre_answer_processing_seconds?: number | null
export const CODE_INTERPRETER_TOOL_TYPES = { PYTHON: "python" } as const;
// Add all new obj interfaces to the ObjTypes union.

2. Grouping engine — mobile/src/chat/messageProcessor.ts (modified → faithful port of web packetProcessor.ts)

ProcessedMessageState gains web's grouping fields (existing 9a fields kept for CitedSources):

ts
export interface ProcessedMessageState {
  nodeId: number; nextPacketIndex: number;
  // 9a (kept): citations, seenCitationDocIds, citationMap, documentMap, isComplete, stopReason
  // 9b (new, mirroring web ProcessorState packetProcessor.ts:32-69):
  groupedPacketsMap: Map<string, Packet[]>;
  seenGroupKeys: Set<string>;
  groupKeysWithSectionEnd: Set<string>;
  expectedBranches: Map<number, number>;
  toolGroupKeys: Set<string>;
  displayGroupKeys: Set<string>;
  isGeneratingImage: boolean; generatedImageCount: number;
  finalAnswerComing: boolean; stopPacketSeen: boolean;
  toolProcessingDuration: number | undefined;
  toolGroups: GroupedPacket[];
  potentialDisplayGroups: GroupedPacket[];
}
export interface GroupedPacket { turn_index: number; tab_index: number; packets: Packet[] }

New internal functions (ported verbatim, pure): getGroupKey, injectSectionEnd, handleTurnTransition, handleStopPacket (grouping half), handleStreamingStatusPacket, handleToolAfterMessagePacket, buildGroupsFromKeys, hasContentPackets, CONTENT_PACKET_TYPES_SET, FINAL_ANSWER_PACKET_TYPES_SET. processPacket gains the per-packet flow (packetProcessor.ts:323-382). See Important Notes §2 for the exact section_end algorithm.

3. Pure step helpers — mobile/src/chat/timeline/ (new)

  • transformers.tsTransformedStep {key, turnIndex, tabIndex, packets}, TurnGroup {turnIndex, steps, isParallel}, transformPacketGroup, transformPacketGroups, groupStepsByTurn (isParallel = steps.length > 1).
  • packetUtils.tsisToolPacket, isActualToolCallPacket, isDisplayPacket, isSearchToolPacket, isStreamingComplete, isFinalAnswerComing, isFinalAnswerComplete, groupPacketsByTurnIndex, getTextContent, getCitations.
  • packetHelpers.tsCOLLAPSED_STREAMING_PACKET_TYPES, CODING_AGENT_PACKET_TYPES, isReasoningPackets, isSearchToolPackets, isPythonToolPackets, isResearchAgentPackets, isCodingAgentPackets, isDeepResearchPlanPackets, isMemoryToolPackets, stepSupportsCollapsedStreaming, stepHasCollapsedStreamingContent.
  • toolDisplay.tsgetToolKey, parseToolKey, getToolName (pure), hasToolError, isToolComplete (sub_turn_index-aware for research/coding). Icon factory split out to components/chat/timeline/toolIcons.ts (returns a mobile icon component, not JSX from web react-icons).

4. Renderer contract — mobile/src/components/chat/renderers/timelineContract.ts (new)

Ported verbatim from web interfaces.ts (types only; JSX.Element = RN element):

ts
export enum RenderType { HIGHLIGHT="highlight", FULL="full", COMPACT="compact", INLINE="inline" }
export type TimelineLayout = "timeline" | "content";
export type TimelineSurfaceBackground = "tint" | "transparent" | "error";
export interface RendererResult {
  icon: IconFunctionComponent | null;            // mobile icon type (from @/icons/types)
  status: string | React.ReactElement | null;
  content: React.ReactElement;
  supportsCollapsible?: boolean; alwaysCollapsible?: boolean;
  timelineLayout?: TimelineLayout; noPaddingRight?: boolean;
  surfaceBackground?: TimelineSurfaceBackground;
  // NOTE: web's `expandedText?` is DEAD (0 readers) — DROPPED in the port.
}
export type RendererOutput = RendererResult[];
export interface FullChatState {              // mobile subset
  agent: MinimalAgent | null;
  citations?: CitationMap; documentMap?: Map<string, SearchDoc>;
  openSource?: (doc: SearchDoc) => void;      // 9a
}
export type MessageRenderer<T extends Packet, S extends Partial<FullChatState>> = React.ComponentType<{
  packets: T[]; state: S; messageNodeId?: number; hasTimelineThinking?: boolean;
  onComplete: () => void; renderType: RenderType; animate: boolean;
  stopPacketSeen: boolean; stopReason?: StopReason; isLastStep?: boolean;
  children: (result: RendererOutput) => React.ReactElement;   // isHover DROPPED (no hover on RN)
}>;
export interface TimelineRendererResult extends RendererResult {
  isExpanded: boolean; onToggle: () => void; renderType: RenderType;
  isLastStep: boolean; timelineLayout: TimelineLayout;
}

5. Timeline state hooks — signatures (ported 1:1 from web; see Important Notes §1 for the two restructured ones)

ts
usePacketProcessor(packets: Packet[], nodeId: number): UsePacketProcessorResult   // RESTRUCTURED
usePacedTurnGroups(toolTurnGroups, displayGroups, stopPacketSeen, nodeId, finalAnswerComing):
   { pacedTurnGroups: TurnGroup[]; pacedDisplayGroups: GroupedPacket[]; pacedFinalAnswerComing: boolean } // RESTRUCTURED
useTimelineUIState(input: TimelineUIStateInput): TimelineUIStateResult             // pure, 1:1
useTimelineExpansion(stopPacketSeen, lastTurnGroup, hasDisplayContent): TimelineExpansionState  // 1:1
useTimelineHeader(turnGroups, stopReason?, isGeneratingImage?): TimelineHeaderResult // 1:1 (see §6)
useStreamingDuration(isStreaming, startTime?, backendDuration?): number             // 1:1 (RAF→interval ok)
useTimelineMetrics(turnGroups, userStopped): TimelineMetrics                         // 1:1
useTimelineStepState(turnGroups): MemoryStepState                                    // 1:1 (dormant/minimal)

TimelineUIState (7 states), TimelineUIStateInput/Result, TimelineExpansionState, TimelineMetrics — exact shapes per 01-research.md and the deepread extract (.context/pr9b-deepread/timeline-hooks-state.md).

New files

FileResponsibility
mobile/src/chat/timeline/transformers.tsGroupedPacket→TransformedStep→TurnGroup; parallel detection
mobile/src/chat/timeline/packetUtils.tspacket categorizers (tool/display/search/text/citations)
mobile/src/chat/timeline/packetHelpers.tsper-family predicates + collapsed-streaming sets
mobile/src/chat/timeline/toolDisplay.tstool key parse, name, completion, error (pure)
mobile/src/chat/timeline/reasoningState.tsextractFirstParagraph + constructCurrentReasoningState (pure)
mobile/src/components/chat/renderers/timelineContract.tsRenderType/RendererResult/MessageRenderer/FullChatState
mobile/src/components/chat/renderers/findRenderer.tspriority-ordered dispatch (reasoning last)
mobile/src/components/chat/renderers/RendererComponent.tsxfinal-answer dispatch at FULL (+ mixed-content stub)
mobile/src/components/chat/renderers/ReasoningRenderer.tsxthe one wired step renderer
mobile/src/hooks/timeline/usePacketProcessor.tshost reducer + derive turn groups (restructured)
mobile/src/hooks/timeline/usePacedTurnGroups.ts200ms staggered reveal + answer gating (restructured)
mobile/src/hooks/timeline/useTimelineUIState.ts7-state machine + show/style booleans
mobile/src/hooks/timeline/useTimelineExpansion.tscollapse state + auto-collapse
mobile/src/hooks/timeline/useTimelineHeader.tsheader text map
mobile/src/hooks/timeline/useStreamingDuration.tslive elapsed timer (frozen by backend duration)
mobile/src/hooks/timeline/useTimelineMetrics.tsstep count + last-step flags
mobile/src/hooks/timeline/useTimelineStepState.tsmemory-only extraction (dormant)
mobile/src/components/chat/timeline/primitives/timelineTokens.tspx token constants (rail 36, icon 12, …)
mobile/src/components/chat/timeline/primitives/TimelineRoot.tsxouter column
mobile/src/components/chat/timeline/primitives/TimelineHeaderRow.tsxrail cell (avatar) + header slot
mobile/src/components/chat/timeline/primitives/TimelineRow.tsxicon column + content row
mobile/src/components/chat/timeline/primitives/TimelineIconColumn.tsxconnector rail + node icon
mobile/src/components/chat/timeline/primitives/TimelineSurface.tsxtint/error surface + rounding
mobile/src/components/chat/timeline/primitives/TimelineStepContent.tsxheader row + collapse control + body
mobile/src/components/chat/timeline/StepContainer.tsxcomposes the primitives into a step frame
mobile/src/components/chat/timeline/TimelineRendererComponent.tsxper-step expand state + renderType derive
mobile/src/components/chat/timeline/TimelineStep.tsxstep composer (children render-prop → StepContainer)
mobile/src/components/chat/timeline/ExpandedTimelineContent.tsxmaps TurnGroup[]→steps + Done/Stopped
mobile/src/components/chat/timeline/CollapsedStreamingContent.tsxstreaming live preview of last step
mobile/src/components/chat/timeline/ParallelTimelineTabs.tsxdormant stub (linearizes for 9b)
mobile/src/components/chat/timeline/headers/StreamingHeader.tsxshimmer text + live seconds + fold
mobile/src/components/chat/timeline/headers/CompletedHeader.tsx"Thought for X · N steps" fold
mobile/src/components/chat/timeline/headers/StoppedHeader.tsx"Interrupted Thinking" + N steps
mobile/src/components/chat/timeline/headers/ParallelStreamingHeader.tsxdormant stub
mobile/src/components/chat/timeline/toolIcons.tspacket-type → mobile icon map (for headers/tabs)
mobile/src/components/chat/timeline/ReasoningTextWindow.tsxmaxHeight markdown window (ExpandableTextDisplay analog)
mobile/src/icons/{circle,fold,expand,check-circle}.tsxnew icons (ASK owner) — see §6
mobile/src/chat/timeline/__tests__/*.test.tsgrouping / section-end / transformers / pacing / uiState / reasoningState

Modified: mobile/src/chat/streamingModels.ts (§1), mobile/src/chat/messageProcessor.ts (§2), mobile/src/components/chat/renderers/registry.ts (→ re-export findRenderer), MessageTextRenderer.tsx (migrate to render-prop contract), mobile/src/hooks/usePacketDisplay.ts (fold into the new hooks / expose processed for CitedSources), mobile/src/components/chat/MessageRow.tsx (AssistantMessage → AgentMessage analog), mobile/src/components/chat/AgentTimeline.tsx (stub → full shell), mobile/src/components/chat/StreamingMarkdown.tsx (+ optional muted variant), and the PR-3 stream controller/store (capture streamingStartedAt, §7).

File structure (tree)

mobile/src/
├── chat/
│   ├── streamingModels.ts                 (modified: full PacketType enum + reasoning/branch/tool-arg objs)
│   ├── messageProcessor.ts                (modified: + web packetProcessor grouping/section-end/finalAnswer)
│   └── timeline/                          (new — pure, reanimated-free, unit-tested)
│       ├── transformers.ts · packetUtils.ts · packetHelpers.ts · toolDisplay.ts · reasoningState.ts
│       └── __tests__/
├── hooks/
│   ├── usePacketDisplay.ts                (modified: expose processed for CitedSources)
│   └── timeline/                          (new)
│       ├── usePacketProcessor.ts (restructured) · usePacedTurnGroups.ts (restructured)
│       ├── useTimelineUIState.ts · useTimelineExpansion.ts · useTimelineHeader.ts
│       └── useStreamingDuration.ts · useTimelineMetrics.ts · useTimelineStepState.ts
├── components/chat/
│   ├── MessageRow.tsx                     (modified: AssistantMessage → AgentMessage analog)
│   ├── AgentTimeline.tsx                  (modified: stub → full shell)
│   ├── StreamingMarkdown.tsx              (modified: + muted variant)
│   ├── renderers/
│   │   ├── registry.ts                    (modified: re-export findRenderer)
│   │   ├── timelineContract.ts · findRenderer.ts · RendererComponent.tsx · ReasoningRenderer.tsx  (new)
│   │   └── MessageTextRenderer.tsx        (modified: → render-prop contract)
│   └── timeline/                          (new)
│       ├── StepContainer.tsx · TimelineRendererComponent.tsx · TimelineStep.tsx
│       ├── ExpandedTimelineContent.tsx · CollapsedStreamingContent.tsx
│       ├── ParallelTimelineTabs.tsx (dormant) · toolIcons.ts · ReasoningTextWindow.tsx
│       ├── primitives/ (timelineTokens, TimelineRoot/HeaderRow/Row/IconColumn/Surface/StepContent)
│       └── headers/ (StreamingHeader, CompletedHeader, StoppedHeader, ParallelStreamingHeader[dormant])
└── icons/  circle.tsx · fold.tsx · expand.tsx · check-circle.tsx   (new; stop-circle already exists from PR3)

What each file will contain

  • messageProcessor.ts — the pure reducer: processPackets(state, rawPackets) loops from nextPacketIndex, routes each packet through handleTurnTransition (synthesizes section_end into prior groups on a new turn_index), grouping/categorization, citations/docs (9a), handleStreamingStatusPacket (finalAnswerComing + pre_answer_processing_seconds), handleStopPacket (synthesizes section_end into all open groups), then rebuilds toolGroups/potentialDisplayGroups via buildGroupsFromKeys (spreads packets → new array; filters hasContentPackets; sorts by turn then tab).
  • usePacketProcessor.tsconst state = useMemo(() => processPackets(createInitialState(nodeId), packets), [nodeId, packets]) (lint-safe full recompute), then toolTurnGroups = useMemo(groupStepsByTurn(transformPacketGroups(state.toolGroups))), displayGroups gated on finalAnswerComing || toolGroups.length===0, renderComplete/forceShowAnswer as useState, isComplete = stopPacketSeen && renderComplete. Returns UsePacketProcessorResult.
  • usePacedTurnGroups.tsuseState(revealedStepKeys: Set) + useState(toolPacingComplete) written by an effect; timer handle in a ref (effect/cleanup only, never read in render). Effect: bypass if stopPacketSeen && nothing revealed && groups exist; first step immediate, rest queued 200 ms apart (PACING_DELAY_MS=200); stop flushes all. Memos build pacedTurnGroups (filter revealed), pacedDisplayGroups (withheld until pacing complete), pacedFinalAnswerComing. Web's prevPacedRef referential-stabilization is dropped.
  • useTimelineUIState.ts — pure useMemo: the 6-branch precedence (EMPTY → DISPLAY_CONTENT_ONLY → STREAMING_PARALLEL/SEQUENTIAL → STOPPED → COMPLETED_EXPANDED → COMPLETED_COLLAPSED) + showTintedBackground, showRoundedBottom, showDoneStep, showStoppedStep, hasDoneIndicator, showParallelTabs, showCollapsedCompact, showCollapsedParallel, isStreaming, isCompleted, isActivelyExecuting.
  • useTimelineExpansion.tsisExpanded=useState(false), userHasToggled=useRef(false) (read only in effect), auto-collapse effect on stopPacketSeen||hasDisplayContent, parallel-tab sync effect.
  • useTimelineHeader.ts — the exact packet-type→text map (§6). 9b caveat: the SEARCH sub-labels ("Reading" vs "Searching the web", which need constructCurrentSearchState) degrade to a generic label until the search phase; reasoning → "Thinking", default → "Thinking…". (Documented divergence §8.)
  • useStreamingDuration.tselapsedSeconds via requestAnimationFrame (or setInterval(1000)), updates only when the integer second changes; returns backendDuration if defined (freezes). Reset only when no startTime.
  • AgentTimeline.tsx — runs useTimelineExpansion + useTimelineUIState + useTimelineHeader + useTimelineMetrics + useStreamingDuration; TimelineRoot > TimelineHeaderRow(avatar 24 in 36 rail) > header; header switch by uiState; body = CollapsedStreamingContent (streaming) or ExpandedTimelineContent (expanded). Memoized.
  • StepContainer.tsx + primitivesTimelineRow(TimelineIconColumn rail + content); TimelineSurface (tint/error bg, rounded bottom if last); TimelineStepContent (header row height 32 with status Text + collapse control gated by supportsCollapsible, body pl-1 pb-1, right gutter 34 unless noPaddingRight). Rail geometry: 1px line, 8px top connector (hidden if first), 20px node wrapper, 12px icon, flex-fill (hidden if last). All hover branches dropped (resting colors: bg-border-01, stroke-text-02, bg-background-tint-00).
  • TimelineRendererComponent.tsxisExpanded=useState(defaultExpanded), renderType = override ?? (isExpanded?FULL:COMPACT), findRenderer(packets) → render-prop, enhanceResult attaches {isExpanded,onToggle,renderType,isLastStep,timelineLayout}; children(results.map(enhanceResult)).
  • ReasoningRenderer.tsxconstructCurrentReasoningState(packets) (hasStart/hasEnd/content=join deltas), extractFirstParagraph(content) (markdown-heading → step title, ≤60 chars), 500 ms min-thinking gate (useState(start) + timer ref in effect; animate gates the floor), then children([{ icon: SvgCircle, status: title || "Thinking", content: <ReasoningTextWindow .../>, noPaddingRight:true }]). As built: supportsCollapsible is left unset (owner call, strict web parity) — web's ReasoningRenderer doesn't set it, StepContainer never gates its body on isExpanded, and web's hideHeader = isSingleStep && !supportsCollapsible intentionally drops the step header on a reasoning-only turn.
  • ReasoningTextWindow.tsx — 192px-tall (8×24) ScrollView wrapping StreamingMarkdown content={displayContent} isStreaming={!hasEnd} variant="muted"; auto-scrolls to the newest lines while streaming (onContentSizeChangescrollToEnd), scrollEnabled only once closed, and reveals a "View full text" button when the content overflows. A clipping View cannot work here — see §8(c).
  • ReasoningTextSheet.tsx — the ExpandableTextDisplay modal analog, on 9a's CitedSourcesSheet shape (RN Modal + ScrollView): full reasoning (heading included), byte-size subtitle, line count, Copy (expo-clipboard). Mounted only while open. Web's Download is dropped.
  • findRenderer.ts — the full priority chain (chat → deep-research → research-agent → coding-agent → web-search → internal-search → image → python → file-reader → custom-tool → fetch → memory → reasoning last). For 9b only chat→MessageTextRenderer and reasoning→ReasoningRenderer are wired; the other 11 predicates are present and return their (not-yet-registered) renderer as null, each tagged // PR 9x: <tool> so a follow-up is a one-line wire-up.
  • RendererComponent.tsx — final-answer path: findRenderer({packets}) at RenderType.FULL; mixed chat+image content handler is stubbed (image = 9e). Memoized on packet identity + stop flags.
  • MessageRow.tsx (AssistantMessage) — the AgentMessage analog: usePacketProcessor + usePacedTurnGroups, <AgentTimeline turnGroups=pacedTurnGroups chatState stopPacketSeen finalAnswerComing hasDisplayContent toolProcessingDuration/> above, pacedDisplayGroups.map(<RendererComponent renderType=FULL/>) below, <CitedSources/> (9a) last.

Integration points

  • mobile/src/chat/messageProcessor.ts (9a) — extended in place; existing citation/document/isComplete fields preserved so CitedSources.tsx and usePacketDisplay keep working.
  • mobile/src/components/chat/renderers/registry.ts + MessageTextRenderer.tsx (PR 3/9a) — migrated to the render-prop MessageRenderer contract; the final answer routes through the new RendererComponent. Inline citation links (9a) and streamed markdown render identically.
  • mobile/src/components/chat/AgentTimeline.tsx (PR 5 stub) — steps: TimelineStepData[] seam replaced by turnGroups: TurnGroup[]; the existing rail geometry + reanimated shimmer are reused/expanded.
  • mobile/src/components/chat/MessageRow.tsxAssistantMessage becomes the composition root; hasContent gating updated (isLoading now driven by uiState===EMPTY).
  • mobile/src/components/chat/StreamingMarkdown.tsx (PR 3/9a) — gains a muted/compact variant so reasoning bodies render text-03 with tight margins (colors still resolved to hex via varsLight/varsDark).
  • PR-3 stream controller / chatSessionStore — records streamingStartedAt per assistant node (Important Notes §7) for the live timer.
  • mobile/src/components/avatars/AgentAvatar (PR 5) — reused at size={24} in the rail.
  • @onyx-ai/shared/nativevarsLight/varsDark + textPresets for markdown colors (unchanged pattern).
  • Backend / DB / APIuntouched. Reasoning packets already stream from backend/onyx/chat/llm_step.py.

Important notes before implementation

  1. The two ref-during-render restructures (the only accepted divergence). usePacketProcessor (web mutates stateRef.current during render) → mobile uses a useMemo full recompute per flush (9a's proven pattern; O(n)/flush, fine at chat scale). usePacedTurnGroups (web reads pacing refs during render for shouldBypassPacing + memos) → hoist revealedStepKeys/toolPacingComplete into useState written by the effect; compute shouldBypassPacing from props synchronously; timer handle stays in a ref (effect-only). Both are behavior-preserving. Verify the first-packet render + 200 ms cadence feel identical on device (a reducer-in-effect can add a one-frame delay — §parity-risk in the deepread).
  2. section_end synthesis is the correctness core — port it exactly. injectSectionEnd(state, key): idempotent (skip if already ended); parse {turn,tab} from key; push a synthetic {placement:{turn,tab}, obj:{type:SECTION_END}} (NO sub_turn_index — so research/coding parent-completion works) into the group; mark the key done. Trigger 1: a real SECTION_END/ERROR packet. Trigger 2 (turn transition): the first packet of a new turn_index injects into every prior seenGroupKey not yet ended. Trigger 3 (stop): the first stop injects into all open groups. A new tab_index within a seen turn does not trigger 2. (packetProcessor.ts:116-133, 190-208, 270-287.)
  3. Full PacketType enum now, per-tool interfaces later. Add every enum value (so the engine/helpers/Sets compile with zero future enum edits) but only the obj interfaces the engine/shell dereference (§1). Each tool renderer phase adds its own obj interface + wires its findRenderer predicate — a one-line change, no engine or enum churn. This is the "zero refactor later" guarantee.
  4. react-hooks/refs + reanimated jest gotcha. Keep ALL pure logic (grouping, transformers, packetUtils, packetHelpers, toolDisplay, reasoningState, the state-machine math) in reanimated-free modules under chat/timeline/ and import leaf components directly in tests. Refs are fine when written/read only in effects/callbacks (timer handles, userHasToggled) — never during render.
  5. Icons + primitives = owner-ASK before UI lands (PR 9b.4). New icons: circle (reasoning node), fold / expand (collapse control — no chevron-up, rotate chevron-down or add glyphs), check-circle (Done). stop-circle already exists (PR 3). New primitive: a tertiary icon Button (confirm the existing mobile Button covers it, else ASK per the web-parity principle). x-octagon (error surface) — confirm/port.
  6. Header text map + tool names (useTimelineHeader / getToolName). Port the full map; reasoning → "Thinking", stop_reason===USER_CANCELLED → StoppedHeader. Tool sub-labels needing constructCurrentSearchState (search "Reading" vs "Searching the web") degrade to a generic label until the search phase (§8). getToolName ("Web Search"/"Code Interpreter"/…) is pure and ported so a rendererless tool step still gets a real header.
  7. Live timer needs a per-message streamingStartedAt. Web reads a useStreamingStartTime store. Mobile: the PR-3 stream controller stamps streamingStartedAt = Date.now() on the assistant node/run at stream start (send + resume); AgentTimeline passes it to useStreamingDuration. Without it the elapsed label never shows. toolProcessingDuration (message_start.pre_answer_processing_seconds) freezes the timer when present.
  8. Documented, intentional divergences from web (per the WEB-PARITY PRINCIPLE — the "as-built" note must list these): (a) the two restructured hooks (§1) — behavior-preserving; (b) shimmer = reanimated opacity pulse, not web's background-clip:text gradient (no RN equivalent) — reuse the existing 9a ThinkingLabel; (c) ReasoningTextWindow = fixed-height ScrollView (auto-scrolls to follow the stream, user-scrollable once closed), not web's pixel-perfect translateY auto-scroll; a clipping View is not an option — the markdown is a native measure leaf and both platforms clamp its height to Yoga's AtMost constraint, so it can never overflow and no justifyContent trick shifts it. ReasoningTextSheet ports the ExpandableTextDisplay modal (full text + size + line count + Copy); web's Download is dropped (no mobile analog); (d) ParallelTimelineTabs/ParallelStreamingHeader dormant — parallel turns linearized into a sequential list for 9b (no @opal pill Tabs on mobile); (e) SEARCH header sub-labels generic until the search phase; (f) entrance/transition-colors CSS animations optional (reanimated FadeIn/Layout or dropped); (g) memory tooltip/modal dropped (memory renderer is a later phase); (h) expandedText dead field dropped; (i) no hover anywhere (resting colors). None change the timeline's look/structure for the reasoning path.
  9. Render-prop discipline. A renderer must call children(results) — never return its own View tree directly — or StepContainer can't wrap it. RenderType policy: final-answer path = always FULL; timeline steps = override ?? (isExpanded?FULL:COMPACT). Keep BOTH entry points.
  10. Testing (unit-first — the pure core carries the risk). Jest over chat/timeline/** + hooks/timeline/** leaf logic: grouping key + the three section_end triggers; groupStepsByTurn parallel detection + turn/tab sort; transformPacketGroups; usePacedTurnGroups (first-immediate, 200 ms stagger with fake timers, stop-flush, history-bypass); useTimelineUIState all 7 states + booleans; useTimelineExpansion auto-collapse + userHasToggled; useStreamingDuration tick + freeze; reasoningState heading extraction + delta accumulation; ReasoningRenderer 500 ms gate (fake timers). Component smoke test: a mocked reasoning packet stream renders a "Thinking" step that collapses to "Thought for Xs". HARD device gate (owner): on a dev build, a reasoning model shows the streaming shimmer + timer, auto-collapse on answer, tap-to-expand, and hydrated (history) render — the agent can't run a device build.