diff --git a/temp-screenshots/split-view-rows/pane-tool-rows.png b/temp-screenshots/split-view-rows/pane-tool-rows.png new file mode 100644 index 00000000000..a1ad76b3243 Binary files /dev/null and b/temp-screenshots/split-view-rows/pane-tool-rows.png differ diff --git a/temp-screenshots/split-view-rows/pane-transcript.png b/temp-screenshots/split-view-rows/pane-transcript.png new file mode 100644 index 00000000000..97440185386 Binary files /dev/null and b/temp-screenshots/split-view-rows/pane-transcript.png differ diff --git a/website/src/components/ChatPane.tsx b/website/src/components/ChatPane.tsx index 7d3d2f43b7a..ee7e59dc3a9 100644 --- a/website/src/components/ChatPane.tsx +++ b/website/src/components/ChatPane.tsx @@ -6,8 +6,7 @@ import { SplitGlyph } from './SplitGlyph' import { useQuery, useMutation } from '@tanstack/react-query' import { useModelsDegraded } from '../providers/modelListHealth' import ChatMessageList from '../app-sdk/ChatMessageList' -import ToolCallLine from '../pages/chat/ToolCallLine' -import type { ChatMessage } from '../types' +import { createTranscriptRenderers } from '../pages/chat/transcriptRenderers' import ChatInput from './ChatInput' import PendingQuestionCard from './PendingQuestionCard' import QueueStack, { SubagentDeliveryProgress, splitPaneMessages } from './QueueStack' @@ -314,12 +313,30 @@ export default function ChatPane({ // response was lost, leaving this client in conflict with execution order. api.reorderQueuedMessages(slotKey, next).catch(() => undefined) }, [slotKey, allMessages, queuedMessages]) - // Split-view panes render tool calls with the full ToolCallLine (purpose / input / - // output / live status) instead of the SDK's bare pill. ToolCallLine's slot-aware - // selectors read THIS slot's per-slot tool log, so a background pane shows the same - // live tool detail as the main chat view. Injected as a render prop so - // app-sdk/ChatMessageList stays Redux-free for the embed SDK. - const renderTool = useCallback((m: ChatMessage) => , [slotKey, running]) + // Split-view panes draw the SAME transcript rows as the single-chat surface, + // through the SDK's row registry: the live ToolCallLine (purpose / input / + // output / live status), the workflow and sub-agent launch cards, thinking + // traces, sent files, auto-nudge turns, recovery injects, workflow + // completions. The SDK's built-in registry is store-free by design and so + // draws weaker rows — or nothing at all — for most of these; the + // store-connected set is supplied here as host entries instead, which is the + // registry's intended extension path and keeps app-sdk/ChatMessageList + // Redux-free for the embed SDK. + // + // The tool rows' expanded state is held ABOVE the rows: a row remounts + // whenever the message list updates, and would otherwise forget it. + const [toolDisclosure, setToolDisclosure] = useState>({}) + 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') + }) +})