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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chat-sidebar.jsonPrompt
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.
Slope Chart
A four-state slope chart for two points in time — labelled at both ends, with a pooling label-avoidance pass, leader lines that go back to the real endpoint, and a same-unit check that refuses to draw a slope across two dimensions.
Chat Window
A chat shell in three presentations — inline, docked and modal — where switching never re-creates the panel, so the draft, caret and reading position survive.