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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/pinned-messages.jsonPrompt
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
onJumphands 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; noonUnpin, no × at all. The chips quietly become labels rather than promising interactions the host never wired up.
Model Compare
A side-by-side arena for two answers to one prompt — per-side streaming states, derived speed, optional blind names, and a vote that fires exactly once.
Message Search
In-conversation search that expands from an icon into a field — debounced dispatch, a hit counter walked with Enter and Shift+Enter, and a result list of role, time and an excerpt with the query emphasised.