Back to Onyx

Mobile Chat 9b — Agentic Reasoning Timeline · PR Roadmap

docs/mobile-chat/9b-timeline/05-pr-roadmap.md

4.6.0-cloud.023.8 KB
Original Source

Status: active · Task: 9b-timeline · Source plan: 04-implementation-plan.md

Mobile Chat 9b — Agentic Reasoning Timeline · PR Roadmap

A faithful port of web's agent timeline shell (Approach C) is honestly a multi-PR phase (~3.7–4.3k LOC). It's sliced into 7 layered, independently-mergeable PRs. Pre-production → no feature flag: the layers land "dark" (compiled + unit-tested, not yet on screen) until the composition PR (9b.7) lights the timeline up; the one mid-sequence behavior change is 9b.4 (the final-answer path migrates to the shared contract, behavior-identical). Every PR leaves main building, tsc/lint/jest green, and the app usable.

Overview

PRTitleEst. LOCDepends onKey deliverable
9b.1feat(mobile): timeline grouping engine + packet contracts~700Pure grouping reducer (turn/tab + section_end synthesis) + full PacketType enum + pure step helpers; unit-tested. Dark.
9b.2feat(mobile): timeline processor + pacing hooks~5909b.1usePacketProcessor (lint-safe recompute) + usePacedTurnGroups (200ms reveal), the two restructured hooks; unit-tested. Dark.
9b.3feat(mobile): timeline state hooks~5509b.1useTimelineUIState/Expansion/Header/StreamingDuration/Metrics/StepState; unit-tested. Dark.
9b.4feat(mobile): render-prop renderer contract + answer migration~5109b.1RendererResult/MessageRenderer contract + findRenderer + RendererComponent; migrate final-answer MessageTextRenderer to it. Live (answer path).
9b.5feat(mobile): timeline primitives + StepContainer~6509b.4Rail/surface/content primitives + StepContainer + TimelineRendererComponent + new icons/Button (ASK) + StreamingMarkdown muted. Dark.
9b.6feat(mobile): reasoning renderer~4509b.4, 9b.5ReasoningRenderer + reasoningState + ReasoningTextWindow; registered in findRenderer; unit-tested. Dark.
9b.7feat(mobile): agent timeline composition~7009b.2, 9b.3, 9b.5, 9b.6AgentTimeline shell + headers + Expanded/Collapsed content + MessageRow wiring + streamingStartedAt. Lights up the timeline. Device gate.

Sequence

9b.1 engine+contracts+helpers ─┬─► 9b.2 processor+pacing hooks ──┐
   (dark, unit-tested)         ├─► 9b.3 state hooks ─────────────┤
                               └─► 9b.4 contract + answer path ──┼─► 9b.5 primitives+StepContainer ─► 9b.6 reasoning ─┐
                                        (live: answer)           │        (dark)                        (dark)        │
                                                                 └──────────────────────────────────────────────────►┴─► 9b.7 composition
                                                                                                                          (LIGHTS UP + device gate)

9b.1 is the root. 9b.2 / 9b.3 / 9b.4 fan out from it (parallelizable). 9b.5→9b.6 chain on the contract. 9b.7 depends on the hooks (9b.2/9b.3), the UI primitives (9b.5), and the reasoning renderer (9b.6) — it's the only PR that changes the timeline on screen.


PR 9b.1 — Grouping engine + packet contracts + pure helpers

  • Goal: The pure, unit-tested data core — packets grouped into timeline steps exactly as web does.
  • Scope (in): full web PacketType enum + the engine/shell obj interfaces (§1 of 03); extend messageProcessor into a faithful packetProcessor port (grouping map, section_end synthesis on turn-transition + stop, tool/display categorization, finalAnswerComing, buildGroupsFromKeys, hasContentPackets); the pure step helpers.
  • Out of scope: all hooks, all UI, the reasoning renderer. Nothing renders differently.
  • Files:
    FileNew/ModifiedSlice
    mobile/src/chat/streamingModels.tsmodifiedenum values + reasoning/branch/tool-arg interfaces + ObjTypes
    mobile/src/chat/messageProcessor.tsmodifiedgrouping fields + section_end synthesis + categorization (keep 9a citations/docs)
    mobile/src/chat/timeline/transformers.tsnewTransformedStep/TurnGroup/groupStepsByTurn
    mobile/src/chat/timeline/packetUtils.tsnewisToolPacket/isDisplayPacket/getTextContent/…
    mobile/src/chat/timeline/packetHelpers.tsnewper-family predicates + collapsed-streaming sets
    mobile/src/chat/timeline/toolDisplay.tsnewparseToolKey/getToolName/isToolComplete/hasToolError
    mobile/src/chat/timeline/__tests__/*newgrouping key, 3 section_end triggers, categorization, parallel detection, model_index tolerance, history-reset
  • Est. size: ~700 LOC (at the band; the engine + its helpers + tests are one coherent, tightly-coupled unit — splitting the helpers out would ship an engine that can't compile).
  • Depends on:
  • Feature-flag state: N/A (pre-production; additive — dark).
  • Tests on merge: Jest unit — grouping + section_end synthesis + transformers fully covered; messageProcessor still passes its 9a citation/document tests.
  • Drift checkpoint: Confirm the backend reasoning packet shapes (reasoning_start/delta/done) and Placement fields are still as 03/deepread recorded; re-verify web/.../packetProcessor.ts hasn't changed since extraction.

PR 9b.2 — Processor + pacing hooks

  • Goal: Host the reducer and drive the 200 ms staggered reveal — the two lint-forced restructures.
  • Scope (in): usePacketProcessor (useMemo full recompute + derive toolTurnGroups/displayGroups/ isComplete); usePacedTurnGroups (useState+effect reveal, answer gating, history bypass; drop prevPacedRef).
  • Out of scope: the state/derive hooks (9b.3), all UI.
  • Files:
    FileNew/ModifiedSlice
    mobile/src/hooks/timeline/usePacketProcessor.tsnewrecompute + group derivation + renderComplete gate
    mobile/src/hooks/timeline/usePacedTurnGroups.tsnew200ms reveal + gating (restructured)
    mobile/src/hooks/timeline/__tests__/*newfake-timer pacing (first-immediate/200ms/stop-flush/bypass) + processor derivation
  • Est. size: ~590 LOC.
  • Depends on: 9b.1.
  • Feature-flag state: N/A (dark).
  • Tests on merge: Jest unit with fake timers — reveal cadence, stop-flush, history bypass, answer withheld until pacing completes; processor recompute correctness.
  • Drift checkpoint: Confirm on-device the recompute + effect-driven reveal feel identical to web (the one-frame risk called out in 03 §1); if the first-packet render flashes, revisit before 9b.7 wires it.

PR 9b.3 — Timeline state hooks

  • Goal: The pure UI-state derivation — 7-state machine, expansion, header text, live timer, metrics.
  • Scope (in): useTimelineUIState (7 states + booleans), useTimelineExpansion (auto-collapse + userHasToggled), useTimelineHeader (text map; search sub-labels generic for now), useStreamingDuration (live timer + backend freeze), useTimelineMetrics, useTimelineStepState (memory, dormant).
  • Out of scope: all UI; the search-specific header sub-labels (search phase).
  • Files:
    FileNew/ModifiedSlice
    mobile/src/hooks/timeline/useTimelineUIState.tsnewstate machine + show/style booleans
    mobile/src/hooks/timeline/useTimelineExpansion.tsnewcollapse + auto-collapse
    mobile/src/hooks/timeline/useTimelineHeader.tsnewheader text map
    mobile/src/hooks/timeline/useStreamingDuration.tsnewlive elapsed timer
    mobile/src/hooks/timeline/useTimelineMetrics.tsnewstep count + last-step flags
    mobile/src/hooks/timeline/useTimelineStepState.tsnewmemory extraction (dormant)
    mobile/src/hooks/timeline/__tests__/*new7 states + booleans, auto-collapse + toggle suppression, timer tick + freeze
  • Est. size: ~550 LOC.
  • Depends on: 9b.1 (types + predicates).
  • Feature-flag state: N/A (dark).
  • Tests on merge: Jest unit — every TimelineUIState branch + derived boolean; expansion auto-collapse and userHasToggled; duration tick/freeze.
  • Drift checkpoint: none beyond 9b.1's.

PR 9b.4 — Render-prop renderer contract + final-answer migration

  • Goal: Adopt web's render-prop contract and route the final answer through it — the "no refactor later" move.
  • Scope (in): timelineContract.ts (RenderType/RendererResult/MessageRenderer/FullChatState/TimelineRendererResult); findRenderer.ts (full 13-slot priority chain — chat wired, reasoning slot present-but-null, others tagged // PR 9x); RendererComponent.tsx (final-answer at FULL, mixed chat+image stubbed = 9e); migrate MessageTextRenderer to the contract; re-export from registry.ts; point MessageRow's answer render at RendererComponent (timeline still stub).
  • Out of scope: timeline UI, reasoning renderer.
  • Files:
    FileNew/ModifiedSlice
    mobile/src/components/chat/renderers/timelineContract.tsnewthe contract types
    mobile/src/components/chat/renderers/findRenderer.tsnewpriority dispatch
    mobile/src/components/chat/renderers/RendererComponent.tsxnewfinal-answer dispatch
    mobile/src/components/chat/renderers/MessageTextRenderer.tsxmodified→ render-prop contract
    mobile/src/components/chat/renderers/registry.tsmodifiedre-export findRenderer
    mobile/src/components/chat/MessageRow.tsxmodifiedanswer → RendererComponent (timeline still stub)
    mobile/src/hooks/usePacketDisplay.tsmodifiedkeep exposing processed for CitedSources
    __tests__/*newfindRenderer dispatch order, MessageTextRenderer render-prop, RendererComponent
  • Est. size: ~510 LOC.
  • Depends on: 9b.1.
  • Feature-flag state: N/A — live behavior change (answer path). Must render identically.
  • Tests on merge: Jest — dispatch order (chat first, reasoning last); MessageTextRenderer emits children([result]); RendererComponent renders answer. Manual: confirm the streamed markdown answer + 9a inline citations + Sources bar are unchanged.
  • Drift checkpoint: This touches shipped PR-3/9a code — before starting, re-confirm the 9a citation-link + CitedSources behavior so the migration preserves it exactly.

PR 9b.5 — Timeline primitives + StepContainer

  • Goal: The visual step frame — rail, connector, surface, header, collapse — plus the new icons/primitives.
  • Scope (in): primitives/* (timelineTokens px constants, TimelineRoot/HeaderRow/Row/IconColumn/ Surface/StepContent, hover dropped); StepContainer; TimelineRendererComponent (per-step expand + renderType derivation); new icons circle/fold/expand/check-circle (owner-ASK); confirm/port a tertiary icon Button (owner-ASK); StreamingMarkdown muted variant.
  • Out of scope: the reasoning renderer, AgentTimeline composition, headers.
  • Files:
    FileNew/ModifiedSlice
    mobile/src/components/chat/timeline/primitives/*newtokens + 6 rail/surface/content primitives
    mobile/src/components/chat/timeline/StepContainer.tsxnewcompose primitives into a step frame
    mobile/src/components/chat/timeline/TimelineRendererComponent.tsxnewper-step expand + renderType
    mobile/src/icons/{circle,fold,expand,check-circle}.tsxnewASK owner
    mobile/src/components/chat/StreamingMarkdown.tsxmodifiedmuted/compact variant
    __tests__/*newStepContainer (collapse gating, last-step connector, error surface), renderType derivation
  • Est. size: ~650 LOC.
  • Depends on: 9b.4 (contract).
  • Feature-flag state: N/A (dark — not yet mounted).
  • Tests on merge: RN Testing Library — StepContainer renders header/body/collapse per renderType; icon rail geometry; error surface.
  • Drift checkpoint: Owner-ASK gate — resolve the new-icon ports + tertiary Button before coding UI (per the web-parity principle). Re-confirm the token px values against 03 if web tokens changed.

PR 9b.6 — Reasoning renderer

  • Goal: The one wired step renderer — streamed reasoning with heading, 500 ms min-thinking, markdown body.
  • Scope (in): reasoningState.ts (pure extractFirstParagraph + constructCurrentReasoningState); ReasoningRenderer.tsx (render-prop; 500 ms gate; icon circle; noPaddingRight; no supportsCollapsible — owner call, strict web parity); ReasoningTextWindow.tsx (fixed-height auto-following ScrollView); ReasoningTextSheet.tsx (full-text + Copy, via expo-clipboard); register reasoning in findRenderer, with the 11 unwired tool predicates restored ahead of it so the last slot can't claim every closed tool group.
  • Out of scope: AgentTimeline composition (9b.7); web's Download button (no mobile analog).
  • Files:
    FileNew/ModifiedSlice
    mobile/src/chat/timeline/reasoningState.tsnewheading extraction + delta accumulation (pure)
    mobile/src/components/chat/renderers/ReasoningRenderer.tsxnewthe render-prop renderer + 500ms gate
    mobile/src/components/chat/timeline/ReasoningTextWindow.tsxnewmaxHeight markdown window
    mobile/src/components/chat/renderers/findRenderer.tsmodifiedwire the reasoning slot
    __tests__/*newheading extraction rules, delta accumulation, 500ms gate (fake timers), empty/pre-start branch
  • Est. size: ~450 LOC.
  • Depends on: 9b.4 (contract), 9b.5 (circle icon, StreamingMarkdown muted, StepContainer for the smoke test).
  • Feature-flag state: N/A (dark — reachable only once 9b.7 mounts the timeline).
  • Tests on merge: Jest — reasoningState heading + accumulation; ReasoningRenderer 500 ms gate + branches; a StepContainer-wrapped smoke render.
  • Drift checkpoint: Confirm react-native-streamdown renders the muted variant acceptably (color/margins) — device-adjacent; the owner's 9b.7 device gate covers it.

PR 9b.7 — Agent timeline composition (lights up)

  • Goal: Assemble the shell and render the timeline on screen — the walking skeleton completes.
  • Scope (in): rewrite AgentTimeline into the shell (runs the state hooks + header switch + body); ExpandedTimelineContent + CollapsedStreamingContent + TimelineStep; headers (StreamingHeader/CompletedHeader/StoppedHeader; ParallelStreamingHeader dormant stub); ParallelTimelineTabs dormant (linearize) + toolIcons; Done/Stopped terminal step; wire MessageRow.AssistantMessage into the AgentMessage analog (usePacketProcessor + usePacedTurnGroupsAgentTimeline above + RendererComponent answer below + CitedSources); capture streamingStartedAt in the PR-3 stream controller/store.
  • Out of scope: any tool renderer (follow-up phases); real parallel-tab tabs.
  • Files:
    FileNew/ModifiedSlice
    mobile/src/components/chat/AgentTimeline.tsxmodifiedstub → full shell
    mobile/src/components/chat/timeline/ExpandedTimelineContent.tsxnewTurnGroup[]→steps + Done/Stopped
    mobile/src/components/chat/timeline/CollapsedStreamingContent.tsxnewstreaming live preview
    mobile/src/components/chat/timeline/TimelineStep.tsxnewstep composer (children → StepContainer)
    mobile/src/components/chat/timeline/headers/*newStreaming/Completed/Stopped (+Parallel dormant)
    mobile/src/components/chat/timeline/ParallelTimelineTabs.tsxnewdormant stub (linearize)
    mobile/src/components/chat/timeline/toolIcons.tsnewpacket-type → icon map
    mobile/src/components/chat/MessageRow.tsxmodifiedAssistantMessage → AgentMessage analog
    stream controller / chatSessionStoremodifiedstreamingStartedAt per node
    __tests__/*newmocked reasoning stream → "Thinking" step → collapse to "Thought for Ns"; tap expands; USER_CANCELLED → Stopped
  • Est. size: ~700 LOC.
  • Depends on: 9b.2, 9b.3, 9b.5, 9b.6.
  • Feature-flag state: N/A — the timeline goes live.
  • Tests on merge: RN Testing Library — mocked reasoning packet stream renders the streaming header → step → auto-collapse → tap-expand → Done; Stopped path. HARD device gate (owner): dev build, reasoning model — streaming shimmer + live timer, auto-collapse on answer, tap-to-expand, Done step, hydrated render; migrated answer + 9a Sources intact; streaming re-render stays smooth (perf-isolation from 04).
  • Drift checkpoint: Re-confirm the unify-chat-input ChatSurface composition (post-PR6 refactor) is where MessageRow/AgentTimeline mount, and that per-conversation draft/reset rules don't interfere with the per-node timeline state.
  • As built (2026-08-03, "parallel tabs pulled forward" — owner-gated before coding): Four AskUserQuestion gates. The big one: the owner checked whether parallel tabs were planned anywhere under docs/mobile-chat/ — they were not (only the unscheduled "After 9b" line below) — and ruled build them now, so ParallelTimelineTabs and ParallelStreamingHeader ship LIVE, not dormant, and the linearize divergence (03 §8(d)) is retired. That required a new mobile primitive: components/ui/tabs.tsx, an RN port of Opal's pill Tabs (Root context + List + Trigger), which the owner approved over hand-rolling a lookalike. Other gates: port stop-circle 1:1 from Opal (the 9b.5 icon precedent); fold usePacketDisplay into usePacketProcessor (expose processed, delete the hook + its test) rather than reduce every packet twice per flush; ship it all as one PR. New: components/chat/timeline/{ExpandedTimelineContent,CollapsedStreamingContent,TimelineStep,TimelineStepComposer,ParallelTimelineTabs,ShimmerText}.tsx + toolIcons.ts, timeline/headers/{Streaming,Completed,Stopped,ParallelStreaming}Header.tsx, timeline/primitives/TimelineTopSpacer.tsx, components/ui/tabs.tsx, and 6 1:1 Opal icon ports (stop-circle, branch, user, code, book-open, slow-time). Modified: AgentTimeline.tsx (stub → full shell), MessageRow.tsx (→ the AgentMessage analog), hooks/timeline/usePacketProcessor.ts (+processed), chat/interfaces.ts + chat/chatHistory.ts (+streamingStartedAt, +processingDurationSeconds), hooks/useChatController.ts + hooks/useChatSessionController.ts (the two stream-start stamps), lib/time.ts (+formatDurationSeconds, ported from Opal time.ts). ~1480 LOC — roughly double the 500–700 band, which the owner accepted when folding tabs in.
  • Divergences from web (9b.7's own, on top of 03 §8): (a) no hover anywhere — web's headerIsInteractive only toggled a hover class, so it has no mobile analog and each header owns its own Pressable instead; (b) useTimelineStepState is not called by the shell (its only web consumers are the memory tag and that hover flag) and CompletedHeader drops web's memory tag/tooltip/MemoriesModal — the memory renderer is a later phase; (c) shimmer = an opacity pulse (ShimmerText, RN Animated so the timeline stays jest-renderable), not web's background-clip:text gradient; (d) the expanded body has no entrance animation (web's animate-in fade-in slide-in-from-top-2) — a 300 ms flourish isn't worth putting reanimated back on the test path; (e) streamingStartTime is a per-node prop, not web's per-session store read, and a resumed run restarts the timer from the resume (this client never observed the run's true start; web shows no timer at all there); (f) the ported Tabs ships the pill variant only (no contained/underline — no consumer) and drops the scroll arrows (a touch list is swiped, not arrow-stepped); (g) TimelineStep is split into its own module — web keeps it private inside ExpandedTimelineContent, which on mobile would cycle ExpandedTimelineContent → ParallelTimelineTabs → TimelineStep; (h) getToolIcon uses Onyx glyphs where web falls back to react-icons against its own icon rule (FiToolcpu, matching chat/tools.ts's custom-tool fallback; FiListtext-lines-small).
  • Also fixed here: hydrated turns carried no duration at all, so every reopened chat's header would have read "Thought for some time" — processing_duration_seconds (already on the backend response, models.py:232) is now mapped through chatHistory.
  • Adversarial review (4 lenses × 3 refute-first skeptics) — 8 raised, 6 fixed: (1) HIGH — the Stopped path was unreachable in production. A user stop aborts the reader before the backend's stop packet arrives, so stopPacketSeen never flipped: the turn stayed in its STREAMING ui-state (and useStreamingDuration's 250 ms interval kept ticking) until the session was reloaded. Fixed by synthesizing a USER_CANCELLED stop packet in runChatStream's finally on abort (buildUserCancelledStopPacket); the resume path already self-heals, since it re-hydrates from a snapshot the backend replays an OverallStop into. (2) MEDIUM — FlashList recycles React keys: RenderStackManager.syncItem hands a pooled key to a different stableId, so a scrolled-away turn's component state (timeline expansion, active branch tab, open sources sheet) was inherited by an unrelated message — and since userHasToggled latches, that turn could never auto-collapse. keyExtractor only supplies the stableId, not the React key; fixed by keying MessageRow on nodeId inside renderItem. (3) LOW ×3 — the chatState memo's count proxies could pin a stale map on mobile (web gets away with it by mutating in place), the Tabs baseline painted over the sliding indicator, and Opal's pt-1 belongs to its no-scroll-arrows branch. (4) 5 files weren't prettier-formatted. Accepted as-is: the CitedSources gate keeps 9a's processed.isComplete (a briefly-early Sources bar during answer retraction is not worth changing shipped 9a semantics here).
  • Verified: tsc clean, lint clean (2 pre-existing _layout warnings), prettier clean, 596 jest pass across 76 suites (+40 new: the mandated composition smoke test — reasoning stream → "Thinking" step → auto-collapse to "Thought for Xs · 1 step" → tap-expand → Done; USER_CANCELLED → Interrupted Thinking → Stopped; parallel turn → a tab per branch in both the streaming header and the expanded body, incl. switching branches — plus the Tabs primitive, StreamingHeader timer/freeze under fake timers, getToolIcon, formatDurationSeconds, the chatHistory duration mapping, the streamingStartedAt stamp, and the synthetic-stop-on-abort regression).
  • HARD DEVICE GATE STILL OWED (owner-run): a dev build against a reasoning model — streaming shimmer + live timer, auto-collapse when the answer starts, tap-to-expand, the Done step, a hydrated (reopened) render showing a real "Thought for Xs", the migrated answer + 9a Sources intact, and that streaming re-render stays smooth. Parallel tabs additionally need a multi-branch run to see the pill strip and the sliding indicator on device.

After 9b — the follow-up renderer phases (not designed here)

Each is its own grill-first PR on the zero-refactor seam: add the tool's obj interfaces + one findRenderer predicate wire-up + one render-prop renderer returning RendererResult (search/fetch reuse 9a SourceRow). Order by product value (owner's call): search → fetch → python/code → custom-tool → deep-research (+nested) → memory, plus parallel-tab tabs (UI-only, un-dormant ParallelTimelineTabs) and 9c/9d/9e independently.