Transcript view
The desktop app's chat transcript as a React component.
Transcript is the desktop app's chat transcript as a React component. It renders prompts, replies, thinking, tool calls and output, edit diffs, subagents, notices, compactions and run receipts. The fixture below has every row kind. Expand a tool call or the subagent, and switch the density.
Mount it#
Transcript takes events you already hold (a recorded session, your own store). HostTranscript reads them from the ChatHost in context, like the session list.
import type { ChatEvent } from 'zidane-ui/chat/headless'
import 'zidane-ui/styles.css'
import { Transcript } from 'zidane-ui/chat'
export function Replay({ id, events }: { id: string, events: readonly ChatEvent[] }) {
return <Transcript events={events} streaming={false} sessionId={id} />
}| Prop | Meaning |
|---|---|
events | ChatEvents, which are zidane StreamEvents with optional client fields. A replayed session works as it is |
streaming | A run is in flight, so the last reply can still grow |
sessionId | The session shown. A switch to another id shows the new rows without enter animations |
className, children | Extra classes for the root. children render after the rows (a menu that your app owns) |
HostTranscript takes sessionId and onOpenSession, plus every other Transcript prop. It reads host.transcript(id), a store of { events, running, hydrated, hasEarlier?, loadingEarlier? }. It adds these parts:
- A loading line until
hydratedis true. - An "Earlier messages" control while
hasEarlieris set. The control calls theloadEarliermethod of the transcript store. - The row actions, bound to the host.
import type { ChatHost } from 'zidane-ui/chat/headless'
import { ChatProvider, HostTranscript } from 'zidane-ui/chat'
export function Conversation({ host, id, onOpen }: { host: ChatHost, id: string, onOpen: (id: string) => void }) {
return (
<ChatProvider host={host}>
<HostTranscript sessionId={id} onOpenSession={onOpen} />
</ChatProvider>
)
}The preview runs on createFixtureHost({ sessions, transcripts }), which serves static events.
A prompt that the session's own user did not type in a window (a Slack or GitHub channel, an orchestration event) carries an origin on its user-prompt event. The transcript shows a short label above it. A channel participant reads Alice · slack, the session's user on another surface reads via slack, and an event shows its source. The row's origin is a PromptOrigin from zidane-ui/chat/headless: { kind, source, sender? }, zidane's TextOrigin without messageId. Override the words (promptOriginSender, promptOriginSomeone, promptOriginVia) through LabelsProvider.
Options#
options takes any subset of zidane's display settings of the same names, so stored settings pass straight through. Missing fields fall back to DEFAULT_TRANSCRIPT_OPTIONS (zidane's defaults). Each setting and its default: Display settings.
| Option | Values |
|---|---|
transcriptDensity | 'full', 'compact'. Compact folds thinking, tools and worker output into one expandable "Worked" row per exchange |
toolCallDisplay | 'hidden', 'formatted', 'full' |
showRowIcons, showThinking, thinkingAutoExpand, showToolResults, showEditDiffs, showFullShellOutput, groupToolCalls, hideSubagentOutput, showRunReceipt, showCacheNotices, codemodeShowCalls, showToolAttachments, renderDiagrams | boolean |
import type { TranscriptOptions } from 'zidane-ui/chat'
const COMPACT: Partial<TranscriptOptions> = { transcriptDensity: 'compact', showThinking: false }
<HostTranscript sessionId={id} options={COMPACT} />Row icons#
Each row starts with an icon: you, the agent, and how a step went (running, done, failed). showRowIcons: false (default true) drops them and the column they sit in, so the rows start at the edge. Switch it in the preview above.
Without the icons, a row's own line shows what only its icon did:
| State | With icons | Without |
|---|---|---|
A call, a batch, a subagent or a ! command runs | The icon's ring turns | A light sweep over its name |
| A call, a batch or a subagent failed | A red cross in the icon | A red cross after its line |
| Anything else | The icon | Nothing: the line already says it (a reply, an error in its tone, a command's exit) |
HostTranscript drops the ring of its earlier-history control too. A row your app draws itself (a renderer slot) reads the option with useRowIcons() from zidane-ui/chat/transcript.
import type { TranscriptOptions } from 'zidane-ui/chat'
const PLAIN: Partial<TranscriptOptions> = { showRowIcons: false }
<HostTranscript sessionId={id} options={PLAIN} />Diagrams#
A reply's ``````mermaid`` fences draw as diagrams. The kit renders them with Pele, which turns Mermaid into SVG. renderDiagrams: false (default true) shows them as code. Switch it in the preview:
Each request to /chat goes through the limiter first:
- Where. Only fences in prose (
Markdown) become diagrams. TheCodeBlockslot still draws tool inputs and the files a tool read, so a.mmdfile shows its source. - Streaming. A fence still open at the end of a streaming reply draws from the source so far, at most every 120 ms, with Pele's
render(). A source that does not parse yet keeps the last drawing, never an error. Once the fence closes, the diagram is mounted (mount()): it lays itself out again when its column resizes, and it zooms. Every drawing is kept per source (a bounded cache), so the closed fence, or a row that mounts again, paints at once. - Zoom. The frame's controls zoom out, zoom in and fit, next to the source toggle and Copy, with Pele's own buttons off. Pinch, Cmd/Ctrl-wheel, double-click and drag work too. A plain wheel scrolls the transcript.
- Source. The source view is highlighted in the code theme.
@pierre/highlightshas no Mermaid grammar, so the kit lexes Mermaid itself (code/mermaid-lexer.ts) in the theme's own syntax colors, in the highlighter's worker and cache. Anymermaidormmdcode block gets it, a.mmdfile a tool read included. - Cost. Pele loads when the first diagram comes near the view, and each diagram type loads on its first use. A transcript with no diagrams loads nothing. A diagram is drawn when it comes near the view, and it lays itself out again when its column resizes. A row that mounts again keeps the height its diagram had, so the view does not move.
- Theme. The SVG reads
--pele-*variables..zg-diagramsets them from the theme tokens: the panel is--zg-code-bg, nodes are--zg-raised, subgraphs are--zg-tray, lines and borders are--zg-text-mutedand--zg-border, and charts use the accent, then the status colors in Pele's hue order (warn, success, danger, ...). Labels use--zg-font-chatand--zg-font-monoat--zg-font-size-chat. A theme switch repaints through CSS alone. A font change from the appearance preferences measures the labels again. To restyle diagrams, set any--pele-*variable on.zg-diagram(Pele theming). - Safety. The source is model output. Pele escapes text, filters styles and limits size. The kit also turns off images (no network requests) and links. A source that Pele cannot draw shows as highlighted code, with the reason under it.
Outside a transcript, wrap Markdown in MarkdownDiagrams to choose, or draw one source with MermaidDiagram. Both come from zidane-ui/chat/transcript:
import { Markdown, MarkdownDiagrams, MermaidDiagram } from 'zidane-ui/chat/transcript'
<MarkdownDiagrams enabled={settings.renderDiagrams}>
<Markdown>{reply}</Markdown>
</MarkdownDiagrams>
<MermaidDiagram code={'flowchart LR\n A --> B'} />Two optional props:
turnMenu({ onContextMenu, prefetch? }) puts a right-click handler on each prompt and each settled reply. Render the menu as thechildrenof the transcript.selectionturns on select-turn mode.selectedis the turn under the cursor.registerStopsgives you the turns that the cursor can move between.
Renderer slots#
Parts that need app services (file badges, session titles, text previews) render through slots with plain defaults. Override any subset with TranscriptRenderersProvider. The desktop app adds its file badges here.
| Slot | Draws | Default |
|---|---|---|
Markdown | Replies, command details, compaction summaries | GFM through react-markdown, with sanitized URLs and no raw HTML. Headings show as paragraphs. Bold, italic and strikethrough show as plain text |
CodeBlock | Fenced blocks, tool input, files read | HighlightedCode: syntax highlighting (@pierre/highlights) in the kit's code frame, with a copy button. A block highlights when it comes near the viewport. CodeHighlightProvider sets the highlighter and the theme |
InlineText | One-line receipts naming files, commits or links | The text as is |
Prompt | A sent prompt inside its bubble | The text as typed |
SessionTitle | The session a tool call addresses | The short id, a link when openSession is set |
attachmentLabel | Icon and name of a prompt attachment | A file icon and the name |
TextPreview | A text attachment in the lightbox | The kit's preview, highlighted for its language by the code blocks' highlighter, up to 1 MiB |
ToolBody, hasToolBody | The whole body of calls hasToolBody accepts | None: every call shows input and output |
import type { CodeBlockSlotProps, TranscriptRenderers } from 'zidane-ui/chat/transcript'
import { TranscriptRenderersProvider } from 'zidane-ui/chat/transcript'
function Highlighted({ code, language }: CodeBlockSlotProps) {
return <pre data-language={language}>{highlight(code, language)}</pre>
}
// A module constant: a new object re-renders every row.
const RENDERERS: Partial<TranscriptRenderers> = { CodeBlock: Highlighted }
<TranscriptRenderersProvider value={RENDERERS}>
<HostTranscript sessionId={id} />
</TranscriptRenderersProvider>Actions#
Each action except sessionId is optional. Without an action, its control is hidden. HostTranscript binds most of them for you. With Transcript alone, pass them through TranscriptActionsProvider.
| Action | Enables | HostTranscript binds |
|---|---|---|
sessionId | Required: the session on screen | its sessionId |
toolOutput(callId) | "Load full output" on a clipped tool result | host.toolOutput |
cancelTool(callId) | Stop button on a running ! command | host.cancelTool |
openSession(id) | Session names as links, "open →" on a worker's report | onOpenSession |
resume(prompt) | Continue or Retry after a run that stopped early | host.send |
toolMediaUrl(callId, index) | Images, video and audio of a tool result, shown inline | Not bound |
modelName | Model names as your model picker writes them (Opus 4.5) | Not bound. Rows name a model from its id |
import type { TranscriptActions } from 'zidane-ui/chat/transcript'
import { TranscriptActionsProvider } from 'zidane-ui/chat/transcript'
const actions = useMemo<TranscriptActions>(
() => ({ sessionId: id, openSession: setActive, resume: prompt => composer.send(prompt) }),
[id, setActive],
)
<TranscriptActionsProvider value={actions}>
<Transcript events={events} streaming={running} sessionId={id} />
</TranscriptActionsProvider>Chips, cards and badges#
ProseMarkdown is the kit's Markdown with chips: inline code that names a file, a folder or a branch, #123 and commit SHAs, links with their site's icon, and extension references (a ticket id, an issue URL). Pass it as the Markdown slot. PromptEcho draws a sent prompt with the composer's badges, and InlineChipText a receipt line with chips. Hovering a chip opens one ChipCard. Right-clicking a badge opens the BadgeMenu (copy, add to prompt, open, plus the extensions' items).
- Ada
- +412−38831 files
Chips, cards, the badge menu, search and turn actions move behind ChatHost.
The rate limiter now keys on the session, not the socket.
A second limiter guards the upload route.
Chips read what they need from optional ChatHost members. A member the host leaves out keeps its chips plain:
| Member | What it gives | Without it |
|---|---|---|
paths.resolve(cwd, texts) | What each inline code names on disk: a file, a folder, a branch, or nothing | A chip opens the path as written. A name that may be a branch stays code |
references.providers(cwd), references.resolve(cwd, items) | Extension reference patterns, and the views of the keys they find | References stay plain text |
chips.commit, chips.sessions, chips.context, chips.skills, chips.commands | The facts each hover card shows | The chip has no card, or a card with its own text only |
badges.actions, badges.run | The extensions' badge menu items | Built-in items only |
siteIcons | Site icons on link badges, one request per origin | The globe |
createServerHost resolves path chips on the server's disk (files.resolvePaths) and fills chips.commit with git.commit, offers references and badges over the server's extensions, chips.context over sessions.contextBreakdown (labeled as the composer's gauge is), and no siteIcons: the web app's Content-Security-Policy (img-src 'self' data: blob:) keeps the page from loading other sites' images, and a server fetching any origin a transcript names is not worth the exposure, so links show the globe.
What a chip resolves against comes from MarkdownContext: the session's cwd, the repo's web base (#123 and SHAs link there), the server a server session's files are on, and open(path, kind, event), which opens a file or folder chip. BadgeMenuProvider takes the focused composer (where "Add to prompt" goes) and system (opens and reveals paths). OpenSessionContext makes a #chat card's title open that chat.
import { ChatProvider } from 'zidane-ui/chat'
import { BadgeMenuProvider, MarkdownContext, ProseMarkdown } from 'zidane-ui/chat/transcript'
import { createFixtureHost } from 'zidane-ui/chat/testing'
const host = createFixtureHost({
sessions: [],
extras: { paths: { '/repo': { 'README.md': { kind: 'file', path: '/repo/README.md' } } } },
})
<ChatProvider host={host}>
<BadgeMenuProvider>
<MarkdownContext.Provider value={{ cwd: '/repo', repoWebBase: 'https://github.com/acme/repo', open }}>
<ProseMarkdown>{'See `README.md`, fixed in #12.'}</ProseMarkdown>
</MarkdownContext.Provider>
</BadgeMenuProvider>
</ChatProvider>Inside a ChatProvider, a fixture's extras (ChatTranscriptFixture) serve each member from static data.
Search, select-turn and the turn menu#
| Piece | What it does |
|---|---|
TranscriptSearch | The ⌘F overlay: the query, the match counter, ▲▼ and Esc. Takes the scroll container and a revision (the events), and terms (your search tokenizer) for a search opened from a content match. Opens on the app's ModalProvider |
useTranscriptSearch | The engine both searches share: matches the transcript's text in the DOM and highlights them with the CSS Custom Highlight API, never touching the rows |
useComposerSearch | The composer's ? search. With ChatHost.searchSync, the query follows the session's other windows |
SelectTurnProvider, useSelectTurn, SelectTurnHintKeys, SelectTurnExit | Select-turn mode (Ctrl+S in the desktop app): one cursor per app, the stops the transcript registers, the dock row's keys |
useSelectTurnKeys({ sessionId, open, copy?, fork?, fallbackStops?, blocked? }) | Drives the mode: returns the enterSelectTurnMode handler (the last stop), then ↑/↓, Enter opens, C copies, F forks, Esc leaves, hover and click on rows. The notices (selectTurnCopied, selectTurnForkFailed) come from the labels |
useTranscriptFindKey({ sessionId, onFind, blocked? }) | ⌘F: the Files or Diffs tab in reach searches itself (⌘⇧F across files), else onFind(sessionId) opens your TranscriptSearch |
useTranscriptFind({ sessionId, blocked? }) | The find's state over useTranscriptFindKey: open, query, close for your TranscriptSearch, and findIn({ sessionId, query }) for a palette match (switching to another session closes it) |
useTurnMenu(sessionId, actions) | The right-click menu of a turn: copy, fork, edit or rewind, delete, and when it was sent. Each action in TurnMenuActions you leave out hides its item |
useTurnCommands(), createTurnCommands(turns) | The turn menu's and select-turn's shared commands over SessionHost.turns: copyTurn, turnText, and with turns.rewind, rewindToTurn and editPrompt (cuts the history before a prompt and puts it back in that session's composer; refused while the composer is closed). promptDraftOf(events, turnId) (zidane-ui/chat/headless) reads a sent prompt back as a draft |
LoadEarlierRow, useFillViewportScrollback | The "Earlier messages" row, and the fill that loads pages while the viewport can't scroll |
useTranscriptScroll | Stick to the bottom, reopen a conversation where it was left, load earlier pages near the top |
Performance#
Follow these rules to keep long sessions fast.
| Rule | Why |
|---|---|
| Memoize props | A new options, selection or turnMenu object, or a new provider value, re-renders every row |
| Keep event identity | While a reply streams, replace only the events that changed. Rows with the same event objects do not render again |
| Rows mount lazily (built in) | The last 40 top-level rows mount at once. Others are placeholders at an estimated height until they near the viewport, then stay mounted. Offscreen rows use content-visibility: auto |
Reference#
zidane-ui/chat/transcript, zidane-ui/chat, zidane-ui/chat/headless.