AI

Conversation Export

An export menu for a whole conversation — scope radio, a size estimate on every format, and a row that spins, ticks or turns into Retry without the menu ever closing under it.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  Braces,
  Check,
  ChevronDown,
  Download,
  FileText,
  Globe,
  Loader2,
  Printer,
  RotateCcw,
  TriangleAlert,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/conversation-export.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "ConversationExport" component: the export
menu of a chat transcript. Built on the shadcn dropdown-menu (Radix), lucide-react
icons and a small controllable-state hook. No other dependencies.

Contract
- forwardRef<HTMLButtonElement>, extends React.ButtonHTMLAttributes<HTMLButtonElement>.
  The forwarded ref and every leftover native prop land on the TRIGGER button.
- messageCount: number — total messages. 0 renders the menu's empty branch.
- selectedCount?: number (default 0) — messages the reader has selected. Greater
  than 0 is the only thing that makes the scope radio group exist.
- characterCount?: number — characters across the transcript, used by the size
  estimator. Default messageCount * 480.
- formats?: ConversationExportFormat[] — default Markdown / JSON / PDF / HTML.
  Each entry: { id, label, extension?, description?, icon?, bytes?, unavailable? }.
  `bytes` is the real size of the WHOLE conversation and overrides the estimator;
  `unavailable` is a sentence that makes the row inert and explains why.
- onExport: (format: string, scope: "all" | "selection") => void | Promise<unknown>
  — required. The returned promise IS the row's state machine.
- scope / defaultScope / onScopeChange — controllable. The default scope is
  "selection" when the component mounts with a selection, otherwise "all".
- open / defaultOpen / onOpenChange — controllable menu state.
- label?: string (default "Export") — trigger text; `children` replaces the whole
  trigger content. heading?: string (default "Export conversation") — deliberately
  NOT named `title`, which belongs to the native attribute on the trigger.
- align?: "start" | "center" | "end" (default "start"), resetMs?: number
  (default 2400), closeOnSuccess?: boolean (default false),
  contentClassName?: string for the portalled panel.
- Also export formatByteSize(bytes) and the DEFAULT_EXPORT_FORMATS array, so a
  consumer can extend the default list instead of retyping it.

Behavior — the row is the state machine
- Row status: "idle" | "pending" | "done" | "error", stored per format id in one
  record. Selecting a row calls onExport and drives that row from the promise:
  spinner while pending, tick on resolve, destructive line + Retry on reject.
- ALWAYS event.preventDefault() in the item's onSelect, so choosing a format does
  NOT close the menu. Progress belongs to the row that started it; a menu that
  closes on select has to borrow a toast, and a failure then arrives detached
  from the choice that caused it, with nowhere to retry.
- One export at a time. The lock is a REF holding { id, token }, checked first, so
  a double click inside one tick — or Enter landing on top of a click — cannot
  start two runs. While one runs, every other row is aria-disabled (never the
  `disabled` attribute, never pointer-events:none) so it stays hoverable,
  focusable and announced; the real guard lives in the handler.
- Every run gets an incrementing token. A result whose token is no longer the one
  the lock holds is dropped instead of written, and nothing is written at all once
  the component has unmounted.
- Success expires, failure does not: a tick clears itself back to idle after
  resetMs; an error row keeps its message ("… · select to retry") until the reader
  selects it again. Selecting it again clears that row's pending reset timer and
  starts a fresh run.
- Catch BOTH failure shapes: a synchronous throw from onExport and a rejected
  promise. Normalise to a string (Error.message, a raw string, else "Export
  failed"). This component must never produce an unhandled rejection.
- The scope is read ONCE, when the run starts. Flipping the radio mid-run cannot
  change what the in-flight export is producing.
- closeOnSuccess closes the menu ~700 ms AFTER the tick, never on the same frame:
  closing instantly means the tick was never seen.
- Closing the menu cancels nothing. The trigger keeps aria-busy and a spinner, and
  the finished row is still there when the menu is reopened.

Behavior — scope
- With no selection there is exactly one thing to export: render a muted
  "<n> messages" line and no radio group at all.
- With a selection, render a radio group: "Whole conversation / <n> messages" and
  "Selected messages / <k> of <n>". Its items also preventDefault on select — a
  scope is a setting, not a command, and keeping the menu open is what lets the
  reader watch every estimate below re-price itself.
- If the selection is cleared while scope is "selection", the EFFECTIVE scope
  degrades to "all" (and the group disappears with the selection that justified
  it). A selection scope with nothing selected must never reach onExport.
- messageCount === 0 renders one explanatory line instead of four formats that
  would all produce an empty file.

Behavior — size estimates
- Every row shows "~<size>" for the CURRENT scope, so the reader knows whether
  they are about to wait 200 ms or 20 s. Model it linearly and legibly:
  bytes ≈ base + characters·perChar + messages·perMessage, with per-format
  weights — markdown {120, 1.02, 28}, json {320, 1.25, 150},
  html {2800, 1.18, 190}, pdf {16000, 1.7, 260}, and a generic {256, 1.1, 64} for
  ids the table does not know. base is container overhead (a PDF ships fonts and
  an object table before it holds a single word), perChar is body swell (JSON
  escapes, HTML entities, PDF text operators), perMessage is the per-turn
  envelope (a role heading, a JSON object, a styled bubble).
- Under scope "selection" scale characters and messages by selected/total; a
  format carrying an explicit `bytes` is scaled by the same share.
- Format sizes as B / KB / MB / GB with one decimal only below 10 of a unit, and
  always prefix "~" so an estimate never reads as a promise.

Rendering & styling
- Semantic tokens only: bg-popover / text-popover-foreground come from the menu
  primitive, text-muted-foreground for descriptions and sizes, text-primary for
  the success tick, text-destructive for the failure line and the retry glyph,
  border for the separator. No hex, no rgb(), no oklch(). Merge every className
  through cn().
- Trigger = shadcn buttonVariants({ variant: "outline", size: "sm" }) applied to
  the Radix trigger itself (not a nested button): a Download icon — Loader2,
  spinning, while busy — the label, and a chevron rotated 180° through
  group-data-[state=open].
- Row layout: leading icon (mt-0.5, aligned with the title), a min-w-0 column with
  the title + extension and one status line, then a FIXED-WIDTH trailing column
  (w-14, tabular-nums) shared by the size text, the spinner, the tick and the
  retry glyph — so a row never re-flows as it changes state.
- Every spin is animate-spin motion-reduce:animate-none and the chevron rotation
  is transition-transform motion-reduce:transition-none. Under reduced motion the
  status line ("Preparing…") plus aria-busy carry the meaning instead.
- Panel is w-72 min-w-72 (overriding the primitive's trigger-width default) with a
  compact p-1.5.
- Accessibility: each item gets an explicit aria-label composing format + scope +
  state ("Export as PDF, 6 selected messages, about 340 KB"), aria-busy while
  pending, aria-disabled while blocked. The trailing glyph column is aria-hidden.
  One sr-only role="status" aria-live="polite" region sits OUTSIDE the portalled
  panel — a slow export usually ends after the menu has closed — and is emptied a
  few seconds later so the same sentence can be announced twice.
- modal={false}: an export can take seconds, and the page behind it should stay
  readable and scrollable while it runs.
- Clean up on unmount: every per-row reset timer, the announcement timer and the
  close timer. Re-arm the mounted flag inside the effect body, not only at
  initialisation, or StrictMode's pre-mount cleanup leaves the instance deaf to
  every later result.

Customization levers
- Format list: `formats` is the whole content model. Add "Send to Notion", ".docx"
  or ".txt"; drop PDF entirely; pass a real `bytes` when the backend already knows
  the size; set `unavailable` on a row whose renderer is down instead of hiding
  it, so the reason stays discoverable by keyboard and screen reader.
- Estimator weights: three numbers per format — retune them against your own
  transcripts, or bypass estimation completely by passing `bytes` everywhere.
- Timing: resetMs (how long a tick lives), the ~700 ms close delay and the ~4 s
  announcement clear are the three constants worth touching. Turn on
  closeOnSuccess when your app already surfaces downloads elsewhere.
- Concurrency: the single in-flight lock is one ref. Swapping it for per-format
  locks (a Set of running ids) is a two-line change if your backend can serve
  parallel exports.
- Density: rows are px-1.5 py-1.5 with a two-line body — drop the description line
  for a compact one-line menu, or move the size into its own trailing badge.
- Trigger: pass `children` for an icon-only trigger (keep an aria-label), or swap
  buttonVariants for the ghost variant when it sits inside a toolbar.
- Scope model: the radio group is the only place scope lives. Growing it to three
  options ("this branch", "since yesterday") means widening the union and the
  messagesIn() helper — nothing else in the component reads scope directly.

Concepts

  • Menu stays open on select — the item's onSelect always calls preventDefault(), so the run reports into the row that started it; a menu that closed on select would have to borrow a toast, and its failures would arrive detached from the choice that caused them.
  • One-shot in-flight lock — the running export is held in a ref, not in state, so the second of two clicks landing in the same tick is refused before React has re-rendered; every other row goes aria-disabled (still focusable, still announced) instead of being removed from the keyboard path.
  • Stale-run token — each run carries an incrementing token, and a promise settling after a newer run took the slot, or after the component unmounted, is dropped instead of written — which is what stops a slow PDF from overwriting the Markdown row you started after it.
  • Scope captured at start — the radio decides what the next export covers; the run already in flight keeps the scope it was launched with, so flipping to "selected" mid-export can never silently change the file you are about to receive.
  • Estimate before commitment — every row prices itself for the current scope (base + characters·perChar + messages·perMessage) and prefixes the number with ~, so a 12 KB Markdown dump can be told apart from a 2 MB PDF render before the click is spent.
  • Success expires, failure persists — a tick fades back to idle after a couple of seconds while an error keeps its reason and turns the row into Retry until it is acted on; disappearing quietly is only an acceptable ending for good news.

On This Page