fix(dashboard): make transcript disclosure state survive row recycling - #1067
Conversation
UX Review (Fable 5) — ✅ PASSAdvisory UX-level review of UX-Verdict: PASS Disclosure now honors user intent through scrolling and mid-turn churn — the core frustration (a click the UI silently discards) is genuinely fixed. Suggestions
[UX-REVIEWED] 1d07da1 |
Opus 5 Review — ✅ no blocking findingsReviewed Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsFINDING -- website/src/pages/chat/CollapsibleToolGroup.tsx:52 -- Expanding a tool group, scrolling it out of the virtualized window, then returning remounts it and False positive or not applicable? A repository writer can comment: |
Design Review (Fable 5) — 🟡 CONCERNSAdvisory design-level review of Design-Verdict: CONCERNS Two parallel mechanisms now solve one problem — the new keyed store and ChatPage-owned maps — doubling the surface future disclosure work must navigate. Watch
Suggestions
[DESIGN-REVIEWED] 1d07da1 |
Arbiter — ✅ no blocking findingsArbiter found no unresolved long-term items that require action before merging Second-order review for Review detailsBoth files read. All listed findings are frontend disclosure-state behavior in the virtualized chat transcript — no persisted data, no wire format, no API contract, no security surface. Judging each against the narrow bar:
Nothing here locks in a contract, schema, or persisted format (the store and maps are in-memory only, reset per slot), and nothing can crash, leak unboundedly (entries exist only for explicit toggles and are cleared on slot switch), or corrupt data. Arbiter-Verdict: PASS No sub-threshold finding meets the long-term-impact bar. Suggested follow-ups (open as issues — non-blocking)
[ARBITER-REVIEWED] 1d07da1 False positive or not applicable? A repository writer can comment: For a broader accepted-risk deferral, apply |
c647acf to
815f408
Compare
815f408 to
7dbf389
Compare
Expanding a tool call, a thinking block, or any other collapsible row while an agent was working did not stick. It closed again on its own, and kept closing for as long as the turn ran. This was a bug CLASS, not one bug. The transcript is virtualised: useVirtualChat renders a row only while `item.mounted` holds and unmounts it once it leaves the window plus overscan band, which streaming does routinely as it scrolls content past. Every collapsible control in the transcript held its open/closed state in row-local `useState`, so each recycle destroyed it. Nine controls had the defect, which is why fixing them one at a time kept surfacing another: ThinkingBlock, ToolCallLine, TurnBlock, CollapsibleToolGroup, NudgeCard, RecoveryCard, WorkflowCompletionCard, FileChangeChips and TaskProgressBar. ToolCallLine already carried a comment naming "virtualizer recycling" as a known hazard for a neighbouring concern. Guarding a collapse cannot fix this. A remount discards the guard along with the state it protects, so the state has to outlive the row. Fix: one shared mechanism, `useRowDisclosure(key, fallback)` in pages/chat/rowDisclosure.tsx, backed by an external store with per-key subscriptions through useSyncExternalStore. Per-key subscription matters: a map held in provider state would re-render every consumer on every toggle, and these are the rows whose render cost is the most sensitive in the app. The hook degrades to plain local state when no provider or key is present, so hosts that render transcript components outside ChatPage (split-view ChatPane, app-sdk's ChatMessageList) keep working untouched. An entry exists only once the user has made an explicit choice, so a control never touched keeps its previous default behaviour. ChatPage supplies the store and resets it on slot switch, since row keys are unique only within a slot. A second, independent defect in TurnBlock is also fixed: it collapsed on any `turn.complete` false->true transition, and `complete` is derived from the slot's running flag, which ChatPage re-reconciles from EVERY slots broadcast. A broadcast catching the slot momentarily idle between tool calls flipped it true mid-turn and fired the collapse. An explicit click now pins that state. Tests: 21 cases. The shared mechanism is proven once (durability across unmount, per-key isolation, reset on slot switch, and the no-provider fallback), rather than re-proving it per component. Writing those tests caught a real bug in the hook itself, where a stale local shadow survived reset and leaked a choice across a slot switch. Component-level cases cover the tool pill and the turn-level toggle, including running-flag churn, and every one is revert-verified: reverting a half fails the cases attributed to it. Five cases guard the opposite direction and fail if a pin is widened into disabling an intended auto-collapse. One `it.fails` case pins a separate pre-existing defect, where promoting loose items into a turn remounts the row. TaskProgressBar and KnowledgeBubbleChip are migrated to the hook but not yet keyed: the progress bar renders outside the virtualised list so it is not exposed, and the knowledge chip has no stable identity at its call site. Both fall back to local state, exactly as before.
7dbf389 to
1d07da1
Compare
bolichen97
left a comment
There was a problem hiding this comment.
Approved: independently re-reviewed the full diff against the GPT + Opus/AUTOSDE contracts (reachable correctness/security, fail-closed/permission boundaries, cross-OS, and deleted-in-use i18n keys per the #976 class). No reachable Critical/High found; all CI checks green.
kirodotdev#1067) Expanding a tool call, a thinking block, or any other collapsible row while an agent was working did not stick. It closed again on its own, and kept closing for as long as the turn ran. This was a bug CLASS, not one bug. The transcript is virtualised: useVirtualChat renders a row only while `item.mounted` holds and unmounts it once it leaves the window plus overscan band, which streaming does routinely as it scrolls content past. Every collapsible control in the transcript held its open/closed state in row-local `useState`, so each recycle destroyed it. Nine controls had the defect, which is why fixing them one at a time kept surfacing another: ThinkingBlock, ToolCallLine, TurnBlock, CollapsibleToolGroup, NudgeCard, RecoveryCard, WorkflowCompletionCard, FileChangeChips and TaskProgressBar. ToolCallLine already carried a comment naming "virtualizer recycling" as a known hazard for a neighbouring concern. Guarding a collapse cannot fix this. A remount discards the guard along with the state it protects, so the state has to outlive the row. Fix: one shared mechanism, `useRowDisclosure(key, fallback)` in pages/chat/rowDisclosure.tsx, backed by an external store with per-key subscriptions through useSyncExternalStore. Per-key subscription matters: a map held in provider state would re-render every consumer on every toggle, and these are the rows whose render cost is the most sensitive in the app. The hook degrades to plain local state when no provider or key is present, so hosts that render transcript components outside ChatPage (split-view ChatPane, app-sdk's ChatMessageList) keep working untouched. An entry exists only once the user has made an explicit choice, so a control never touched keeps its previous default behaviour. ChatPage supplies the store and resets it on slot switch, since row keys are unique only within a slot. A second, independent defect in TurnBlock is also fixed: it collapsed on any `turn.complete` false->true transition, and `complete` is derived from the slot's running flag, which ChatPage re-reconciles from EVERY slots broadcast. A broadcast catching the slot momentarily idle between tool calls flipped it true mid-turn and fired the collapse. An explicit click now pins that state. Tests: 21 cases. The shared mechanism is proven once (durability across unmount, per-key isolation, reset on slot switch, and the no-provider fallback), rather than re-proving it per component. Writing those tests caught a real bug in the hook itself, where a stale local shadow survived reset and leaked a choice across a slot switch. Component-level cases cover the tool pill and the turn-level toggle, including running-flag churn, and every one is revert-verified: reverting a half fails the cases attributed to it. Five cases guard the opposite direction and fail if a pin is widened into disabling an intended auto-collapse. One `it.fails` case pins a separate pre-existing defect, where promoting loose items into a turn remounts the row. TaskProgressBar and KnowledgeBubbleChip are migrated to the hook but not yet keyed: the progress bar renders outside the virtualised list so it is not exposed, and the knowledge chip has no stable identity at its call site. Both fall back to local state, exactly as before.
Open PR relationship auditThis is a consolidated, point-in-time code-level audit note. It compares complete merge-base diffs and current/merged code; it does not treat a shared topic as duplication or partial coverage as completion. Relationship findings
No PR, Issue, label, branch, or review state was changed by the relationship-note portion of this audit. |
Problem
Expanding anything collapsible in the chat transcript while an agent is working
does not stick. A tool call, a thinking block, a workflow card: you open it, and
a moment later it closes on its own. Re-opening does not help, and it keeps
closing for as long as the turn runs. It looks intermittent, because whether it
happens depends on scroll position and streaming timing rather than on anything
the user did.
Why it matters
Reading tool calls and reasoning as they stream is the main way a user follows
what an agent is doing during a long turn, and it is how they catch a wrong
command early. A disclosure control that discards an explicit click is worse
than one that never opened: the user keeps re-clicking, loses their place in the
transcript each time, and reasonably concludes the control is broken. It lands
at the moment attention matters most, mid-turn while work is in flight.
Fix (symptoms → root cause → change)
This is a bug class, not one bug, and that is the important part of the
diagnosis.
The transcript is virtualised.
useVirtualChatrenders a row only whileitem.mountedholds, and unmounts it once the row leaves the window plusoverscan band, which streaming does routinely as it scrolls content past. Every
collapsible control in the transcript kept its open/closed state in row-local
useState, so each recycle destroyed it. Nine controls had the defect:ThinkingBlock,ToolCallLine,TurnBlock,CollapsibleToolGroup,NudgeCard,RecoveryCard,WorkflowCompletionCard,FileChangeChips,TaskProgressBar.ToolCallLinealready carried a comment naming "virtualizer recycling" as aknown hazard for a neighbouring concern, so the mechanism was known, just not
followed through to disclosure state.
A guard cannot fix this. A remount discards the guard along with the state it
protects, so the state has to outlive the row.
Change: one shared mechanism.
useRowDisclosure(key, fallback)inwebsite/src/pages/chat/rowDisclosure.tsx, backed by an external store withper-key subscriptions via
useSyncExternalStore. Per-key subscription is adeliberate choice over holding the map in provider state: the latter re-renders
every consumer on every toggle, and these are the rows whose render cost is the
most sensitive in the app. The hook degrades to plain local state when no
provider or key is present, so hosts that render transcript components outside
ChatPage (split-view
ChatPane, app-sdk'sChatMessageList) keep workinguntouched. An entry exists only once the user has made an explicit choice, so a
control that was never touched keeps its previous default behaviour.
ChatPagesupplies the store and resets it on slot switch, since row keys are unique only
within a slot.
A second, independent defect in
TurnBlockis also fixed. It collapsed onany
turn.completefalse→true transition, andcompleteis derived from theslot's running flag, which
ChatPagere-reconciles from every slotsbroadcast, accepting any
running: falsenot shadowed by a locally pendingturn. A broadcast catching the slot momentarily idle between tool calls flipped
it true mid-turn and fired the collapse. An explicit click now pins that state.
Tests
21 cases across four files. The mechanism is proven once rather than
re-proven per component: durability across unmount/remount, per-key isolation,
reset on slot switch, and the no-provider fallback. Component-level cases then
cover the tool pill and the turn-level toggle, including running-flag churn.
Writing the mechanism tests caught a real bug in the hook itself:
expanded = stored ?? locallet a stale localtruesurvivereset(), leaking a choiceacross a slot switch. The store has to be the sole source of truth when
present. That case now guards it.
Six cases fail without the fix:
running:falseframeFive more guard the opposite direction and pass in both states, covering
explicit collapse, an untouched control still auto-collapsing, per-key
isolation, and a new turn not disturbing an earlier one. Mutating either pin to
be too broad fails one of them, so the fix cannot be widened into disabling
intended behaviour unnoticed.
One
it.failscase pins a separate pre-existing defect found whilediagnosing this:
flushTurnonly emits aturnobject once the trailing groupexceeds
items.length > 2, so crossing that threshold wraps the items inTurnBlockand remounts every row beneath it.it.failsasserts the defect isstill present, so fixing the promotion turns the test red and forces the flip to
itrather than rotting silently. Follows existing in-repo precedent(
website/src/i18n/memoBailout.test.tsx).Manual verification
Reported from real use, and reproduced by hand across three separate controls
(the turn-level toggle, the tool pill, the thinking block). Each iteration was
built and synced to a live gateway for hands-on confirmation.
Diagnosis was instrumented rather than assumed. Mount counters separated a
re-render (state preserved) from a remount (state destroyed), which is what
identified the virtualizer recycle as the dominant path and ruled out an earlier
suspect: a 60 Hz style-recalc loop from a never-resolving spinner, measured and
excluded because it produces zero DOM mutations and so never re-renders
React. That is a real but separate CPU-waste issue, not folded in here.
Worth recording, because it explains the shape of this change. The first pass
fixed only the flag-churn mechanism and asserted the remount was "no longer
urgent once disclosure is pinned". That was wrong: a remount takes the guard
with it. The second pass fixed the remount for the wrong control, the
turn-level summary rather than the tool pill the report was about. Only after a
third report did enumerating every expandable control in the transcript
reveal this as a class, at which point a shared mechanism replaced the two
bespoke patches. Fixing controls one at a time was the error; the class fix is
the correction.
Two controls are migrated to the hook but intentionally not keyed, and fall
back to local state exactly as before:
TaskProgressBarrenders outside thevirtualised list so it was never exposed, and
KnowledgeBubbleChiphas nostable identity at its call site.
Also worth flagging for reviewers: the migration initially introduced three new
react-hooks/exhaustive-depswarnings, becausesetExpandedis now a memoizedcallback rather than a stable
useStatesetter.tscwas clean throughout andwould not have caught it, and the warning ratchet would have failed the build.
Fixed by adding it to the three dependency arrays.
Screenshots
Not applicable, and worth being explicit rather than silently dropping the
section. This changes temporal behaviour, not appearance: no pixel differs
in any single frame, because the bug is that a correctly-rendered open panel
closes a moment later. A still of either state looks identical before and after,
so it would be evidence of nothing. The behaviour over time is what the six
revert-verified cases capture, reproducing the exact frame sequences that
trigger it.
Follow-ups (not in this PR)
ToolCallLineandTurnBlockstill use ChatPage-state props from the earlier bespoke passes.Behaviourally equivalent to the hook, but two mechanisms for one job is
worth collapsing.
syncSlotRunningFromServeracceptsa
running: falsethat contradicts an in-flight turn. Whether it shouldreject that is a separate question with its own blast radius, and it is a
plausible cause of other transient UI churn. This PR makes the disclosure
controls robust either way.
it.failscase. Rowidentity across that promotion is structural. Disclosure no longer pays for
it, but other row-local state remains exposed.
/api/sessions/usagereturns null because bothnullandundefinedfallinto the same falsy branch. Measured, unrelated to this bug, unfixed.