Skip to content

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.

Rate limit the chat endpoint: 20 requests a minute per IP, and tell clients how long to wait when it trips. The chart shows who hit it last night.
    • The chat route now takes a token from a per-IP bucket: 20 a minute, with bursts of 5. Over the limit it answers 429 with a Retry-After header, so clients know how long to wait.

      All 44 server tests pass.

      Opus 5.538s4$0.0717k1.1k
    Document it in docs/server.mdx, then commit.
      • Documented under Limits in docs/server.mdx and committed as 83dfea7.

        Opus 5.552s3$0.0912k980

      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.

      TSX
      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} />
      }
      PropMeaning
      eventsChatEvents, which are zidane StreamEvents with optional client fields. A replayed session works as it is
      streamingA run is in flight, so the last reply can still grow
      sessionIdThe session shown. A switch to another id shows the new rows without enter animations
      className, childrenExtra 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 hydrated is true.
      • An "Earlier messages" control while hasEarlier is set. The control calls the loadEarlier method of the transcript store.
      • The row actions, bound to the host.
      TSX
      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.

      OptionValues
      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, renderDiagramsboolean
      TSX
      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:

      StateWith iconsWithout
      A call, a batch, a subagent or a ! command runsThe icon's ring turnsA light sweep over its name
      A call, a batch or a subagent failedA red cross in the iconA red cross after its line
      Anything elseThe iconNothing: 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.

      TSX
      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. The CodeBlock slot still draws tool inputs and the files a tool read, so a .mmd file 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/highlights has 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. Any mermaid or mmd code block gets it, a .mmd file 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-diagram sets 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-muted and --zg-border, and charts use the accent, then the status colors in Pele's hue order (warn, success, danger, ...). Labels use --zg-font-chat and --zg-font-mono at --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:

      TSX
      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 the children of the transcript.
      • selection turns on select-turn mode. selected is the turn under the cursor. registerStops gives 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.

      SlotDrawsDefault
      MarkdownReplies, command details, compaction summariesGFM through react-markdown, with sanitized URLs and no raw HTML. Headings show as paragraphs. Bold, italic and strikethrough show as plain text
      CodeBlockFenced blocks, tool input, files readHighlightedCode: 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
      InlineTextOne-line receipts naming files, commits or linksThe text as is
      PromptA sent prompt inside its bubbleThe text as typed
      SessionTitleThe session a tool call addressesThe short id, a link when openSession is set
      attachmentLabelIcon and name of a prompt attachmentA file icon and the name
      TextPreviewA text attachment in the lightboxThe kit's preview, highlighted for its language by the code blocks' highlighter, up to 1 MiB
      ToolBody, hasToolBodyThe whole body of calls hasToolBody acceptsNone: every call shows input and output
      TSX
      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.

      ActionEnablesHostTranscript binds
      sessionIdRequired: the session on screenits sessionId
      toolOutput(callId)"Load full output" on a clipped tool resulthost.toolOutput
      cancelTool(callId)Stop button on a running ! commandhost.cancelTool
      openSession(id)Session names as links, "open →" on a worker's reportonOpenSession
      resume(prompt)Continue or Retry after a run that stopped earlyhost.send
      toolMediaUrl(callId, index)Images, video and audio of a tool result, shown inlineNot bound
      modelNameModel names as your model picker writes them (Opus 4.5)Not bound. Rows name a model from its id
      TSX
      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).

      Prose: file, branch, commit, issue and reference chips

      Moved the badges into badges.tsx and left README.md alone.

      Fixed on main in e32789e, tracked as ZID-42, see #128.

      useState and npm test stay code.

      A sent prompt's badges
      /review badges.tsx against zidane.dev/docs
      A chip's hover card
      ZID-42In Progress
      Move the transcript extras into the kit
      Kit
      Assignee
      Ada
      Changes
      +412−38831 files

      Chips, cards, the badge menu, search and turn actions move behind ChatHost.

      Earlier history, idle and loading
      Find in conversation (⌘F)

      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:

      MemberWhat it givesWithout it
      paths.resolve(cwd, texts)What each inline code names on disk: a file, a folder, a branch, or nothingA 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 findReferences stay plain text
      chips.commit, chips.sessions, chips.context, chips.skills, chips.commandsThe facts each hover card showsThe chip has no card, or a card with its own text only
      badges.actions, badges.runThe extensions' badge menu itemsBuilt-in items only
      siteIconsSite icons on link badges, one request per originThe 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.

      TSX
      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#

      PieceWhat it does
      TranscriptSearchThe ⌘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
      useTranscriptSearchThe 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
      useComposerSearchThe composer's ? search. With ChatHost.searchSync, the query follows the session's other windows
      SelectTurnProvider, useSelectTurn, SelectTurnHintKeys, SelectTurnExitSelect-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, useFillViewportScrollbackThe "Earlier messages" row, and the fill that loads pages while the viewport can't scroll
      useTranscriptScrollStick 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.

      RuleWhy
      Memoize propsA new options, selection or turnMenu object, or a new provider value, re-renders every row
      Keep event identityWhile 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.