AI

Conversation Summary

A compaction seam for long chats — a hairline marker that reports how many earlier messages were summarized, discloses the bullet summary, and offers a restore that can only fire once.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { ChevronDown, History, Loader2, Sparkles } from "lucide-react"
import { cn } from "@/lib/utils"

/* -------------------------------------------------------------------------- *
 * Shimmer
 *
 * Keyframes ship with the component through a React 19 hoisted <style> — no
 * Tailwind config edits, and every marker on the page dedupes by href. One
 * sweep drives both the pill and the skeleton bullets: each overlay is
 * `inset-0`, so a -100% → 100% translate crosses it exactly once.
 * -------------------------------------------------------------------------- */

Installation

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

Prompt

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

Build a React + TypeScript + Tailwind "ConversationSummary" component: the
"earlier conversation summarized" marker a long chat leaves behind when the
head of the thread is compacted into a few bullets. lucide-react for icons;
no disclosure library — the panel is a grid-rows trick.

Contract
- forwardRef<HTMLDivElement>, extends React.HTMLAttributes<HTMLDivElement>
  minus children; leftover props spread onto the root so a transcript can pass
  its own data-* / aria-label.
- messageCount: number (required) — how many messages the marker stands in for.
  0 or negative drops the number from the label entirely.
- points?: { id?: string; text: ReactNode; source?: string }[] (default []) —
  the summary itself. It may GROW while status is "summarizing"; `source` is a
  provenance chip ("8 messages", "Mar 3 · 09:14") rendered muted after a bullet.
- status?: "summarizing" | "summarized" | "restored" (default "summarized").
- tokensSaved?: number — rendered as "≈12.4k tokens saved".
- rangeLabel?: string — caller-formatted span of the folded messages. The
  component never reads a clock, a timezone or a locale.
- expanded? / defaultExpanded? (false) / onExpandedChange?(expanded: boolean).
- onRestore?: () => void | Promise<void>. Omitted = no restore affordance at all.
- restoreLabel? ("Restore full history"), restoreErrorLabel? ("Couldn't restore
  the full history. Try again.").
- renderLabel?: ({ status, messageCount, pointCount }) => ReactNode — replaces
  the pill text, and with it the toggle's accessible name.
- anchorScroll?: boolean (default true), announce?: boolean (default true).
- Export the status union, the point type and the label-context type.

Behavior — the status machine
- One direction only: summarizing → summarized → restored. "restored" is
  terminal; the only way back is a remount with a new key. Model it as a prop,
  not internal state: the caller owns the compaction job.
- summarizing: the pill shimmers and the panel shows placeholder bullets. Any
  points that already arrived are rendered for real, and the placeholder count
  is max(1, 3 - points.length) — at least one row keeps shimmering, so the
  panel never looks finished a beat early.
- summarized with points.length === 0 is a real case (the model returned
  nothing usable): render a plain, non-interactive seam instead of a toggle
  that opens an empty box.
- restored: swap the sparkle for a history icon, drop the toggle, drop the
  restore button. There is nothing left to disclose and nothing left to undo.

Behavior — disclosure
- Uncontrolled by default (defaultExpanded), controlled when `expanded` is
  passed; the effective value is always ANDed with "is there anything to
  disclose".
- Nothing opens itself. A compaction marker sits above the reader's place in
  the transcript, so auto-expanding shoves the conversation they are reading
  down the page for a reason they never asked for.
- Animate with a two-element grid: the outer div toggles grid-rows-[0fr] ↔
  grid-rows-[1fr] with transition-[grid-template-rows], the inner div is
  overflow-hidden. This animates to the content's natural height without
  measuring anything, and collapses the panel's own top margin with it.
- Mount the bullets lazily — only once the panel has been open at least once —
  and keep them mounted afterwards. A transcript full of unopened seams then
  lays out nothing. The mount happens in the same render that opens the panel,
  so there is no empty first frame.
- The panel is role="group" + aria-labelledby={triggerId}, never role="region":
  a long chat can hold several seams, and one landmark each drowns landmark
  navigation. Collapsed, it carries `inert` and aria-hidden, so a closed
  summary is out of the tab order and out of the reading order.

Behavior — restore is a latch, not a button
- The restore button is a SIBLING of the disclosure button inside the pill,
  never nested in it: nested interactive elements are invalid HTML and cost a
  keyboard user one of the two actions.
- Guard the callback with a ref, not state — the ref flips before React
  commits, so a double click, a rage click or a re-entrant render can never
  fire onRestore twice.
- Call it inside try/catch. A synchronous throw and a rejected promise both
  mean "failed attempt, not a spent one": release the ref, render
  restoreErrorLabel in a role="alert" line, and let the reader try again. A
  resolved promise (or a plain synchronous return) latches permanently and the
  button goes disabled reading "History restored", whether or not the caller
  flips `status`.
- Keep a mounted ref and skip the state write if the promise settles after
  unmount — this marker lives in a transcript that may be virtualised away
  mid-request.
- Disable restore while status is "summarizing": there is not yet a summary for
  the full history to replace.

Behavior — growth above the fold
- The marker almost always sits ABOVE the reader in a bottom-anchored
  transcript, and it grows on its own (bullets landing, status flipping). Watch
  the root with a ResizeObserver; on every height delta walk up to the nearest
  ancestor whose computed overflow-y is auto/scroll/overlay AND whose
  scrollHeight exceeds its clientHeight (falling back to the window), then add
  the delta to its scrollTop.
- Compensate only when the marker was ENTIRELY above the viewport before it
  grew: compare `rect.bottom - delta` with the container's top edge, not the
  post-growth rect, or the one case this exists for is misread as "on screen".
  When the reader can see the marker, growing downward is what they expect —
  do not fight it.
- A ResizeObserver callback runs after layout and before paint, so the
  correction is never a visible jump. Disconnect on unmount, and expose
  anchorScroll={false} for transcripts that already own their scroll anchoring.

Behavior — screen readers
- One sr-only role="status" aria-live="polite" region. It speaks exactly twice
  in a marker's life: on summarizing → summarized ("Earlier conversation
  summarized: 48 messages condensed into 5 points.") and when the status
  reaches restored. Mounting an already-summarized marker is SILENT, or
  scrolling back through a transcript announces every seam in it.
- Derive the announcement from a render-phase status comparison (previous
  status kept in state, compared during render), not from an effect: it belongs
  to the frame the status changed on.
- Any transition not worth saying writes "" into the region first, so the next
  identical sentence is a real DOM mutation — a live region handed the same
  string twice says nothing the second time.
- Placeholder bullets are aria-hidden: the pill already says "Summarizing…",
  and empty rows read as noise.

Rendering & styling
- Semantic tokens only: bg-card + border for the pill and the panel, bg-border
  for the two hairlines, text-muted-foreground for secondary text, text-primary
  for the sparkle and the bullet dots, bg-muted for hover and skeleton bars,
  text-destructive for the restore error, ring for focus. No hex, no rgb(), no
  oklch().
- Layout is a seam: [hairline flex-1][pill][hairline flex-1] in one flex row,
  the panel underneath. The pill label truncates (min-w-0 + truncate) so a long
  custom label can never widen the transcript, and the hairlines keep a small
  min-width so the seam still reads on a phone.
- Shimmer: one @keyframes translateX(-100% → 100%) shipped in a React 19
  hoisted <style href precedence> (it dedupes across every instance on the
  page), applied to inset-0 gradient overlays — one over the pill, one per
  skeleton bar, staggered ~160ms apart. All of it is
  motion-reduce:[animation:none]: the bars stay static muted blocks and the
  word "Summarizing…" carries the state.
- Every transition is motion-reduce:transition-none — chevron rotation, panel
  height, hover colours. The component works identically without them.
- Counts are formatted WITHOUT toLocaleString (842 / 12.4k / 1.2M): a locale
  formatter renders differently on the server and in the reader's browser,
  which is a hydration mismatch on a component that ships inside SSR'd
  transcripts.
- Both actions are real <button type="button"> with focus-visible:ring-2
  ring-ring; the toggle carries aria-expanded + aria-controls; the root is
  role="group" with an aria-label, so the whole seam announces as one thing.

Customization levers
- Density: the pill is text-xs / py-1 and the panel is p-3 with gap-2 bullets —
  raise both a step for a roomier "summary card", or drop the panel border and
  let the bullets sit straight on the transcript background.
- Skeleton shape: the widths array (w-11/12, w-9/12, w-10/12) and its length
  decide how many rows shimmer; feed it the model's expected bullet count when
  you know it, or cut it to a single row for a quieter wait.
- Which sub-blocks exist: tokensSaved, rangeLabel and point.source are each
  independently optional — drop all three for a bare "N messages summarized"
  seam, keep them for a context-budget UI.
- Voice: renderLabel replaces the whole pill text ("Context compacted · 1284
  turns → 2 notes"), restoreLabel and restoreErrorLabel cover the rest of the
  copy, so the component localises without touching its logic.
- Accent: the sparkle and the bullet dots are the only text-primary / bg-primary
  spots — move them to var(--chart-1) to tie the seam to a product accent, or to
  text-muted-foreground for a marker that stays completely out of the way.
- Motion budget: shimmer duration (1.8s), stagger (160ms) and the 200ms panel
  transition are the three knobs; zero them (or rely on reduced motion) for a
  fully static seam.
- Restore semantics: turn onRestore into a confirm step by resolving its promise
  only after the user confirms — the latch stays open until it resolves, and it
  re-arms if you reject on cancel.

Concepts

  • Compaction seam — the marker is not a message, it is the join between what the model still remembers verbatim and what it now only remembers as five bullets; drawing it as a hairline with a pill on it keeps that meaning visible without competing with the conversation around it.
  • Incremental summary — while the summary is being written, the bullets that already arrived render for real and only the remaining rows shimmer, so the panel fills in front of the reader instead of flipping from "all placeholder" to "all text" in one frame.
  • One-shot restore latch — restoring history is not idempotent, so the callback is guarded by a ref that closes before React commits; a rejection or a synchronous throw re-arms the latch and surfaces an alert line, while a resolution disables the action for the lifetime of the instance.
  • Growth above the fold — a compaction marker sits above the reader, so anything it does to its own height silently drags the conversation; the component measures its own deltas and repays them into the scroll container, but only when it was fully off-screen, because growth the reader can see is growth they asked for.
  • Lazy disclosure — the bullets mount the first time the panel opens and are never unmounted again, which keeps a transcript of dozens of unopened seams free of layout work while preserving scroll position and text selection inside the ones that were opened.
  • Announce once, then clear — the live region speaks only on summarizing → summarized and on restore, and every other transition blanks it first, so an identical sentence in a later run is still a DOM change and is still spoken.

On This Page