Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
35 changes: 26 additions & 9 deletions website/src/components/ChatPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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) => <ToolCallLine message={m} running={running} slot={slotKey} />, [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<Record<string, boolean>>({})
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'

Expand Down Expand Up @@ -371,7 +388,7 @@ export default function ChatPane({
{messages.length === 0 && !running && (
<div className="text-center text-muted text-[13px] py-8">{i18nT('components.chatPane.session_ready_type_a_message_to_start')}</div>
)}
<ChatMessageList messages={messages} running={running} renderTool={renderTool} hideCardOwnedOAuth={connectionsUiOn} />
<ChatMessageList messages={messages} running={running} renderers={renderers} hideCardOwnedOAuth={connectionsUiOn} />
<div ref={endRef} />
</div>

Expand Down
246 changes: 246 additions & 0 deletions website/src/pages/chat/transcriptRenderers.tsx
Original file line number Diff line number Diff line change
@@ -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<string, boolean>
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(
<ToolCallLine
message={m}
running={ctx.running}
slot={o.slot}
onFileOpen={o.onFileOpen}
disclosure={o.toolDisclosure?.[ctx.key]}
disclosureKey={ctx.key}
onDisclosureChange={o.onToolDisclosureChange}
appInPanel={o.appInPanel}
onOpenApp={o.onOpenApp}
/>,
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(<StopEventCard message={m} />),
},
{
// 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) => (
<SubagentCompletionCard
key={ctx.key}
message={m}
onFileOpen={o.onFileOpen}
onFolderOpen={o.onFolderOpen}
disclosureKey={ctx.key}
onOpenPanel={o.onOpenSubagentPanel}
/>
),
},

// ── 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(<WorkflowRunCard runId={runId} message={m} />)
},
},
{
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(<SubagentRunCard launch={launch} slot={o.slot} />)
},
},
{
// 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(<ThinkingBlock content={m.content} disclosureKey={ctx.key} />) : 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(<FileCard file={file} />)
},
},
{
// No default entry: an auto-nudge turn would draw nothing at all.
id: 'nudge',
roles: ['nudge'],
render: (m, ctx) =>
ctx.row(
<NudgeCard
message={m}
disclosureKey={ctx.key}
onOpenLoop={
o.onOpenNudgeLoop && nudgeMatchesLoop(m, o.activeNudgeLoopId) ? o.onOpenNudgeLoop : undefined
}
/>,
),
},
{
// 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(<RecoveryCard parsed={parsed} disclosureKey={ctx.key} />)
},
},
{
// 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) => (
<WorkflowCompletionCard
key={ctx.key}
message={m}
onFileOpen={o.onFileOpen}
onFolderOpen={o.onFolderOpen}
disclosureKey={ctx.key}
/>
),
},
{
// 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(
<ErrorCard
content={m.content}
onContinue={
o.onContinue && o.continuable && o.interrupted && ctx.index === lastErrorIndex(ctx.messages)
? o.onContinue
: undefined
}
continuing={o.continuing}
/>,
),
},
]
}
Loading
Loading