Skip to content

feat(agent): collapsible Reasoning block composed into agent-turn (CL-8482) - #47

Merged
TheGreatAxios merged 6 commits into
mainfrom
cl-8482-reasoning-block
Sep 21, 2026
Merged

TheGreatAxios merged 6 commits into
mainfrom
cl-8482-reasoning-block

Conversation

@TheGreatAxios

Copy link
Copy Markdown
Contributor

Summary

  • Adds src/ui/reasoning-block.tsx: a collapsible "thought" block for the agent's reasoning text — collapsed by default, keyboard-accessible toggle (button + aria-expanded + a labelled content region), an optional duration label ("Thought for 4s"), and a streaming state ("Thinking…").
  • Composes it into AgentTurn in place of the old reasoning summary, fed by the existing reasoningText() accessor, via a new optional reasoningDurationLabel prop. Rendering is unchanged for a turn with no reasoning text.
  • Story under stories/primitives/reasoning-block.stories.tsx (collapsed, expanded, streaming, no-duration) and a red/green test for the toggle.

Test plan

  • bun run dep-guard
  • bun run typecheck
  • bun run lint
  • bun run build (incl. contrast gate)
  • bun test

@TheGreatAxios

Copy link
Copy Markdown
Contributor Author

Review

Verdict: request changes. ReasoningBlock collapsed by default; tests cover empty/toggle/streaming. No CI. No prior reviews.

Blocking: hardcoded regionId = reasoning-block-content. Two AgentTurns with reasoning duplicate the id; both buttons aria-controls the first region. Use useId().

A uniqueness fix is in flight on this branch.

@TheGreatAxios

Copy link
Copy Markdown
Contributor Author

Re-review

Verdict: approve. SHA 1da98af.

ReasoningBlock uses useId() for region id / aria-controls. Two-instance test added. bun test 340 pass, typecheck/lint/build 0.

@TheGreatAxios

Copy link
Copy Markdown
Contributor Author

Review (Draper + Emil)

Verdict: approve with copy nits. Collapsed-by-default including while streaming is the right call. useId uniqueness already landed.

Library strings: streaming “Thinking…”, idle “Thought about this”, aria “Reasoning.” Three names; idle fallback is generic-AI. TypingIndicator already says Thinking. Prefer one term; drop the cute idle fallback. Duration label stays caller-supplied.

Copy-only follow-up if we touch this PR.

@TheGreatAxios TheGreatAxios left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pushback on the shape of this one. ReasoningBlock is a near-duplicate of ActivityBlock — same chevron disclosure, same collapsed-while-streaming rationale, same bordered body — and this PR orphans ActivityBlock (still exported, zero consumers once agent-turn switches).

The doc comment's justification for dropping <details> doesn't match the implementation: it says the duration label "needs to sit next to the summary text without becoming part of it," but the label IS the summary text inside the button — <details><summary>Thought for 4s</summary> renders that identically.

The real delta is controlled open/onOpenChange — which ActivityBlock could gain additively (uncontrolled-by-default, the exact pattern the CL-5633 pass applied to ToolBlock, ToolNarrative, and friends). The button+region rewrite also loses what the codebase praised <details> for: find-in-page expansion and free disclosure semantics — {open ? <p> : null} means closed reasoning isn't in the DOM at all, so browser find can't match it.

Two honest options: fold durationLabel + controlled state into ActivityBlock (keeps <details>), or make this a clean rename that deletes ActivityBlock. Shipping both leaves two public components doing the same job.

@TheGreatAxios

Copy link
Copy Markdown
Contributor Author

Re-review

Idle fallback is “Thinking”. SHA 9e4b776. bun test 341 pass. ActivityBlock still has the old phrase (out of this PR).

…-8482)

Adds src/ui/reasoning-block.tsx, a keyboard-accessible disclosure for the
agent's thinking text: collapsed by default, an optional duration label
("Thought for 4s"), and a streaming state. AgentTurn now renders it in place
of the reasoning summary, fed by an optional reasoningDurationLabel prop —
rendering is unchanged when a turn has no reasoning text.

Gates run locally: dep-guard, typecheck, lint, build (incl. contrast gate),
bun test.
Hardcoded reasoning-block-content duplicated aria-controls when two
AgentTurns mounted. Generate a per-instance id with useId and assert
two blocks have distinct region ids matching their toggles.
Drop the "Thought about this" fallback so the term matches TypingIndicator.
Duration label stays caller-supplied; streaming still uses Thinking…; aria
region stays Reasoning.
@TheGreatAxios
TheGreatAxios force-pushed the cl-8482-reasoning-block branch from 9e4b776 to cf0da24 Compare September 21, 2026 18:34
@TheGreatAxios

Copy link
Copy Markdown
Contributor Author

Review pass — rebased onto post-prune main, reconciled with ActivityBlock

The original PR landed a hand-rolled button/region disclosure next to the existing ActivityBlock. Its stated rationale — that <details> can't carry a duration label — didn't hold: durationLabel simply is the summary text. Reconciled instead: ReasoningBlock replaces ActivityBlock on native <details> (disclosure semantics, keyboard behavior, find-in-page expansion stay free), keeping the PR's real additions — durationLabel, streaming, and controlled open/onOpenChange. AgentTurn renders it; module count stays 113.

Persona review findings — all resolved

  • Controlled-<details> divergence (emil): documented on the open prop — a controlled parent that ignores onOpenChange leaves DOM attribute and React state split, since the browser mutates open without React's knowledge.
  • onToggle echo (emil): the browser fires toggle for any attribute change, including prop-driven ones — a bare setOpen echoed the parent's own commands back through onOpenChange. Now reports only DOM-vs-prop divergence: exactly one callback per user activation, none for parent-driven updates. Proven in tests (happy-dom implements real <details> semantics — summary clicks toggle and queue toggle events).
  • Incoherent aria-live (emil): was attached only while streaming, i.e. it vanished exactly when the summary text changed. Now unconditional.
  • Craft regressions (emil): restored the sibling ReasoningPartView patterns — 40px ::after hit-area extension and the keyed entrance animation.
  • Overridable strings (product decision): streamingLabel (default "Thinking…") and idleLabel (default "Thinking") are props.
  • Tests: rewritten around real summary activation rather than el.open mutation; cover collapsed default, duration/streaming/idle labels, same-instance streaming→idle (summary + aria-busy), uncontrolled callback, controlled activation-intent, and no-echo on parent-driven open/close.

Validation

233 tests, typecheck, lint, dep-guard, full build, contrast gate — all green at 2e94a97.

@TheGreatAxios
TheGreatAxios merged commit 1340e3d into main Sep 21, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant