refactor(chat-core): dispatch ChatPage's transcript rows through the renderer registry - #8713
Conversation
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsNo findings. False positive or not applicable? A repository writer can comment: |
Design Review (Fable 5) — ✅ PASSDesign-level review of Design-Verdict: PASS A real double-dispatch defect class, closed at the mechanism level with verbatim-moved bodies, pinned fallback semantics, and an explicitly staged convergence path — sound and proportionate. Watch
[DESIGN-REVIEWED] 7e823e0 |
UX Review (Fable 5) — ✅ PASSUX-level review of This PR is a pure internal refactor: ChatPage's transcript row dispatch moves from an inline if-chain to the shared renderer registry, with each row type carried over verbatim (same components, same precedence order, same fall-through-to-bubble behavior for unclaimed roles). I verified:
UX-Verdict: PASS Zero rendered-pixel change: every transcript row keeps its exact component, chrome, and fall-through behavior, and the parity tests pin unclaimed roles to stay visible. [UX-REVIEWED] 7e823e0 |
First Principles Review (Fable 5) — ✅ PASSPremise-level review of I've read the contract, the intent file, the full patch, and the surrounding repository ( First-Principles-Verdict: PASS Deletes ChatPage's hand-maintained copy of the registry's dispatch decision, closing the What this change shipsIntent: make the main chat resolve transcript rows through the one registry every other surface uses, so a role registered once renders everywhere. This is a FIX (of a defect class, previously held shut only by a parity test).
On item 10 I verified the deferral is honest rather than a dodge: the page entries carry chrome ( [FIRST-PRINCIPLES-REVIEWED] 7e823e0 |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsI've independently traced the refactor. Let me verify my analysis of the merge ordering is complete by confirming the one remaining risk: whether any kept default could shadow a page entry. The kept (non-overridden) defaults are I verified: The single candidate ("No candidates") died at discovery, and my falsification finds no new grounded defect. No findings. [OPUS-REVIEWED] 7e823e0 Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
e5e7539 to
8e10fda
Compare
|
GPT — BLOCKING: Accepted — all four lanes, one bug, and the description and test comment both asserted the opposite of what shipped. |
|
First Principles — Watch: "renders everywhere by construction" holds only for the SDK defaults; the dashboard's rich rows still live in two host lists (
|
…TranscriptRenderers After P5-a the dashboard's rich transcript rows lived in two hand-kept host lists: ChatPage's and pages/chat/transcriptRenderers.tsx's createTranscriptRenderers (ChatPane's). They shared ids but not code, so a row could still diverge between the page and a pane. The factory now carries the page's behaviours behind options with the pane's defaults -- toolDisclosureKey (the #8204 tool_call_id fold), toolRunning (the page's trailing-group rule), transcriptHot, and the completion cards' session hand-offs (onSessionOpen / sessions / activeSession); `slot` becomes optional (the page reads the active slot). ChatPage's host list is the factory spread plus its page-only rows: the bubble, stop_event, notice, permission, the narrow undrawn set, mcp_oauth, the hidden invisible-assistant skip, the file variant that falls through to the bubble (ahead of the spread, so no row's output changes), and tool_completion (the deny/complete sibling draws nothing; claimed because this page's unclaimed-role fallback is the bubble). ctx.row / ctx.wrapper are keyed Fragments, so a shared row lands in the DOM exactly as the page's own entry did. Tests: transcriptRenderers.test's role-by-role drift guard against the page's if-chain becomes a structural check that the page spreads this factory and keeps no private copy of a shared row; the parity contract counts the factory's ids as host entries; RecoveryCard's shape test follows the entry into the factory. All ChatPage and ChatPane suites pass unchanged. RFC chat-core extraction, P5-b (stacked on P5-a, #8713).
…TranscriptRenderers After P5-a the dashboard's rich transcript rows lived in two hand-kept host lists: ChatPage's and pages/chat/transcriptRenderers.tsx's createTranscriptRenderers (ChatPane's). They shared ids but not code, so a row could still diverge between the page and a pane. The factory now carries the page's behaviours behind options with the pane's defaults -- toolDisclosureKey (the #8204 tool_call_id fold), toolRunning (the page's trailing-group rule), transcriptHot, and the completion cards' session hand-offs (onSessionOpen / sessions / activeSession); `slot` becomes optional (the page reads the active slot). ChatPage's host list is the factory spread plus its page-only rows: the bubble, stop_event, notice, permission, the narrow undrawn set, mcp_oauth, the hidden invisible-assistant skip, the file variant that falls through to the bubble (ahead of the spread, so no row's output changes), and tool_completion (the deny/complete sibling draws nothing; claimed because this page's unclaimed-role fallback is the bubble). ctx.row / ctx.wrapper are keyed Fragments, so a shared row lands in the DOM exactly as the page's own entry did. Tests: transcriptRenderers.test's role-by-role drift guard against the page's if-chain becomes a structural check that the page spreads this factory and keeps no private copy of a shared row; the parity contract counts the factory's ids as host entries; RecoveryCard's shape test follows the entry into the factory. All ChatPage and ChatPane suites pass unchanged. RFC chat-core extraction, P5-b (stacked on P5-a, #8713).
…renderer registry ChatPage.renderMessage was a ~200-line if-chain over message roles, kept in step with app-sdk/messageRenderers by hand and by a parity test; the mcp_oauth defect (wired in the registry, raw text in the main chat) lived in that gap. The page now resolves every row through the same registry the other surfaces consume: its chrome (tool disclosure state, fork/pin/footer, the error card's Continue, the nudge card's Loop button, the mcp_oauth connections gate, session/folder/panel hand-offs on the completion cards) rides as host entries that reuse the default ids they override, plus documented page-only shape entries (reasoning, inject_recovery, permission, workflow_completion, hidden_invisible_assistant, bubble). Entry order is the if-chain's precedence order; an unclaimed role still renders as the bubble, which is what the fall-through did. No row's output changes. The parity contract narrows accordingly: it now asserts the dispatch is registry-driven (no `if (m.role === ...)` in the renderer block), that every host entry overrides a default or is documented page-only, and that the roles the page's chrome still names are registry-claimed; the chrome allowlist shrinks to `permission`. Four source-shape tests that pinned the old branch text now pin the equivalent entry. RFC chat-core extraction, P5-a (render dispatch layer only).
8e10fda to
7e823e0
Compare
…TranscriptRenderers After P5-a the dashboard's rich transcript rows lived in two hand-kept host lists: ChatPage's and pages/chat/transcriptRenderers.tsx's createTranscriptRenderers (ChatPane's). They shared ids but not code, so a row could still diverge between the page and a pane. The factory now carries the page's behaviours behind options with the pane's defaults -- toolDisclosureKey (the #8204 tool_call_id fold), toolRunning (the page's trailing-group rule), transcriptHot, and the completion cards' session hand-offs (onSessionOpen / sessions / activeSession); `slot` becomes optional (the page reads the active slot). ChatPage's host list is the factory spread plus its page-only rows: the bubble, stop_event, notice, permission, the narrow undrawn set, mcp_oauth, the hidden invisible-assistant skip, the file variant that falls through to the bubble (ahead of the spread, so no row's output changes), and tool_completion (the deny/complete sibling draws nothing; claimed because this page's unclaimed-role fallback is the bubble). ctx.row / ctx.wrapper are keyed Fragments, so a shared row lands in the DOM exactly as the page's own entry did. Tests: transcriptRenderers.test's role-by-role drift guard against the page's if-chain becomes a structural check that the page spreads this factory and keeps no private copy of a shared row; the parity contract counts the factory's ids as host entries; RecoveryCard's shape test follows the entry into the factory. All ChatPage and ChatPane suites pass unchanged. RFC chat-core extraction, P5-b (stacked on P5-a, #8713).
…TranscriptRenderers After P5-a the dashboard's rich transcript rows lived in two hand-kept host lists: ChatPage's and pages/chat/transcriptRenderers.tsx's createTranscriptRenderers (ChatPane's). They shared ids but not code, so a row could still diverge between the page and a pane. The factory now carries the page's behaviours behind options with the pane's defaults -- toolDisclosureKey (the #8204 tool_call_id fold), toolRunning (the page's trailing-group rule), transcriptHot, and the completion cards' session hand-offs (onSessionOpen / sessions / activeSession); `slot` becomes optional (the page reads the active slot). ChatPage's host list is the factory spread plus its page-only rows: the bubble, stop_event, notice, permission, the narrow undrawn set, mcp_oauth, the hidden invisible-assistant skip, the file variant that falls through to the bubble (ahead of the spread, so no row's output changes), and tool_completion (the deny/complete sibling draws nothing; claimed because this page's unclaimed-role fallback is the bubble). ctx.row / ctx.wrapper are keyed Fragments, so a shared row lands in the DOM exactly as the page's own entry did. Tests: transcriptRenderers.test's role-by-role drift guard against the page's if-chain becomes a structural check that the page spreads this factory and keeps no private copy of a shared row; the parity contract counts the factory's ids as host entries; RecoveryCard's shape test follows the entry into the factory. All ChatPage and ChatPane suites pass unchanged. RFC chat-core extraction, P5-b (stacked on P5-a, #8713).
…TranscriptRenderers After P5-a the dashboard's rich transcript rows lived in two hand-kept host lists: ChatPage's and pages/chat/transcriptRenderers.tsx's createTranscriptRenderers (ChatPane's). They shared ids but not code, so a row could still diverge between the page and a pane. The factory now carries the page's behaviours behind options with the pane's defaults -- toolDisclosureKey (the #8204 tool_call_id fold), toolRunning (the page's trailing-group rule), transcriptHot, and the completion cards' session hand-offs (onSessionOpen / sessions / activeSession); `slot` becomes optional (the page reads the active slot). ChatPage's host list is the factory spread plus its page-only rows: the bubble, stop_event, notice, permission, the narrow undrawn set, mcp_oauth, the hidden invisible-assistant skip, the file variant that falls through to the bubble (ahead of the spread, so no row's output changes), and tool_completion (the deny/complete sibling draws nothing; claimed because this page's unclaimed-role fallback is the bubble). ctx.row / ctx.wrapper are keyed Fragments, so a shared row lands in the DOM exactly as the page's own entry did. Tests: transcriptRenderers.test's role-by-role drift guard against the page's if-chain becomes a structural check that the page spreads this factory and keeps no private copy of a shared row; the parity contract counts the factory's ids as host entries; RecoveryCard's shape test follows the entry into the factory. All ChatPage and ChatPane suites pass unchanged. RFC chat-core extraction, P5-b (stacked on P5-a, #8713).
chenmingwei23
left a comment
There was a problem hiding this comment.
Tier 1 auto-approve: refactor (7 files). Criteria: no conflict, no requested changes, security path denylist clean, design-doc gate clean, SAST annotations clean (Semgrep + CodeQL both success, 0 annotations, 0 alerts), security checklist all-NO, AI reviewers green. Category: pure render-dispatch refactor moving ChatPage transcript-row branches verbatim into the renderer registry, no behaviour change.
iamwhatever
left a comment
There was a problem hiding this comment.
Tier 1 auto-approve: refactor (7 files). Criteria: no conflict, no requested changes, security path denylist clean, design-doc gate clean, SAST annotations clean, security checklist all-NO, AI reviewers green. Category: code-move only -- ChatPage's ~200-line if (m.role === ...) render chain is replaced by resolveRenderer over mergeRenderers, with each branch body moved verbatim into a host entry; no component, prop, style or output change, and the parity contract test is narrowed rather than deleted.
…TranscriptRenderers After P5-a the dashboard's rich transcript rows lived in two hand-kept host lists: ChatPage's and pages/chat/transcriptRenderers.tsx's createTranscriptRenderers (ChatPane's). They shared ids but not code, so a row could still diverge between the page and a pane. The factory now carries the page's behaviours behind options with the pane's defaults -- toolDisclosureKey (the #8204 tool_call_id fold), toolRunning (the page's trailing-group rule), transcriptHot, and the completion cards' session hand-offs (onSessionOpen / sessions / activeSession); `slot` becomes optional (the page reads the active slot). ChatPage's host list is the factory spread plus its page-only rows: the bubble, stop_event, notice, permission, the narrow undrawn set, mcp_oauth, the hidden invisible-assistant skip, the file variant that falls through to the bubble (ahead of the spread, so no row's output changes), and tool_completion (the deny/complete sibling draws nothing; claimed because this page's unclaimed-role fallback is the bubble). ctx.row / ctx.wrapper are keyed Fragments, so a shared row lands in the DOM exactly as the page's own entry did. Tests: transcriptRenderers.test's role-by-role drift guard against the page's if-chain becomes a structural check that the page spreads this factory and keeps no private copy of a shared row; the parity contract counts the factory's ids as host entries; RecoveryCard's shape test follows the entry into the factory. All ChatPage and ChatPane suites pass unchanged. RFC chat-core extraction, P5-b (stacked on P5-a, #8713).
…TranscriptRenderers (#8733) After P5-a the dashboard's rich transcript rows lived in two hand-kept host lists: ChatPage's and pages/chat/transcriptRenderers.tsx's createTranscriptRenderers (ChatPane's). They shared ids but not code, so a row could still diverge between the page and a pane. The factory now carries the page's behaviours behind options with the pane's defaults -- toolDisclosureKey (the #8204 tool_call_id fold), toolRunning (the page's trailing-group rule), transcriptHot, and the completion cards' session hand-offs (onSessionOpen / sessions / activeSession); `slot` becomes optional (the page reads the active slot). ChatPage's host list is the factory spread plus its page-only rows: the bubble, stop_event, notice, permission, the narrow undrawn set, mcp_oauth, the hidden invisible-assistant skip, the file variant that falls through to the bubble (ahead of the spread, so no row's output changes), and tool_completion (the deny/complete sibling draws nothing; claimed because this page's unclaimed-role fallback is the bubble). ctx.row / ctx.wrapper are keyed Fragments, so a shared row lands in the DOM exactly as the page's own entry did. Tests: transcriptRenderers.test's role-by-role drift guard against the page's if-chain becomes a structural check that the page spreads this factory and keeps no private copy of a shared row; the parity contract counts the factory's ids as host entries; RecoveryCard's shape test follows the entry into the factory. All ChatPage and ChatPane suites pass unchanged. RFC chat-core extraction, P5-b (stacked on P5-a, #8713).
Problem / Motivation
ChatPage.renderMessagewas a ~200-lineif (m.role === …)chain deciding which component draws each transcript row — a second, hand-maintained copy of the decisionapp-sdk/messageRenderersalready makes for SideChat, ChatPane and ChatEmbed. The two had to be kept in step by hand and by a parity test, and the defect class that bought that test —mcp_oauthwired in the registry and rendered as raw text in the main chat — lived in the gap between them.Why it matters
RFC chat-core extraction P5-a: the first cut of the ChatPage slim-down, render-dispatch layer only. With this, every chat surface resolves rows through the one registry mechanism (
resolveRendererovermergeRenderers), so an SDK-default role registered once renders on every surface by construction and the parity test stops being the only thing standing between "registered" and "renders in the main chat". What this PR does not yet unify: the dashboard's rich rows still live in two host lists — ChatPage's new one andpages/chat/transcriptRenderers.tsx'screateTranscriptRenderers(ChatPane's). They now share ids (tool,file,nudge,recovery_inject,thinking_block,error,workflow_completion,subagent_completion) so they can converge by override, and that convergence — spreading the factory into ChatPage's list — is the next P5 cut, deliberately not bundled here so this PR stays a pure dispatch move. It also gives the later cuts (turn grouping, composer band) a page whose row dispatch is already data, not code.What changed (motivation → approach → change)
Dispatch (
ChatPage.tsx).renderMessageis nowresolveRenderer(m, chatPageRenderers).render(m, ctx), withchatPageRenderers = mergeRenderers([...])memoized on the same dependency list the old callback carried (UI-state deps deliberately stay in it so settled turns re-render on a behaviour change and the changed identity breaks throughmemo(TurnBlock)). The page's chrome rides as host entries:tool(WorkflowRunCard / SubagentRunCard /ToolCallLinewith disclosure state, MCP app panel hand-off, trailing-group animation),file,nudge(Loop button gated on the row's own loop),stop_event,error(Continue gated oncontinuable && interrupted && lastErrorIdx),notice,mcp_oauth(theconnectionsUiOngate),subagent_completion(session/folder/panel hand-offs).PAGE_ONLY_ENTRY_IDS:thinking_block(ThinkingBlock with page disclosure; same id astranscriptRenderers),recovery_inject(RecoveryCard,resolveInjectCarddecides — shared with the registry's inject row; same id astranscriptRenderers),permission(undrawn; grouped),workflow_completion,hidden_invisible_assistant, andbubble— the user / inject / assistant row with fork, pin, footer, regenerate, variants and search-scope chrome (one entry for three roles, so it keeps its own id rather than overridinguser+assistant+inject; the three defaults stay in the merged list but are never reached).undrawnwith a narrower role set than the SDK's (REASONING_ROLES+queued):system/doneare left unclaimed on purpose so they keep the if-chain's fall-through (the bubble) rather than taking the SDK's null — this store never carries them, but the safety net is the one the old code had.Entry order is the if-chain's precedence order (thinking_block → tool → file → nudge → stop_event → recovery_inject → error → notice → permission → undrawn → mcp_oauth → workflow completion → sub-agent completion → hidden invisible assistant → bubble). Roles none of these claim fall to the remaining registry defaults (
tool_lifecyclefor raw wire shapes the store normalizes away; the never-reacheduser/assistant/inject); a role nobody claims renders as the bubble by reference — the memo returns{ renderers, fallback: bubble }andrenderMessagedoes(entry ?? bubbleRenderer).render(...)— which is what the if-chain's fall-through did, so an unknown role stays visible. (Round 1 indexed the merged list's tail for this, which is the SDKundrawndefault and would have hidden an unregistered role; all four lanes caught it, and the parity test now pins the by-reference fallback.) The per-rowMessageRenderContextcarries the page's index/messages/running/key/onFileOpen/hideCardOwnedOAuth;wrapper/roware minimal keyed wrappers only a non-overridden default could reach.No row's output changes. Every branch body moved verbatim into its entry (
const i = ctx.index; const key = ctx.keyat the top); the unparseable-filecase still falls to the bubble by calling it explicitly.Parity contract (
chatRolesParity.contract.test.ts), narrowed. It now asserts (1) ChatPage importsmergeRenderers/resolveRendererand its renderer block contains noif (m.role === …)orswitch (m.role)dispatch; (2) the unclaimed-role fallback is the bubble by reference and the page'sundrawnleavessystem/doneunclaimed; (3) every host entry id either overrides a default or is in the documented page-only list, and that list carries no stale ids; (4) roles the page's remaining chrome logic names (footer rule, queue rail, last-error lookup, permission grouping) are registry-claimed or chrome-allowlisted — the allowlist shrinks from{queued, permission, streaming}to{permission}(the registry claims the other two); (5) no stale allowlist entry. The old "registry-only roles are orphaned in ChatPage" assertion is gone: the page consumes the registry, so an SDK-default role is covered by construction. The chrome check still keys on the.role === '…'extractor, as before; the structural assertion now also rejects aswitch, so a dispatch cannot come back in either spelling.Four source-shape tests re-pinned to the equivalent entry instead of the old branch text:
ChatPage.mcpOAuth,NoticeCard(registry wiring),RecoveryCard(card entry ordered before the bubble),invisibleText(skip entry ordered before the bubble).Not in this PR. No component, style, layout or behaviour change; no change to
app-sdk/messageRenderersitself; the turn grouper, virtualizer and composer band are untouched (later P5 cuts). The merge of ChatPage's host list withcreateTranscriptRenderersis the next cut (its header comment now says so instead of "ChatPage draws its rows from a local role chain").Tests
chatRolesParity.contract.test.ts(rewritten, 5): registry-driven dispatch (no if-chain, no switch); bubble fallback by reference +undrawnleaves system/done unclaimed; host-entry ids override-or-documented (+ no stale docs); remaining chrome literals claimed-or-allowlisted; no stale allowlist.ChatPage.mcpOAuth,NoticeCard,RecoveryCard,invisibleText: re-pinned to entry shapes.ChatPage*suites (63 files, 578 tests) pass as-is, plus the 15 non-ChatPage suites that readChatPage.tsxas source andmessageRenderers.test.ts/AppSdkMessageRenderersCov80(366 tests across 18 files).Local gates:
tsc -b, eslint on changed files,check-i18n-strings(0 added). The full vitest suite runs in CI.Manual verification
N/A — a pure dispatch refactor with verbatim branch bodies and deterministic coverage; no rendered output changes.
Why no screenshot: every entry's body is the old branch's body moved verbatim; the components, classes, keys and props each row receives are unchanged, so there is no pixel to show.
Related Issues
Checklist
Contribution License Agreement
By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.