>({})
+ const setToolDisclosureFor = useCallback((key: string, expanded: boolean) => {
+ setToolDisclosure((prev) => ({ ...prev, [key]: expanded }))
+ }, [])
+ const renderers = useMemo(
+ () => createTranscriptRenderers({
+ slot: slotKey,
+ toolDisclosure,
+ onToolDisclosureChange: setToolDisclosureFor,
+ }),
+ [slotKey, toolDisclosure, setToolDisclosureFor],
+ )
const ddInputCls = 'w-full px-2 py-1 text-[13px] font-body bg-bg border border-border rounded text-text outline-none focus:border-accent'
@@ -371,7 +388,7 @@ export default function ChatPane({
{messages.length === 0 && !running && (
{i18nT('components.chatPane.session_ready_type_a_message_to_start')}
)}
-
+
diff --git a/website/src/pages/chat/transcriptRenderers.tsx b/website/src/pages/chat/transcriptRenderers.tsx
new file mode 100644
index 00000000000..f940585b5d1
--- /dev/null
+++ b/website/src/pages/chat/transcriptRenderers.tsx
@@ -0,0 +1,246 @@
+/**
+ * transcriptRenderers — the dashboard's row set for the shared chat transcript.
+ *
+ * The single-chat surface (ChatPage) draws its rows from a local role chain.
+ * Every OTHER dashboard surface draws through app-sdk/ChatMessageList, whose
+ * default registry is deliberately store-free and therefore renders a WEAKER
+ * transcript: a static pill instead of the live tool line, and nothing at all
+ * for a thinking trace, a sent file, an auto-nudge turn, a workflow launch, a
+ * sub-agent launch, a recovery inject or a workflow completion. This module
+ * carries ChatPage's row set as registry entries so a second surface reads the
+ * SAME transcript rather than a reduced one.
+ *
+ * It lives under pages/chat rather than in app-sdk on purpose: the registry's
+ * own module must stay importable by consumers that have no Redux store at all,
+ * so anything store-connected is supplied BY the host as an entry — which is
+ * exactly what this is.
+ *
+ * The returned array is merged AHEAD of the SDK defaults (see mergeRenderers),
+ * so an entry reusing a default's `id` REPLACES it, a new `id` ADDS a row type,
+ * and a narrow entry must precede the broader one it refines.
+ */
+import ThinkingBlock from './ThinkingBlock'
+import ToolCallLine from './ToolCallLine'
+import StopEventCard from './StopEventCard'
+import NudgeCard, { nudgeMatchesLoop } from './NudgeCard'
+import RecoveryCard, { parseRecoveryMessage } from './RecoveryCard'
+import { ErrorCard } from './ErrorCard'
+import WorkflowRunCard, { extractWorkflowRunId, isWorkflowRunTool } from './WorkflowRunCard'
+import SubagentRunCard, { extractSpawnRunLaunch, isSpawnRunTool } from './SubagentRunCard'
+import WorkflowCompletionCard, { isWorkflowCompletionMessage } from './WorkflowCompletionCard'
+import SubagentCompletionCard from './SubagentCompletionCard'
+import { isSubagentCompletionMessage, type ParsedSubagentCompletion } from './subagentCompletion'
+import { FileCard } from '../../components/FileCard'
+import type { MessageRenderer, MessageRenderContext } from '../../app-sdk/messageRenderers'
+import type { ChatMessage } from '../../types'
+
+export interface TranscriptRendererOptions {
+ /** Slot these rows belong to. The tool line keys its per-slot log off it. */
+ slot: string
+ /** Open a file in the host's side panel. */
+ onFileOpen?: (path: string, opts?: { line?: number; endLine?: number }) => void
+ /** Open a directory in the host's side panel. */
+ onFolderOpen?: (path: string) => void
+ /** "Show in side panel" on a sub-agent completion card. */
+ onOpenSubagentPanel?: (parsed: ParsedSubagentCompletion) => void
+ /** Expanded-state map for tool rows, held ABOVE the row: a virtualised or
+ * remounted transcript unmounts the row and would otherwise forget it. */
+ toolDisclosure?: Record
+ onToolDisclosureChange?: (key: string, expanded: boolean) => void
+ /** Whether an MCP app may be revealed in the panel, and how. */
+ appInPanel?: boolean
+ onOpenApp?: (toolCallId: string) => void
+ /** Id of the auto-nudge loop this surface can open, plus the opener. The
+ * match rule stays here so a host never re-implements it. */
+ activeNudgeLoopId?: string | null
+ onOpenNudgeLoop?: () => void
+ /** Turn-recovery state for the error row's Continue button. Omitted → the
+ * row renders without one, which is correct for a surface that cannot
+ * continue a turn. */
+ continuable?: boolean
+ interrupted?: boolean
+ continuing?: boolean
+ onContinue?: () => void
+}
+
+/** Index of the last `error` row, so only that one offers Continue. Derived
+ * from the transcript the list already handed us rather than asked of the
+ * host, which would let the two drift apart. */
+function lastErrorIndex(messages: ChatMessage[]): number {
+ for (let j = messages.length - 1; j >= 0; j--) if (messages[j].role === 'error') return j
+ return -1
+}
+
+export function createTranscriptRenderers(
+ o: TranscriptRendererOptions,
+): readonly MessageRenderer[] {
+ const toolLine = (m: ChatMessage, ctx: MessageRenderContext) =>
+ ctx.row(
+ ,
+ true,
+ )
+
+ return [
+ // ── Shape-matched rows, ahead of anything keyed only by role ──
+ {
+ // Replaces the default's inline danger line with the real card.
+ id: 'stop_event',
+ roles: ['*'],
+ match: m => m.kind === 'stop_event' || m.meta?.kind === 'stop_event',
+ render: (m, ctx) => ctx.row(),
+ },
+ {
+ // Replaces the default: same card, but wired to open a folder and the
+ // side panel the way the single-chat surface does.
+ id: 'subagent_completion',
+ roles: ['*'],
+ match: isSubagentCompletionMessage,
+ render: (m, ctx) => (
+
+ ),
+ },
+
+ // ── Tool rows: the two launch cards refine the generic line, so they
+ // must be resolved before it. Both reuse the shared predicate the
+ // grouping logic uses, so a launch card and TurnBlock can never
+ // disagree about whether a row is a launch. ──
+ {
+ id: 'workflow_run_tool',
+ roles: ['tool'],
+ match: m => !!m.content?.startsWith('🔧') && isWorkflowRunTool(m),
+ render: (m, ctx) => {
+ const runId = extractWorkflowRunId(m)
+ // The match already proved this, but a null here must draw the generic
+ // line rather than crash the row.
+ if (!runId) return toolLine(m, ctx)
+ return ctx.row()
+ },
+ },
+ {
+ id: 'subagent_run_tool',
+ roles: ['tool'],
+ match: m => !!m.content?.startsWith('🔧') && isSpawnRunTool(m),
+ render: (m, ctx) => {
+ const launch = extractSpawnRunLaunch(m)
+ if (!launch) return toolLine(m, ctx)
+ return ctx.row()
+ },
+ },
+ {
+ // Replaces the default pill with the live, store-connected tool line:
+ // purpose label, expandable detail, elapsed time, file affordance, MCP
+ // app reveal. The 🔧 guard is the default's and must be kept — the
+ // hidden 🚫 deny sibling shares this role and is never drawn.
+ id: 'tool',
+ roles: ['tool'],
+ match: m => !!m.content?.startsWith('🔧'),
+ render: toolLine,
+ },
+
+ // ── Rows the default registry leaves undrawn ──
+ {
+ // The default registry draws nothing for a thinking trace. It carries
+ // real content, so it gets its own block.
+ //
+ // LIMITATION: `thinking` is in GROUPED_ROLES, so this row renders INSIDE
+ // the collapsible group rather than standalone the way the single-chat
+ // surface renders it. Opting a grouped role out of the group is not an
+ // extension point yet — tracked in #2940.
+ id: 'thinking_block',
+ roles: ['thinking'],
+ render: (m, ctx) => (m.content ? ctx.row() : null),
+ },
+ {
+ // Replaces the default's null with the player / download card.
+ id: 'file',
+ roles: ['file'],
+ render: (m, ctx) => {
+ let file
+ try {
+ file = JSON.parse(m.content)
+ } catch {
+ return null
+ }
+ return ctx.row()
+ },
+ },
+ {
+ // No default entry: an auto-nudge turn would draw nothing at all.
+ id: 'nudge',
+ roles: ['nudge'],
+ render: (m, ctx) =>
+ ctx.row(
+ ,
+ ),
+ },
+ {
+ // Refines `inject`: a synthetic turn-recovery injection is a one-line
+ // card, not the cron-notification bubble the default draws.
+ id: 'recovery_inject',
+ roles: ['inject'],
+ match: m => parseRecoveryMessage(m.content) !== null,
+ render: (m, ctx) => {
+ const parsed = parseRecoveryMessage(m.content)
+ if (!parsed) return null
+ return ctx.row()
+ },
+ },
+ {
+ // Refines `assistant`: an injected workflow completion is a compact
+ // status card, not a full markdown reply.
+ id: 'workflow_completion',
+ roles: ['assistant'],
+ match: isWorkflowCompletionMessage,
+ render: (m, ctx) => (
+
+ ),
+ },
+ {
+ // Replaces the default's bare div: same text, plus the Continue
+ // affordance on the LAST error when a turn was interrupted.
+ id: 'error',
+ roles: ['error'],
+ render: (m, ctx) =>
+ ctx.row(
+ ,
+ ),
+ },
+ ]
+}
diff --git a/website/src/test/transcriptRenderers.test.tsx b/website/src/test/transcriptRenderers.test.tsx
new file mode 100644
index 00000000000..55febee9e7c
--- /dev/null
+++ b/website/src/test/transcriptRenderers.test.tsx
@@ -0,0 +1,192 @@
+/**
+ * Contract for the dashboard's transcript row set.
+ *
+ * The registry's own defaults are store-free and therefore draw a REDUCED
+ * transcript — a static pill for a tool call, and nothing at all for a thinking
+ * trace, a sent file, an auto-nudge turn, a workflow or sub-agent launch, a
+ * recovery inject or a workflow completion. This module supplies the
+ * store-connected set, so what is pinned here is that every one of those rows
+ * resolves to an entry that actually DRAWS something, and that the narrow
+ * entries win over the broad ones they refine.
+ *
+ * The ordering assertions are the load-bearing ones. `mergeRenderers` normally
+ * guarantees that a shape-matched default (a stop event, a sub-agent
+ * completion) outranks anything keyed only by role. This module REPLACES both
+ * of those defaults, so after the merge there are no shape-matched defaults
+ * left and that guarantee is carried by this module's own array order instead.
+ * Reordering the returned array can therefore silently let a role claim swallow
+ * a stop event, which is exactly what these tests exist to catch.
+ */
+import { describe, it, expect } from 'vitest'
+import type { ReactElement } from 'react'
+import type { ChatMessage } from '../types'
+import { mergeRenderers, resolveRenderer, type MessageRenderContext } from '../app-sdk/messageRenderers'
+import { createTranscriptRenderers } from '../pages/chat/transcriptRenderers'
+import { isWorkflowRunTool } from '../pages/chat/WorkflowRunCard'
+import { isSpawnRunTool } from '../pages/chat/SubagentRunCard'
+import { isWorkflowCompletionMessage } from '../pages/chat/WorkflowCompletionCard'
+import { isSubagentCompletionMessage } from '../pages/chat/subagentCompletion'
+import { parseRecoveryMessage } from '../pages/chat/RecoveryCard'
+
+const msg = (role: string, over: Partial = {}): ChatMessage =>
+ ({ role, content: '', cls: '', ...over }) as ChatMessage
+
+/** The registry a split-view pane actually renders through. */
+const registry = (opts: Parameters[0] = { slot: 's1' }) =>
+ mergeRenderers(createTranscriptRenderers(opts))
+
+const idFor = (m: ChatMessage, opts?: Parameters[0]) =>
+ resolveRenderer(m, registry(opts))?.id
+
+/** Identity `row`/`wrapper` so a render returns the card element itself. */
+const ctx = (over: Partial = {}): MessageRenderContext => ({
+ index: 0,
+ messages: [],
+ running: false,
+ key: 'k0',
+ hideCardOwnedOAuth: false,
+ autoDeniedIds: new Set(),
+ wrapper: (children) => children,
+ row: (children) => children,
+ ...over,
+})
+
+function render(m: ChatMessage, opts?: Parameters[0], over?: Partial) {
+ const entry = resolveRenderer(m, registry(opts))
+ return entry?.render(m, ctx(over))
+}
+
+// Fixtures for the two launch rows, checked against the SHARED predicates the
+// grouping logic uses — a fixture that stopped matching would otherwise make
+// the ordering assertions below pass for the wrong reason.
+const workflowLaunch = msg('tool', {
+ content: '🔧 workflow_run',
+ meta: { output: 'Started workflow run `wf_abc123`' },
+})
+const subagentLaunch = msg('tool', {
+ content: '🔧 spawn_run',
+ meta: { output: 'Spawned 2 subagent(s).\n 1a2b3c4d (kirocrew): read specs\n 5e6f7a8b (kirocrew): read code' },
+})
+
+describe('fixtures match the shared launch predicates', () => {
+ it('is a workflow launch and a spawn launch respectively', () => {
+ expect(isWorkflowRunTool(workflowLaunch)).toBe(true)
+ expect(isSpawnRunTool(subagentLaunch)).toBe(true)
+ })
+})
+
+describe('rows the default registry leaves undrawn', () => {
+ it('draws a thinking trace, a sent file and an auto-nudge turn', () => {
+ expect(idFor(msg('thinking', { content: 'weighing options' }))).toBe('thinking_block')
+ expect(idFor(msg('nudge', { content: '[cycle 3]' }))).toBe('nudge')
+ expect(idFor(msg('file', { content: '{"filename":"a.png"}' }))).toBe('file')
+ })
+
+ it('actually renders them, rather than resolving to an entry that draws nothing', () => {
+ expect(render(msg('thinking', { content: 'weighing options' }))).toBeTruthy()
+ expect(render(msg('nudge', { content: '[cycle 3]' }))).toBeTruthy()
+ expect(render(msg('file', { content: '{"filename":"a.png"}' }))).toBeTruthy()
+ })
+
+ it('draws nothing for a thinking row with no content, matching the single-chat surface', () => {
+ expect(render(msg('thinking', { content: '' }))).toBeNull()
+ })
+
+ it('survives a file row whose payload is not JSON', () => {
+ expect(render(msg('file', { content: 'not json' }))).toBeNull()
+ })
+})
+
+describe('narrow rows win over the broad row they refine', () => {
+ it('routes the two tool launches to their cards, not the generic tool line', () => {
+ expect(idFor(workflowLaunch)).toBe('workflow_run_tool')
+ expect(idFor(subagentLaunch)).toBe('subagent_run_tool')
+ expect(idFor(msg('tool', { content: '🔧 grep' }))).toBe('tool')
+ })
+
+ it('routes a recovery inject to its card and leaves a cron inject alone', () => {
+ const recovery = msg('inject', { content: '[Stalled turn — automatic recovery]\nplease continue' })
+ // Guard the fixture: a parse miss would make this pass as a plain inject.
+ expect(parseRecoveryMessage(recovery.content)).not.toBeNull()
+ expect(idFor(recovery)).toBe('recovery_inject')
+ expect(idFor(msg('inject', { content: 'ordinary injection' }))).toBe('inject')
+ })
+
+ it('routes a workflow completion to its card and leaves a plain reply alone', () => {
+ const completion = msg('assistant', {
+ content: '[Workflow completion event]\nWorkflow `demo` (wf_abc123) → **finished**\nResult: ok\n',
+ })
+ expect(isWorkflowCompletionMessage(completion)).toBe(true)
+ expect(idFor(completion)).toBe('workflow_completion')
+ expect(idFor(msg('assistant', { content: 'hello' }))).toBe('assistant')
+ })
+})
+
+describe('the tool row keeps the deny-sibling guard', () => {
+ it('claims only the visible 🔧 message', () => {
+ // The hidden 🚫 sibling shares the role and is read for the auto-denied
+ // flag — drawing it would double the row.
+ expect(idFor(msg('tool', { content: '🚫 denied by policy' }))).toBeUndefined()
+ expect(idFor(msg('tool', { content: 'plain text' }))).toBeUndefined()
+ })
+
+ it('does not treat a launch-shaped output as a launch without the 🔧 prefix', () => {
+ const denied = msg('tool', { content: '🚫 denied', meta: { output: 'Started workflow run `wf_abc123`' } })
+ expect(idFor(denied)).toBeUndefined()
+ })
+})
+
+describe('shape still beats role after the defaults are replaced', () => {
+ it('draws a stop event as a stop event whatever role carries it', () => {
+ expect(idFor(msg('assistant', { kind: 'stop_event' }))).toBe('stop_event')
+ expect(idFor(msg('notice', { meta: { kind: 'stop_event' } }))).toBe('stop_event')
+ // The regression this guards: `nudge`, `error` and `file` are claimed by
+ // this module BY ROLE, and a stop event can travel on any of them.
+ expect(idFor(msg('nudge', { kind: 'stop_event' }))).toBe('stop_event')
+ expect(idFor(msg('error', { kind: 'stop_event' }))).toBe('stop_event')
+ })
+
+ it('keeps the sub-agent completion card ahead of the role rows', () => {
+ const completion = msg('subagent', {
+ content: '[Subagent completion event]\nAgent `1a2b3c4d` (kirocrew) ✅ completed\nTask: read specs\n',
+ })
+ expect(isSubagentCompletionMessage(completion)).toBe(true)
+ expect(idFor(completion)).toBe('subagent_completion')
+ })
+})
+
+describe('the error row offers Continue only where the single-chat surface does', () => {
+ const errs = [msg('error', { content: 'first' }), msg('assistant', { content: 'x' }), msg('error', { content: 'last' })]
+ const recoverable = { slot: 's1', continuable: true, interrupted: true, onContinue: () => undefined }
+
+ it('offers it on the last error only', () => {
+ const last = render(errs[2], recoverable, { index: 2, messages: errs }) as ReactElement
+ const first = render(errs[0], recoverable, { index: 0, messages: errs }) as ReactElement
+ expect(last.props.onContinue).toBeTypeOf('function')
+ expect(first.props.onContinue).toBeUndefined()
+ })
+
+ it('withholds it when the turn was not interrupted', () => {
+ const el = render(errs[2], { ...recoverable, interrupted: false }, { index: 2, messages: errs }) as ReactElement
+ expect(el.props.onContinue).toBeUndefined()
+ })
+
+ it('withholds it on a surface that cannot continue a turn', () => {
+ const el = render(errs[2], { slot: 's1' }, { index: 2, messages: errs }) as ReactElement
+ expect(el.props.onContinue).toBeUndefined()
+ })
+})
+
+describe('rows the defaults already draw correctly are left to them', () => {
+ it('keeps the default entry for the rows this module does not claim', () => {
+ expect(idFor(msg('user'))).toBe('user')
+ expect(idFor(msg('streaming'))).toBe('assistant')
+ expect(idFor(msg('notice'))).toBe('notice')
+ expect(idFor(msg('mcp_oauth'))).toBe('mcp_oauth')
+ expect(idFor(msg('tool_call'))).toBe('tool_lifecycle')
+ expect(idFor(msg('tool_result'))).toBe('tool_lifecycle')
+ // Still deliberately undrawn, and still resolving to an ENTRY that says so.
+ expect(idFor(msg('queued'))).toBe('undrawn')
+ expect(idFor(msg('system'))).toBe('undrawn')
+ })
+})