docs/mobile-chat/9b-timeline/03-detailed-design.md
Status: active · Task: 9b-timeline · Approach: C — Faithful Shell First
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.
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.)
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.
// 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.
mobile/src/chat/messageProcessor.ts (modified → faithful port of web packetProcessor.ts)ProcessedMessageState gains web's grouping fields (existing 9a fields kept for CitedSources):
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.
mobile/src/chat/timeline/ (new)transformers.ts — TransformedStep {key, turnIndex, tabIndex, packets}, TurnGroup {turnIndex, steps, isParallel},
transformPacketGroup, transformPacketGroups, groupStepsByTurn (isParallel = steps.length > 1).packetUtils.ts — isToolPacket, isActualToolCallPacket, isDisplayPacket, isSearchToolPacket,
isStreamingComplete, isFinalAnswerComing, isFinalAnswerComplete, groupPacketsByTurnIndex,
getTextContent, getCitations.packetHelpers.ts — COLLAPSED_STREAMING_PACKET_TYPES, CODING_AGENT_PACKET_TYPES, isReasoningPackets,
isSearchToolPackets, isPythonToolPackets, isResearchAgentPackets, isCodingAgentPackets,
isDeepResearchPlanPackets, isMemoryToolPackets, stepSupportsCollapsedStreaming,
stepHasCollapsedStreamingContent.toolDisplay.ts — getToolKey, 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).mobile/src/components/chat/renderers/timelineContract.ts (new)Ported verbatim from web interfaces.ts (types only; JSX.Element = RN element):
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;
}
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).
| File | Responsibility |
|---|---|
mobile/src/chat/timeline/transformers.ts | GroupedPacket→TransformedStep→TurnGroup; parallel detection |
mobile/src/chat/timeline/packetUtils.ts | packet categorizers (tool/display/search/text/citations) |
mobile/src/chat/timeline/packetHelpers.ts | per-family predicates + collapsed-streaming sets |
mobile/src/chat/timeline/toolDisplay.ts | tool key parse, name, completion, error (pure) |
mobile/src/chat/timeline/reasoningState.ts | extractFirstParagraph + constructCurrentReasoningState (pure) |
mobile/src/components/chat/renderers/timelineContract.ts | RenderType/RendererResult/MessageRenderer/FullChatState |
mobile/src/components/chat/renderers/findRenderer.ts | priority-ordered dispatch (reasoning last) |
mobile/src/components/chat/renderers/RendererComponent.tsx | final-answer dispatch at FULL (+ mixed-content stub) |
mobile/src/components/chat/renderers/ReasoningRenderer.tsx | the one wired step renderer |
mobile/src/hooks/timeline/usePacketProcessor.ts | host reducer + derive turn groups (restructured) |
mobile/src/hooks/timeline/usePacedTurnGroups.ts | 200ms staggered reveal + answer gating (restructured) |
mobile/src/hooks/timeline/useTimelineUIState.ts | 7-state machine + show/style booleans |
mobile/src/hooks/timeline/useTimelineExpansion.ts | collapse state + auto-collapse |
mobile/src/hooks/timeline/useTimelineHeader.ts | header text map |
mobile/src/hooks/timeline/useStreamingDuration.ts | live elapsed timer (frozen by backend duration) |
mobile/src/hooks/timeline/useTimelineMetrics.ts | step count + last-step flags |
mobile/src/hooks/timeline/useTimelineStepState.ts | memory-only extraction (dormant) |
mobile/src/components/chat/timeline/primitives/timelineTokens.ts | px token constants (rail 36, icon 12, …) |
mobile/src/components/chat/timeline/primitives/TimelineRoot.tsx | outer column |
mobile/src/components/chat/timeline/primitives/TimelineHeaderRow.tsx | rail cell (avatar) + header slot |
mobile/src/components/chat/timeline/primitives/TimelineRow.tsx | icon column + content row |
mobile/src/components/chat/timeline/primitives/TimelineIconColumn.tsx | connector rail + node icon |
mobile/src/components/chat/timeline/primitives/TimelineSurface.tsx | tint/error surface + rounding |
mobile/src/components/chat/timeline/primitives/TimelineStepContent.tsx | header row + collapse control + body |
mobile/src/components/chat/timeline/StepContainer.tsx | composes the primitives into a step frame |
mobile/src/components/chat/timeline/TimelineRendererComponent.tsx | per-step expand state + renderType derive |
mobile/src/components/chat/timeline/TimelineStep.tsx | step composer (children render-prop → StepContainer) |
mobile/src/components/chat/timeline/ExpandedTimelineContent.tsx | maps TurnGroup[]→steps + Done/Stopped |
mobile/src/components/chat/timeline/CollapsedStreamingContent.tsx | streaming live preview of last step |
mobile/src/components/chat/timeline/ParallelTimelineTabs.tsx | dormant stub (linearizes for 9b) |
mobile/src/components/chat/timeline/headers/StreamingHeader.tsx | shimmer 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.tsx | dormant stub |
mobile/src/components/chat/timeline/toolIcons.ts | packet-type → mobile icon map (for headers/tabs) |
mobile/src/components/chat/timeline/ReasoningTextWindow.tsx | maxHeight markdown window (ExpandableTextDisplay analog) |
mobile/src/icons/{circle,fold,expand,check-circle}.tsx | new icons (ASK owner) — see §6 |
mobile/src/chat/timeline/__tests__/*.test.ts | grouping / 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).
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)
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.ts — const 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.ts — useState(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.ts — isExpanded=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.ts — elapsedSeconds 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 + primitives — TimelineRow(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.tsx — isExpanded=useState(defaultExpanded), renderType = override ?? (isExpanded?FULL:COMPACT), findRenderer(packets) → render-prop, enhanceResult attaches
{isExpanded,onToggle,renderType,isLastStep,timelineLayout}; children(results.map(enhanceResult)).ReasoningRenderer.tsx — constructCurrentReasoningState(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 (onContentSizeChange → scrollToEnd), 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.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.tsx — AssistantMessage 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).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/native — varsLight/varsDark + textPresets for markdown colors (unchanged pattern).backend/onyx/chat/llm_step.py.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).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.)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.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.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.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.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.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.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.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.