docs/mobile-chat/9b-timeline/05-pr-roadmap.md
Status: active · Task: 9b-timeline · Source plan: 04-implementation-plan.md
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.
| PR | Title | Est. LOC | Depends on | Key deliverable |
|---|---|---|---|---|
| 9b.1 | feat(mobile): timeline grouping engine + packet contracts | ~700 | — | Pure grouping reducer (turn/tab + section_end synthesis) + full PacketType enum + pure step helpers; unit-tested. Dark. |
| 9b.2 | feat(mobile): timeline processor + pacing hooks | ~590 | 9b.1 | usePacketProcessor (lint-safe recompute) + usePacedTurnGroups (200ms reveal), the two restructured hooks; unit-tested. Dark. |
| 9b.3 | feat(mobile): timeline state hooks | ~550 | 9b.1 | useTimelineUIState/Expansion/Header/StreamingDuration/Metrics/StepState; unit-tested. Dark. |
| 9b.4 | feat(mobile): render-prop renderer contract + answer migration | ~510 | 9b.1 | RendererResult/MessageRenderer contract + findRenderer + RendererComponent; migrate final-answer MessageTextRenderer to it. Live (answer path). |
| 9b.5 | feat(mobile): timeline primitives + StepContainer | ~650 | 9b.4 | Rail/surface/content primitives + StepContainer + TimelineRendererComponent + new icons/Button (ASK) + StreamingMarkdown muted. Dark. |
| 9b.6 | feat(mobile): reasoning renderer | ~450 | 9b.4, 9b.5 | ReasoningRenderer + reasoningState + ReasoningTextWindow; registered in findRenderer; unit-tested. Dark. |
| 9b.7 | feat(mobile): agent timeline composition | ~700 | 9b.2, 9b.3, 9b.5, 9b.6 | AgentTimeline shell + headers + Expanded/Collapsed content + MessageRow wiring + streamingStartedAt. Lights up the timeline. Device gate. |
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.
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.| File | New/Modified | Slice |
|---|---|---|
mobile/src/chat/streamingModels.ts | modified | enum values + reasoning/branch/tool-arg interfaces + ObjTypes |
mobile/src/chat/messageProcessor.ts | modified | grouping fields + section_end synthesis + categorization (keep 9a citations/docs) |
mobile/src/chat/timeline/transformers.ts | new | TransformedStep/TurnGroup/groupStepsByTurn |
mobile/src/chat/timeline/packetUtils.ts | new | isToolPacket/isDisplayPacket/getTextContent/… |
mobile/src/chat/timeline/packetHelpers.ts | new | per-family predicates + collapsed-streaming sets |
mobile/src/chat/timeline/toolDisplay.ts | new | parseToolKey/getToolName/isToolComplete/hasToolError |
mobile/src/chat/timeline/__tests__/* | new | grouping key, 3 section_end triggers, categorization, parallel detection, model_index tolerance, history-reset |
section_end synthesis + transformers fully covered; messageProcessor still passes its 9a citation/document tests.reasoning_start/delta/done) and Placement
fields are still as 03/deepread recorded; re-verify web/.../packetProcessor.ts hasn't changed since extraction.usePacketProcessor (useMemo full recompute + derive toolTurnGroups/displayGroups/
isComplete); usePacedTurnGroups (useState+effect reveal, answer gating, history bypass; drop
prevPacedRef).| File | New/Modified | Slice |
|---|---|---|
mobile/src/hooks/timeline/usePacketProcessor.ts | new | recompute + group derivation + renderComplete gate |
mobile/src/hooks/timeline/usePacedTurnGroups.ts | new | 200ms reveal + gating (restructured) |
mobile/src/hooks/timeline/__tests__/* | new | fake-timer pacing (first-immediate/200ms/stop-flush/bypass) + processor derivation |
03 §1); if the first-packet render flashes, revisit before 9b.7 wires it.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).| File | New/Modified | Slice |
|---|---|---|
mobile/src/hooks/timeline/useTimelineUIState.ts | new | state machine + show/style booleans |
mobile/src/hooks/timeline/useTimelineExpansion.ts | new | collapse + auto-collapse |
mobile/src/hooks/timeline/useTimelineHeader.ts | new | header text map |
mobile/src/hooks/timeline/useStreamingDuration.ts | new | live elapsed timer |
mobile/src/hooks/timeline/useTimelineMetrics.ts | new | step count + last-step flags |
mobile/src/hooks/timeline/useTimelineStepState.ts | new | memory extraction (dormant) |
mobile/src/hooks/timeline/__tests__/* | new | 7 states + booleans, auto-collapse + toggle suppression, timer tick + freeze |
TimelineUIState branch + derived boolean; expansion auto-collapse and userHasToggled; duration tick/freeze.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).| File | New/Modified | Slice |
|---|---|---|
mobile/src/components/chat/renderers/timelineContract.ts | new | the contract types |
mobile/src/components/chat/renderers/findRenderer.ts | new | priority dispatch |
mobile/src/components/chat/renderers/RendererComponent.tsx | new | final-answer dispatch |
mobile/src/components/chat/renderers/MessageTextRenderer.tsx | modified | → render-prop contract |
mobile/src/components/chat/renderers/registry.ts | modified | re-export findRenderer |
mobile/src/components/chat/MessageRow.tsx | modified | answer → RendererComponent (timeline still stub) |
mobile/src/hooks/usePacketDisplay.ts | modified | keep exposing processed for CitedSources |
__tests__/* | new | findRenderer dispatch order, MessageTextRenderer render-prop, RendererComponent |
children([result]); RendererComponent renders answer. Manual: confirm the streamed markdown answer + 9a inline citations + Sources bar are unchanged.CitedSources behavior so the migration preserves it exactly.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.AgentTimeline composition, headers.| File | New/Modified | Slice |
|---|---|---|
mobile/src/components/chat/timeline/primitives/* | new | tokens + 6 rail/surface/content primitives |
mobile/src/components/chat/timeline/StepContainer.tsx | new | compose primitives into a step frame |
mobile/src/components/chat/timeline/TimelineRendererComponent.tsx | new | per-step expand + renderType |
mobile/src/icons/{circle,fold,expand,check-circle}.tsx | new | ASK owner |
mobile/src/components/chat/StreamingMarkdown.tsx | modified | muted/compact variant |
__tests__/* | new | StepContainer (collapse gating, last-step connector, error surface), renderType derivation |
renderType; icon rail geometry; error surface.03 if web tokens changed.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.AgentTimeline composition (9b.7); web's Download button (no mobile analog).| File | New/Modified | Slice |
|---|---|---|
mobile/src/chat/timeline/reasoningState.ts | new | heading extraction + delta accumulation (pure) |
mobile/src/components/chat/renderers/ReasoningRenderer.tsx | new | the render-prop renderer + 500ms gate |
mobile/src/components/chat/timeline/ReasoningTextWindow.tsx | new | maxHeight markdown window |
mobile/src/components/chat/renderers/findRenderer.ts | modified | wire the reasoning slot |
__tests__/* | new | heading extraction rules, delta accumulation, 500ms gate (fake timers), empty/pre-start branch |
circle icon, StreamingMarkdown muted, StepContainer for the smoke test).reasoningState heading + accumulation; ReasoningRenderer 500 ms gate + branches; a StepContainer-wrapped smoke render.react-native-streamdown renders the muted variant acceptably (color/margins) — device-adjacent; the owner's 9b.7 device gate covers it.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 + usePacedTurnGroups →
AgentTimeline above + RendererComponent answer below + CitedSources); capture streamingStartedAt in the
PR-3 stream controller/store.| File | New/Modified | Slice |
|---|---|---|
mobile/src/components/chat/AgentTimeline.tsx | modified | stub → full shell |
mobile/src/components/chat/timeline/ExpandedTimelineContent.tsx | new | TurnGroup[]→steps + Done/Stopped |
mobile/src/components/chat/timeline/CollapsedStreamingContent.tsx | new | streaming live preview |
mobile/src/components/chat/timeline/TimelineStep.tsx | new | step composer (children → StepContainer) |
mobile/src/components/chat/timeline/headers/* | new | Streaming/Completed/Stopped (+Parallel dormant) |
mobile/src/components/chat/timeline/ParallelTimelineTabs.tsx | new | dormant stub (linearize) |
mobile/src/components/chat/timeline/toolIcons.ts | new | packet-type → icon map |
mobile/src/components/chat/MessageRow.tsx | modified | AssistantMessage → AgentMessage analog |
stream controller / chatSessionStore | modified | streamingStartedAt per node |
__tests__/* | new | mocked reasoning stream → "Thinking" step → collapse to "Thought for Ns"; tap expands; USER_CANCELLED → Stopped |
04).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.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.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 (FiTool → cpu, matching chat/tools.ts's custom-tool fallback; FiList → text-lines-small).processing_duration_seconds (already on the backend response, models.py:232) is now mapped through chatHistory.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).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).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.