AI

Pinned Messages

A pinned-messages strip for a conversation — one chip per pin, as many as genuinely fit measured against the real container, the rest collapsed into a +N more panel, unpin behind a confirmation that fires once, and real loading/empty/error states.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { AlertCircle, Bot, Cog, Pin, PinOff, RefreshCcw, Terminal, User, X, type LucideIcon } from "lucide-react"

import { cn } from "@/lib/utils"
import { Popover } from "@/registry/ui/popover"
import type {
  PinnedInstant,
  PinnedMessageRole,
  PinnedMessagesItem,
  PinnedMessagesStatus,
} from "./pinned-messages.contract"

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/pinned-messages.json

Prompt

Build a React + TypeScript + Tailwind "PinnedMessages" component with zod and
lucide-react, reusing an existing portalled Popover primitive for the two
floating surfaces.

Contract
- A zod schema (`pinnedMessagesItemSchema` in a sibling contract file) is the
  single source of truth for ONE pin: { id, messageId, excerpt, role, pinnedAt }.
- `id` is the identity of the PIN, `messageId` of the message it points at. A pin
  is a POINTER, never a copy — storing the message body would freeze text the
  transcript can still edit, translate or regenerate.
- `role` is a four-value union: "user" | "assistant" | "system" | "tool".
- `pinnedAt` is an absolute instant (ISO string, epoch ms or Date), never a
  pre-formatted "3 h ago": a relative label is a function of (instant, now) and
  both live on the component, so a cached page cannot serve a stale "just now".
- A separate envelope status — "loading" | "empty" | "error" | "ready" — is the
  strip's own render state. `ready` with an empty array renders the empty branch
  as well; a strip that claims to be ready and paints nothing is a bug you only
  find in production.
- Props: items: Item[]; status; onJump?(messageId, item); onUnpin?(id, item);
  onRetry?; now? (injected clock); maxVisible? (hard cap, clamped to >= 1);
  formatPinnedAt?(item, nowMs) (return null to draw no age); emptyState?;
  errorMessage?; label? ("Pinned"); className plus the rest of the element props,
  ref forwarded to the <section>.
- The component holds NO copy of the list. Removing an unpinned item is the
  consumer's job, which is what makes an optimistic update and its rollback both
  work without a reset prop.

Behavior
- HOW MANY CHIPS FIT IS MEASURED, NOT GUESSED. A hidden measuring layer renders
  one twin per pin (plus one for the overflow trigger) at natural width, using
  the exact same box classes as the real chips; the split is then computed from
  those widths, the row's own clientWidth and the row's computed column gap.
  Twins are `visibility: hidden`, never `display: none` — a display:none box has
  no geometry and cannot be measured, which is the trap this pattern exists to
  avoid. Wrap the twins in a 0x0 overflow-hidden shell: a hidden element still
  contributes SCROLLABLE overflow, and twelve twins laid out 1200px wide inside a
  375px viewport would make the whole page scroll sideways.
- The measurement runs in a LAYOUT effect, so the re-render it triggers is
  flushed before paint and the first painted frame is already the collapsed one.
  With a passive effect the reader sees one frame of a fully expanded, clipped
  strip. It re-runs on a ResizeObserver over the row AND over every twin (a late
  web font, a zoom change or an edited excerpt all move labels), on window
  resize, and once document.fonts.ready resolves. Never call setState straight
  from a ResizeObserver callback — schedule it in a requestAnimationFrame or you
  get "ResizeObserver loop completed with undelivered notifications". Bail out
  when the new metrics compare equal, so the observer that re-fires on your own
  writes becomes one no-op.
- The split is a PREFIX of the list (pins keep the order you passed; the overflow
  is the tail) and it is recomputed from scratch on every pass, so it stays a
  pure function of (widths, gap, available, cap) and cannot oscillate. The
  trigger PAYS FOR ITSELF: every candidate split is costed including the "+N
  more" button, so hiding a narrow chip to make room for a wider trigger is never
  chosen. Measure that trigger at its WIDEST possible label ("+{all} more") —
  its width is an input to the split that decides its own label, and a worst-case
  budget breaks that loop by over-reserving a few pixels instead of flip-flopping.
- Degenerate case: a container narrower than one chip plus the trigger keeps one
  chip and lets IT be clipped, never the trigger — losing the only door to the
  hidden pins is worse than losing a few pixels of one label.
- UNPIN ASKS FIRST, and the confirmation lives where the pin does: a popover
  anchored to the chip's × in the strip, and an INLINE row inside the overflow
  panel. Do not nest a popover inside the panel — it portals to the document
  body, which the panel reads as an outside press and closes underneath it.
- UNPIN IS ONE-SHOT. The lock is a ref read and written synchronously inside the
  click handler, because two clicks dispatched in a single task both observe the
  same stale state and a state-only guard would fire the callback twice for one
  confirmation. The lock resets whenever a confirmation is OPENED, so the next
  genuine unpin is never blocked by the previous one.
- FOCUS IS HANDED OFF, not dropped. Unpinning removes the very control the user
  was standing on; without a hand-off focus lands on the document body and the
  next Tab restarts from the top of the page. One frame after the callback (the
  consumer's removal has landed by then) focus moves to the pin now occupying the
  same slot — inside the panel when the unpin came from the panel, otherwise in
  the strip. It runs ONLY when focus was actually orphaned: if the pin survived a
  rejected request, the popover has already restored focus to its trigger and
  stealing it back would be the rudest possible outcome.
- Affordances are HANDLER-GATED. No onJump and the chips render as plain labels
  with no pointer cursor, no hover and no tab stop; no onUnpin and no × is drawn
  at all. An affordance that leads nowhere is worse than no affordance.
- Excerpts are normalised to one line (collapse every whitespace run) BEFORE they
  are measured and rendered, otherwise the twin and the chip disagree about how
  wide a two-newline excerpt is. An all-whitespace excerpt falls back to visible
  placeholder text so the chip never collapses into an unclickable sliver.
- Keyboard: Tab reaches every control (nothing is a roving-tabindex prison),
  ←/→ walk between pins, Home/End jump to the ends, Delete/Backspace on a focused
  chip opens the same confirmation the × does. Esc closes either floating surface.
- The strip owns NO CLOCK. Ages come from `now - pinnedAt`, recomputed from the
  injected instant every render, so server and client agree and a screenshot is
  reproducible. A pin dated in the future by a skewed client clock reads "just
  now", never "-5 m ago". Omit `now` and no age is drawn at all.
- Render-phase adjust-state, no effects: the overflow panel closes itself the
  moment its last hidden pin comes back (a panel left open would spring open by
  itself the next time the container narrowed), and a confirmation whose pin has
  disappeared — someone else in the thread unpinned it — closes itself too.
- Every timer, frame and observer is cancelled on unmount: the ResizeObserver is
  disconnected, the resize listener removed, and the focus hand-off frame
  cancelled so it cannot fire into a detached tree.

Rendering & styling
- Semantic tokens only: bg-card + border for the strip, bg-background for a chip,
  bg-muted for hover and the loading pulse, text-muted-foreground for chrome and
  role glyphs, text-destructive for the load failure, bg-destructive +
  text-background for the confirm button, ring-ring for every focus ring. No
  hard-coded colours anywhere.
- Four roles are four SHAPES (person / bot / cog / terminal), each carrying its
  word in the accessible name, so the strip survives a monochrome theme and a
  colour-blind reader.
- The root is a <section aria-label="Pinned messages"> holding one persistent
  sr-only role="status" line with the pin COUNT. Deliberately not the hidden
  count — that moves on every resize and would turn a window drag into a stream
  of announcements.
- A chip's label is truncated by CSS only (max-width + ellipsis), so the visible
  string stays a substring of its aria-label: "click <excerpt>" keeps working for
  voice control and a screen reader still gets the part the ellipsis ate. Panel
  rows clamp to two lines instead — the pin you are hunting for is often
  identifiable only past the ellipsis.
- Nothing in the chip's painted box may change with interaction state: a chip
  that grew on hover or while its confirm is open would move every chip after it
  and restart the maths mid-gesture. Hover changes colour, focus draws an inset
  ring (box-shadow, zero layout).
- Every transition is motion-reduce-guarded and the loading pulse stops under
  prefers-reduced-motion; nothing about the collapse depends on animation.
- cn() merges the consumer's className into the root, which also spreads the
  remaining element props and forwards its ref.

Customization levers
- Density: the chip box (padding, `rounded-full`, `text-xs`) and the label's
  max-width are single constants shared by the chip and its twin — change them in
  ONE place or the measurement silently drifts from what is painted.
- Budget: `maxVisible` caps the strip at N chips regardless of available width
  (three is a good "toolbar" feel); leave it out for "as many as genuinely fit".
- Wording: `label` renames the strip, `formatPinnedAt` swaps relative ages for
  absolute dates or a locale-aware formatter (return null to draw none),
  `emptyState` replaces the teaching copy, `errorMessage` + `onRetry` own the
  envelope failure.
- Roles: the role map is one object of { icon, label } — add "note" or "human
  agent" there and nothing else changes.
- Surfaces: the confirmation is a small presentational piece mounted in two
  places; swap it for a one-click undo (fire onUnpin immediately, show a toast)
  by rendering it inline-never and dropping the popover, or make it an
  AlertDialog if your product treats unpinning as destructive.
- Chrome: drop the header entirely for a bare row of chips, or give the trigger
  an icon instead of the "+N more" wording — just keep the twin in sync.

Concepts

  • Measured overflow, not a magic number — "show three chips" is a guess that is wrong on every other viewport. Hidden twins carry each pin's natural width, so the split is computed from the box that will actually be painted, in a layout effect that lands before the first frame. A late web font or a resized window re-runs the same pure function instead of nudging the previous answer.
  • The trigger pays for itself — the "+N more" button competes for the same pixels as the chips, so every candidate split is costed with it included, and it is measured at its widest possible label. Otherwise the strip flip-flops: hiding one more pin widens the trigger, which hides one more pin.
  • A pin is a pointer, not a copy — the item carries the id of the message plus a label, and onJump hands that id straight back. The strip never resolves, scrolls or highlights anything itself, which is why it drops unchanged into a virtualised transcript, a deep link or a server-rendered permalink.
  • Confirmation lives where the pin does — a chip's × opens a popover anchored to it; a row inside the overflow panel flips inline instead, because a nested popover portals to the document body and the panel would read that as an outside press and close underneath the question it just asked.
  • One-shot unpin plus a focus hand-off — the lock is a ref, read and written synchronously in the click handler, so a double-click cannot fire the callback twice. Removal then takes the focused control with it, so focus is placed on the pin that took its slot — but only when it was genuinely orphaned, never when a rejected request left the pin where it was.
  • Handler-gated affordances — no onJump, no pointer cursor and no tab stop; no onUnpin, no × at all. The chips quietly become labels rather than promising interactions the host never wired up.

On This Page