feat(web): render thinking blocks in planner transcript message bubbles #1193
No reviewers
Labels
No labels
area:agents
area:dashboard
area:database
area:design
area:design-review
area:flows
area:infra
area:meta
area:security
area:sessions
area:webhook
area:workdir
security
type:bug
type:chore
type:meta
type:user-story
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
charles/agent-hooks!1193
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "dev/1165"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
ReasoningBlockcomponent inplanner/transcript.tsxwith the shared<Reasoning>component fromcomponents/agent/reasoning.tsxm.thinkingand render the collapsed accordionstreamThinkingStartedAtis extracted from the firstassistantSSE event that carries thinking blocks, so the elapsed timer starts from when thinking actually beganDetails
The
<Reasoning>component was already extracted (#1165 AC: Extraction). This PR completes the transcript integration:streamingprop wired throughBubble→Reasoningfor pulse/auto-expand behaviourthinkingStartedAtprop added toBubblefor accurate elapsed time on the live bubble; theTranscriptcomponent derives it from stream eventsendedAt={ts}(message timestamp) as a safe fallback showing "Thought for 1s" since per-thinking-block duration isn't stored separatelyReasoningBlocklocal function removed (replaced entirely by shared component)Closes #1165
Test plan
just qapassesReview — PR #1193: render thinking blocks in planner transcript bubbles
CI ✅ · mergeable ❌ (base branch conflict — rebase needed before merge)
Overview
Clean, focused integration of the shared
<Reasoning>component into the planner transcript. The 40-lineReasoningBlocklocal function is deleted entirely; the replacement wires all the props needed for pulse animation, auto-expand-on-stream, and elapsed timer. No new abstractions, no scope creep.What's correct
streamThinkingStartedAtIIFE — iteratesstreamEventsonce, returns thetsof the firstassistantevent carrying thinking blocks, orundefinedbefore any thinking arrives. The type cast (ev.detail as { thinking?: string[] } | undefined) is identical to the pattern inextractAssistantThinking, which is the right source to mirror. Returningundefinedgracefully means theReasoningaccordion won't show a misleading "0s" before the first SSE event.streaming ?? falsecoercion —BubbleProps.streamingisboolean | undefined;ReasoningProps.streamingrequiresboolean. The?? falsefallback is the correct narrowing.endedAt={streaming ? undefined : ts}logic — correctly leavesendedAtundefined while the live counter should tick, then seals it with the render timestamp once streaming ends. TheReasoningcomponent's live timer stops whenendedAtis provided, which is exactly the desired transition.Persisted turn fallback — passing
startedAt={thinkingStartedAt ?? ts}withendedAt={ts}on persisted bubbles (wherethinkingStartedAtis not provided) yieldsendedAt − startedAt = 0 → Math.max(1, 0) = 1s, so "Thought for 1s" always shows. That's documented, intentional, and correct given per-block duration isn't stored.No orphaned imports —
ChevronDown/ChevronRightsurvive theReasoningBlockdeletion because they're still used at line 647. Import order is Biome-compliant (@/components/agent/reasoningbefore@/components/agent-message).No test regression —
data-testid="reasoning-block"(old) has no usages in the test suite;data-testid="reasoning-accordion"(new, from the shared component) can be picked up by future tests without any cleanup.Observations (non-blocking)
endedAtcan drift on re-renders after streaming ends.ts={Date.now()}on the streaming bubble is recomputed on every render, soendedAt={streaming ? undefined : ts}advances if the parent re-renders while the streaming bubble is still mounted (e.g. a late steer pill arrives afterstreaming=false). In practice the window is at most one or two render cycles before the persisted messages replace the streaming bubble, andMath.roundabsorbs sub-second jitter, so users won't notice. But it's worth knowing the timer isn't strictly frozen at end-of-stream. AuseRefthat latchesDate.now()on thetrue→falsetransition would make this exact — worth a follow-up ticket if elapsed accuracy ever becomes a product requirement.idis unstable for the streaming bubble.id={\reasoning-${ts}`}wherets={Date.now()}changes every render. The_idunderscore inReasoningsignals it's currently unused internally, so there's no runtime impact. But theReasoningPropsJSDoc calls out that callers should key off event id for interleaved-thinking support (#951). Once interleaved thinking lands andidstarts mattering, the streaming bubble will need a stable key derived from the first thinking event's id rather thanDate.now()`. Fine to defer, just flag it before #951 merges.No automated test for
thinkingStartedAtwiring.The
Reasoningcomponent itself has coverage from #1165; the new prop threading throughTranscript → Bubble → Reasoningis test-plan manual-only. Given the simplicity of the wiring this is acceptable, but a smoke test assertingdata-testid="reasoning-accordion"appears whenstreamEventscontains a thinking block would prevent regressions cheaply.Verdict
All three observations are minor / theoretical / pre-existing. The implementation is correct, the shared component is wired up properly,
just qapasses, and CI is green.Approved. Rebase onto
mainto resolve the merge conflict, then this is ready to land.5310d18f1f3a3a3662bf