AI

Chat Sidebar

A conversation-history sidebar — calendar-day groups cut in the display time zone, in-place rename with rollback, delete confirmed by title, pinning, local search with its own no-match state, and real loading/empty/error branches.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  AlertCircle,
  Link2,
  MessagesSquare,
  MoreHorizontal,
  Pencil,
  Pin,
  PinOff,
  Search,
  SearchX,
  Share2,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/chat-sidebar.json

Prompt

Build a React + TypeScript + Tailwind "ChatSidebar" component with zod,
lucide-react, a shadcn DropdownMenu and a ConfirmDialog.

Contract
- `chatConversationSchema` in a sibling contract file is the single source of
  truth for one row: { id, title, updatedAt (ISO 8601 with offset), pinned?,
  preview?, shared? }. A separate `chatSidebarStatusSchema` — "loading" |
  "empty" | "error" | "ready" — is the list's own render state.
- `updatedAt` stays an absolute instant and is never a pre-bucketed "Today"
  string: which calendar day an instant falls on depends on who is reading it,
  so the server cannot decide it. Determinism is bought with props instead:
  `now` (ISO string | epoch ms | Date), `timeZone` (IANA id, default "UTC") and
  `locale` (default "en-US"). Render never calls Date.now(), new Date() or
  Intl.*(undefined), so server and client output are identical.
- Other props: items; status; activeId?; onSelect?(item); onNewChat?;
  onRename?(id, title) returning void or Promise<void>; onDelete?(id) the same;
  onTogglePin?(id, pinned); onShare?(id); onRetry?; searchable? (default true);
  titleLines? (1 | 2 | 3 | "none", default 1); skeletonRows? (clamped 1-16);
  label?; emptyState?; footer?; className.
- Every affordance is gated on the callback that would make it work, so nothing
  dead ever renders: no onNewChat means no New chat button, no write handler at
  all means no row menu, no onRetry means the error branch has no button.

Behavior
- Grouping. Order is Pinned (flag-driven, dates ignored), then Today,
  Yesterday, Previous 7 days, Earlier. The bucket is a CALENDAR-DAY distance,
  not a rolling 24h window: format the instant with
  Intl.DateTimeFormat("en-US", { timeZone, year, month, day }), read the numeric
  parts, and re-anchor that (y, m, d) triple on the UTC timeline to get an
  integer day index; subtract the same index computed for `now`. A delta of 0 or
  less is Today (future instants from clock skew fold in here), 1 is Yesterday,
  2 to 7 is Previous 7 days, above 7 is Earlier. Three traps this exists to
  avoid: comparing getDate()/getUTCDate() breaks every month and year boundary
  (1 Mar minus 28 Feb reads as -27 days); subtracting raw epoch ms compares
  elapsed hours, so a 23:50 chat becomes "2 days ago" at 00:10 and a DST day is
  23 or 25 hours long; and cutting the boundary on UTC files a Tokyo evening
  chat under the wrong header. Pin the day formatter's locale to "en-US" — a
  locale whose default calendar is not Gregorian returns a different triple for
  the same instant. An unknown IANA id falls back to UTC instead of throwing.
- Sorting is newest-first inside each bucket with the incoming array index as
  the tie-breaker, so equal timestamps never shuffle between renders. An
  unparseable `updatedAt` is filed under Earlier, sorted last, and read out as
  "Date unknown" — never dropped, never "Invalid Date".
- Rename is in place, not a dialog. Opening it (row menu, or F2 on a focused
  row) swaps the row for an input, focuses it and selects the whole title: the
  common case is "replace", and "append" is one End keypress away. Do that
  focus+select from a ref callback with a STABLE identity (useCallback with no
  deps) — an inline arrow re-runs on every render and would re-select the text
  the user is typing. Enter submits, Escape restores the original and hands
  focus back to the row, blur submits (an accidental click-away must not throw
  the edit away). Empty is refused two different ways on purpose: Enter on a
  blank field shows an inline error and keeps the editor open, while blurring
  out of a blank field simply cancels — refusing to release focus there would be
  a keyboard trap. Either way "" is never stored. A returned promise puts the
  input in readOnly + aria-busy (never `disabled`, which blurs the element the
  instant it is set and drops focus to the body); a rejection rolls the field
  back to the stored title, keeps the editor open, keeps the caret, and shows
  the reason.
- Delete is confirmed by a ConfirmDialog whose description quotes the
  conversation's title — "delete this chat?" is unverifiable, and a mis-aimed
  delete is unrecoverable. Resolve the neighbouring id BEFORE deleting (next
  row, else previous, else none) and move focus there once the dialog closes.
  The dialog is controlled and has no trigger of its own, so Radix tries to
  restore focus to something that does not exist and focus lands on the body:
  park the target in a ref and apply it on a macrotask after commit, falling
  back to the New chat button and then to the tabIndex -1 root. Cancelling
  returns focus to the row's menu button. A rejected delete keeps the dialog
  open with the reason and leaves the row alive.
- Pinning moves the row into another group, which unmounts the very trigger the
  menu wants to restore focus to — suppress Radix's onCloseAutoFocus for actions
  that place focus themselves, then re-find the control by id after the move.
- Search filters title + preview case-insensitively on the client. Zero matches
  renders a DIFFERENT state from "no conversations yet": it names the query,
  reports the total it searched, and offers Clear search. Escape inside the
  field clears the filter and stops propagating, so it cannot also close the
  drawer the sidebar lives in. One polite live region reports
  "M of N conversations match Q", a second reports action outcomes, so a result
  count never overwrites "Deleted X".
- The open conversation carries aria-current="page" and is scrolled into the
  scrollport by adjusting the container's own scrollTop — scrollIntoView would
  also scroll the page and every scrollport in between. Add the sticky group
  heading's height AND the scroller's padding-top to the top inset: a sticky
  `top: 0` parks against the padding edge, so leaving the padding out lands the
  row a few pixels underneath the heading.
- Four first-class branches on `status`: loading (skeleton rows at the real row
  height, aria-busy, one polite "Loading conversations…"), empty ("no
  conversations yet" plus the New chat button, replaceable via `emptyState`),
  error (message plus Try again), ready (search + grouped lists). There is no
  cap on how many rows render.

Rendering & styling
- Semantic tokens only: bg-card / text-card-foreground for the surface, bg-muted
  and text-muted-foreground for hover, previews, group headings and skeletons,
  bg-primary/4 plus a bg-primary left rail for the current row, text-destructive
  for the error branch and the failed-rename message, focus-visible:ring-2
  ring-ring on every control. cn() merges the consumer className into the root.
  "Current" is carried by three channels — rail, font weight, tint — because in
  this palette a 4% primary tint and the hover surface are nearly the same
  colour, and a 10% tint would push the preview line under AA (4% measures
  4.58:1 in the light theme, 7.25:1 in the dark one).
- Long titles use `wrap-anywhere` together with `min-w-0`, not `break-words`:
  break-words leaves min-content at the longest word, so a single 68-character
  identifier can widen the whole column. Truncation is a prop (`titleLines`)
  rather than a hard-coded clamp.
- Structure: a nav landmark wrapping one heading + one list per group, each list
  labelled by its heading. The row is a button (the consumer owns routing) and
  the menu trigger is its sibling — never a button inside a link. Pinned/shared
  glyphs sit inside the row button with sr-only text so they reach its
  accessible name, and an sr-only time element carries the exact instant in the
  same zone the grouping was cut on.
- Only the skeleton pulse and colour transitions animate, both carrying
  motion-reduce variants that switch them off.

Customization levers
- Buckets: edit the ladder in `bucketOf` (add "Previous 30 days", or split
  Earlier by month) plus the matching entries in GROUP_LABEL and
  CHAT_GROUP_ORDER — the day-index maths underneath stays untouched. Localise
  the labels by turning GROUP_LABEL into a prop.
- Zone policy: the "UTC" default keeps SSR and hydration identical; pass
  Intl.DateTimeFormat().resolvedOptions().timeZone from a client effect for a
  viewer-local sidebar, or a stored workspace zone for a team-consistent one.
- Density and content: `titleLines` 1 for a ChatGPT-tight rail, 2-3 or "none"
  for a roomier column; drop `preview` from the schema for title-only rows;
  py-1.5 to py-1 tightens the list without touching any state machine.
- Action set: the menu is assembled from whichever of rename / pin / share /
  delete handlers you pass — "Duplicate" or "Move to project" is one more
  DropdownMenuItem plus one more callback.
- Scale: rows are plain DOM with no per-row effects; 500 of them render in about
  60ms with a ~9ms filter keystroke (measured in Edge). Past a few thousand,
  wrap the list in @tanstack/react-virtual and leave the grouping memo as is.
- Chrome: `footer` takes an account row or storage meter, `label` names the
  landmark, `emptyState` replaces the whole zero-state body, and
  `searchable={false}` removes the filter when the list is always short.

Concepts

  • Calendar-day bucketing — "Today" is a property of a date in a zone, not of an elapsed duration. The instant is formatted in the display zone, the resulting year/month/day triple is re-anchored on the UTC timeline, and two integers are subtracted. That one indirection is what survives month ends, year ends, DST days that are 23 or 25 hours long, and half-hour offsets such as Asia/Kolkata.
  • Injected now — the instant everything is measured against is a prop, so the component is a pure function of its inputs: no wall-clock read during render, byte-identical SSR and hydration, reproducible screenshots. The cost is explicit: a tab left open overnight keeps yesterday's grouping until you push a fresh now.
  • Rollback on rejection — an optimistic rename the server refuses puts the field back to the title the server still holds, keeps the editor mounted and keeps the caret. The alternative — closing the editor and firing a toast — asks the user to remember what they had typed.
  • Confirm by name — a destructive dialog that says "this chat" cannot be checked by the person reading it. Quoting the title turns the confirmation into something the user can actually verify before agreeing.
  • Focus hand-off — a row that disappears takes focus with it. The neighbour is resolved before the delete, parked in a ref, and applied after every other layer has finished moving focus around; the root is focusable programmatically so even the last-row case has somewhere to land instead of body.
  • Filtered-empty vs never-had-data — "nothing matched sonnet" and "you have never started a chat" need different words and different exits. Sharing one empty state tells a user with 400 conversations that they have none.
  • Handler-gated affordances — a control exists only when the callback that would make it work was passed, so an unwired sidebar degrades into a read-only list instead of a wall of dead buttons.

On This Page