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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/conversation-export.jsonPrompt
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
onSelectalways callspreventDefault(), 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.
Share Conversation
A publish-a-link share dialog for a chat thread — server-issued URL, optimistic visibility and attachment writes with rollback, copy feedback and a destructive revoke confirm.
Answer Sources
The numbered favicon cards behind one AI answer — a scrolling strip or an expandable list, the retrieved excerpt one hover or one Tab away, dead sources that refuse to link, and four data states.