# 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](https://zidane.si/docs/ui-sessions.md).

```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} />
}
```

| Prop | Meaning |
| - | - |
| `events` | `ChatEvent`s, which are zidane `StreamEvent`s 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 `hydrated` is true.
- An "Earlier messages" control while `hasEarlier` is set. The control calls the `loadEarlier` method of the transcript store.
- The row [actions](#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](https://zidane.si/docs/apps.md#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` |

```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:

| 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](#renderer-slots)) 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](https://pele.run), which turns Mermaid into SVG. `renderDiagrams: false` (default `true`) shows them as code. Switch it in the preview:

- **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](https://pele.run/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.

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

```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`.

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

```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).

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.

```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

| 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`](https://zidane.si/docs/reference/ui-chat-transcript.md), [`zidane-ui/chat`](https://zidane.si/docs/reference/ui-chat.md), [`zidane-ui/chat/headless`](https://zidane.si/docs/reference/ui-chat-headless.md).
