{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"thread-list","type":"registry:ui","title":"ThreadList","description":"Conversation history for a chat sidebar: one row per thread with an unread dot, its last message, and a relative timestamp, plus selection and per-row delete","author":"fancy-ui <https://fancy-ui.rama.app>","categories":["ai-chat"],"registryDependencies":["https://fancy-ui.rama.app/r/internals.json","https://fancy-ui.rama.app/r/internals-dom.json","https://fancy-ui.rama.app/r/sound.json","https://fancy-ui.rama.app/r/utils.json"],"files":[{"path":"react/src/components/thread-list/ThreadList.tsx","content":"\"use client\";\n\nimport { forwardRef, useState } from \"react\";\nimport type { MouseEvent, ReactNode } from \"react\";\nimport { cn } from \"../../utils\";\nimport { useNow } from \"../../internals/use-elapsed\";\nimport { formatRelativeTime } from \"../../internals/relative-time\";\nimport type { ThreadData } from \"../../internals/ai-types\";\nimport { useSoundCue } from \"../../sound/use-sound\";\nimport \"./thread-list.css\";\n\n/**\n * Props for ThreadList\n */\nexport interface ThreadListProps {\n\t/** The conversations to list, in the order they should appear. */\n\tthreads: ThreadData[];\n\t/**\n\t * Id of the selected conversation. Passing it makes the selection\n\t * controlled — the React counterpart of the Svelte source's `$bindable`;\n\t * omit it entirely to let the list own its own selection and report every\n\t * pick through `onSelect`.\n\t */\n\tactiveId?: string;\n\t/** Called with the conversation the reader picked. The highlight moves either way. */\n\tonSelect?: (thread: ThreadData) => void;\n\t/** Supplying it puts a delete button on every row, revealed on hover or focus. */\n\tonDelete?: (thread: ThreadData) => void;\n\t/** Accessible name for the list. */\n\tlabel?: string;\n\t/** Replaces the built-in row body. Receives the thread and whether it is the active one. */\n\titem?: (thread: ThreadData, active: boolean) => ReactNode;\n\t/** Replaces the built-in \"no conversations yet\" line. */\n\tempty?: ReactNode;\n\t/** Additional CSS classes */\n\tclassName?: string;\n\t/**\n\t * Plays select when a different conversation is picked and press when\n\t * one is deleted, through the sound controller. Off by default; only\n\t * audible once the user has enabled sound.\n\t */\n\tsound?: boolean;\n}\n\n/** How often every timestamp in the list is recomputed. */\nconst REFRESH_MS = 30_000;\n\n/**\n * The machine-readable half of a timestamp. A date the browser could not read\n * has no ISO form, and `datetime=\"\"` is invalid rather than absent, so an\n * unreadable one is spread onto the element as no attributes at all.\n */\nfunction timeAttrs(timestamp: Date | number): { dateTime?: string; title?: string } {\n\tconst date = timestamp instanceof Date ? timestamp : new Date(timestamp);\n\tif (!Number.isFinite(date.getTime())) return {};\n\tconst exact = date.toISOString();\n\treturn { dateTime: exact, title: exact };\n}\n\n/** One rendered row: the conversation, and the key the list tracks it by. */\ninterface ThreadRow {\n\tthread: ThreadData;\n\tkey: string;\n}\n\n/**\n * The first thread under an id keeps that id as its key, so a row survives a\n * reorder with its DOM node — and its focus — intact. Only a repeat gets a\n * suffix, and the suffix counts occurrences rather than positions: keying on\n * the position would rename every row below an insertion, destroying the state\n * of rows that never moved.\n *\n * A suffix is also checked against the ids actually in the list, since one of\n * them may well look like a generated one: `x`, `x`, `x#1` would otherwise\n * hand the same key to two different rows.\n */\nfunction keyedRows(threads: ThreadData[]): ThreadRow[] {\n\tconst ids = new Set(threads.map((thread) => thread.id));\n\tconst seen = new Map<string, number>();\n\tconst used = new Set<string>();\n\treturn threads.map((thread) => {\n\t\tconst seenBefore = seen.get(thread.id) ?? 0;\n\t\tseen.set(thread.id, seenBefore + 1);\n\t\tlet key = thread.id;\n\t\tif (seenBefore > 0) {\n\t\t\tlet suffix = seenBefore;\n\t\t\tdo {\n\t\t\t\tkey = `${thread.id}#${suffix}`;\n\t\t\t\tsuffix++;\n\t\t\t} while (ids.has(key) || used.has(key));\n\t\t}\n\t\tused.add(key);\n\t\treturn { thread, key };\n\t});\n}\n\n/**\n * ThreadList renders a sidebar of conversations: title, preview, unread dot\n * and a relative timestamp per row, refreshed by one shared clock for the\n * whole list rather than one interval per row. Nothing ticks under SSR — the\n * clock only starts once the list is mounted.\n *\n * The root element arrives through the ref channel (the Svelte source\n * declares `ref = $bindable(null)`).\n */\nexport const ThreadList = forwardRef<HTMLDivElement, ThreadListProps>(function ThreadList(\n\t{\n\t\tthreads,\n\t\tactiveId: activeIdProp,\n\t\tonSelect,\n\t\tonDelete,\n\t\tlabel = \"Conversations\",\n\t\titem,\n\t\tempty,\n\t\tclassName,\n\t\tsound = false,\n\t},\n\tref\n) {\n\t// The Svelte source's `activeId` is `$bindable()`: a consumer can bind it,\n\t// or leave it alone and let the component keep writing its own copy. React\n\t// has no such channel, so the prop is controlled when it is passed and this\n\t// local copy takes over when it is not. Either way `onSelect` fires with\n\t// the picked thread.\n\tconst [uncontrolledActiveId, setUncontrolledActiveId] = useState<string | undefined>(undefined);\n\tconst isControlled = activeIdProp !== undefined;\n\tconst activeId = isControlled ? activeIdProp : uncontrolledActiveId;\n\n\t// One clock for the whole list: fifty rows cost one interval rather than\n\t// fifty, and nothing is scheduled until the list is mounted.\n\tconst now = useNow(REFRESH_MS);\n\n\tconst playCue = useSoundCue(sound);\n\n\tfunction select(thread: ThreadData) {\n\t\t// Changed-only, matching the sidebar/navbar/tabs rule: re-picking the row\n\t\t// that is already active plays nothing. The check gates the cue alone —\n\t\t// `onSelect` and the highlight move below still happen unconditionally.\n\t\tif (thread.id !== activeId) playCue(\"select\");\n\t\t// The highlight is moved whether or not anyone is listening on\n\t\t// `onSelect`, so an uncontrolled list drives itself.\n\t\tif (!isControlled) setUncontrolledActiveId(thread.id);\n\t\tonSelect?.(thread);\n\t}\n\n\tfunction remove(event: MouseEvent, thread: ThreadData) {\n\t\t// The delete button is a *sibling* of the row button rather than a child —\n\t\t// nesting buttons is invalid HTML — so nothing bubbles into a selection\n\t\t// today. Stopping it anyway keeps that true if a consumer ever wraps the\n\t\t// row in something clickable of their own — and it is what keeps this\n\t\t// press cue from also triggering the row's own select cue.\n\t\tevent.stopPropagation();\n\t\tplayCue(\"press\");\n\t\tonDelete?.(thread);\n\t}\n\n\tfunction relative(timestamp: Date | number): string {\n\t\treturn formatRelativeTime(timestamp, { now });\n\t}\n\n\treturn (\n\t\t<div\n\t\t\tref={ref}\n\t\t\tclassName={cn(\n\t\t\t\t\"ft-threadlist w-full text-sm\",\n\t\t\t\tonDelete !== undefined && \"ft-threadlist-deletable\",\n\t\t\t\tclassName\n\t\t\t)}\n\t\t>\n\t\t\t{threads.length === 0 ? (\n\t\t\t\t(empty ?? (\n\t\t\t\t\t<p className=\"ft-threadlist-empty text-muted-foreground px-2 py-6 text-center text-xs\">\n\t\t\t\t\t\tNo conversations yet\n\t\t\t\t\t</p>\n\t\t\t\t))\n\t\t\t) : (\n\t\t\t\t/*\n\t\t\t\t * `role=\"list\"` is stated rather than left implicit: `list-style: none`\n\t\t\t\t * strips list semantics in Safari, and how many conversations there are\n\t\t\t\t * is half of what this list says.\n\t\t\t\t */\n\t\t\t\t<ul className=\"ft-threadlist-list\" role=\"list\" aria-label={label}>\n\t\t\t\t\t{/* Keyed by the identity worked out above, never by the position. */}\n\t\t\t\t\t{keyedRows(threads).map(({ thread, key }) => {\n\t\t\t\t\t\tconst isActive = activeId !== undefined && thread.id === activeId;\n\t\t\t\t\t\treturn (\n\t\t\t\t\t\t\t<li key={key} className=\"ft-threadlist-row\">\n\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\t\t\"ft-threadlist-button focus-visible:ring-ring hover:bg-muted/60 flex w-full items-start gap-2 rounded-md py-2 pl-2 text-left transition-colors focus-visible:ring-2 focus-visible:outline-none\",\n\t\t\t\t\t\t\t\t\t\tisActive && \"ft-threadlist-active\"\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\taria-current={isActive ? \"true\" : undefined}\n\t\t\t\t\t\t\t\t\tonClick={() => select(thread)}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t{/*\n\t\t\t\t\t\t\t\t\t * The dot's column is reserved on every row, unread or not, so\n\t\t\t\t\t\t\t\t\t * the titles stay on one vertical line instead of stepping in\n\t\t\t\t\t\t\t\t\t * and out as messages are read.\n\t\t\t\t\t\t\t\t\t */}\n\t\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\t\t\t\"ft-threadlist-dot\",\n\t\t\t\t\t\t\t\t\t\t\tthread.unread && \"ft-threadlist-unread\"\n\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\taria-hidden=\"true\"\n\t\t\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t\t\t\t{thread.unread ? (\n\t\t\t\t\t\t\t\t\t\t// The dot is colour alone, which says nothing out loud.\n\t\t\t\t\t\t\t\t\t\t<span className=\"sr-only\">Unread</span>\n\t\t\t\t\t\t\t\t\t) : null}\n\n\t\t\t\t\t\t\t\t\t{item ? (\n\t\t\t\t\t\t\t\t\t\titem(thread, isActive)\n\t\t\t\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t\t\t\t<span className=\"ft-threadlist-main min-w-0 flex-1\">\n\t\t\t\t\t\t\t\t\t\t\t<span className=\"flex min-w-0 items-baseline gap-2\">\n\t\t\t\t\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\"ft-threadlist-title truncate\",\n\t\t\t\t\t\t\t\t\t\t\t\t\t\tthread.unread && \"ft-threadlist-strong\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t\t\ttitle={thread.title}\n\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t{thread.title}\n\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t\t<time\n\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"ft-threadlist-time text-muted-foreground ml-auto shrink-0 text-xs tabular-nums\"\n\t\t\t\t\t\t\t\t\t\t\t\t\t{...timeAttrs(thread.updatedAt)}\n\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t{relative(thread.updatedAt)}\n\t\t\t\t\t\t\t\t\t\t\t\t</time>\n\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t{thread.preview ? (\n\t\t\t\t\t\t\t\t\t\t\t\t<span\n\t\t\t\t\t\t\t\t\t\t\t\t\tclassName=\"ft-threadlist-preview text-muted-foreground mt-0.5 block truncate text-xs\"\n\t\t\t\t\t\t\t\t\t\t\t\t\ttitle={thread.preview}\n\t\t\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t\t\t{thread.preview}\n\t\t\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t\t\t) : null}\n\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t</button>\n\n\t\t\t\t\t\t\t\t{onDelete ? (\n\t\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\t\tclassName=\"ft-threadlist-delete text-muted-foreground hover:text-foreground focus-visible:ring-ring grid size-7 place-items-center rounded-md focus-visible:ring-2 focus-visible:outline-none\"\n\t\t\t\t\t\t\t\t\t\taria-label={`Delete ${thread.title}`}\n\t\t\t\t\t\t\t\t\t\tonClick={(event) => remove(event, thread)}\n\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t<svg\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"size-3.5\"\n\t\t\t\t\t\t\t\t\t\t\tviewBox=\"0 0 24 24\"\n\t\t\t\t\t\t\t\t\t\t\tfill=\"none\"\n\t\t\t\t\t\t\t\t\t\t\tstroke=\"currentColor\"\n\t\t\t\t\t\t\t\t\t\t\tstrokeWidth=\"2\"\n\t\t\t\t\t\t\t\t\t\t\tstrokeLinecap=\"round\"\n\t\t\t\t\t\t\t\t\t\t\tstrokeLinejoin=\"round\"\n\t\t\t\t\t\t\t\t\t\t\taria-hidden=\"true\"\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t<path d=\"M3 6h18\" />\n\t\t\t\t\t\t\t\t\t\t\t<path d=\"M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2\" />\n\t\t\t\t\t\t\t\t\t\t\t<path d=\"M19 6v14a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V6\" />\n\t\t\t\t\t\t\t\t\t\t\t<path d=\"M10 11v6\" />\n\t\t\t\t\t\t\t\t\t\t\t<path d=\"M14 11v6\" />\n\t\t\t\t\t\t\t\t\t\t</svg>\n\t\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t\t) : null}\n\t\t\t\t\t\t\t</li>\n\t\t\t\t\t\t);\n\t\t\t\t\t})}\n\t\t\t\t</ul>\n\t\t\t)}\n\t\t</div>\n\t);\n});\n","type":"registry:ui","target":"@ui/fancy/components/thread-list/ThreadList.tsx"},{"path":"react/src/components/thread-list/index.ts","content":"\"use client\";\n\nexport { ThreadList } from \"./ThreadList\";\nexport type { ThreadListProps } from \"./ThreadList\";\n","type":"registry:ui","target":"@ui/fancy/components/thread-list/index.ts"},{"path":"react/src/components/thread-list/thread-list.css","content":"/* Every class name below carries the component prefix, matching the source. */\n\n.ft-threadlist {\n\t/*\n\t * The active tint is a mix of currentColor, so one value reads correctly on\n\t * both themes without a `light-dark()` pair. It stays deliberately faint —\n\t * seven percent of the text colour — because the row's muted preview line\n\t * has to keep its contrast on top of it.\n\t */\n\t--ft-threadlist-active-bg: color-mix(in oklab, currentColor 7%, transparent);\n\t--ft-threadlist-dot-size: 0.4375rem;\n\t--ft-threadlist-reveal-duration: 150ms;\n}\n\n.ft-threadlist-list {\n\tmargin: 0;\n\tpadding: 0;\n\tlist-style: none;\n}\n\n.ft-threadlist-row {\n\t/* Anchors the delete button, which cannot live inside the row button. */\n\tposition: relative;\n}\n\n.ft-threadlist-button {\n\t/* Room for the delete button, but only on a list that has one. */\n\tpadding-right: 0.5rem;\n}\n\n.ft-threadlist-deletable .ft-threadlist-button {\n\tpadding-right: 2.25rem;\n}\n\n.ft-threadlist-button.ft-threadlist-active {\n\tbackground: var(--ft-threadlist-active-bg);\n}\n\n.ft-threadlist-dot {\n\tflex: none;\n\t/* Centred on the title's line box: half of 1.25rem, less half the dot. */\n\tmargin-top: 0.40625rem;\n\twidth: var(--ft-threadlist-dot-size);\n\theight: var(--ft-threadlist-dot-size);\n\tborder-radius: 9999px;\n\tbackground: transparent;\n}\n\n/*\n * Read at the point of use through the component's own hook, then the\n * `--ft-status-*` vocabulary this whole family shares, then the literal.\n * Setting either anywhere up the tree retints the dot without having to win a\n * specificity fight against this rule.\n */\n.ft-threadlist-dot.ft-threadlist-unread {\n\tbackground: var(\n\t\t--ft-threadlist-unread,\n\t\tvar(--ft-status-running, light-dark(oklch(0.5 0.18 265), oklch(0.72 0.15 265)))\n\t);\n}\n\n.ft-threadlist-title {\n\tfont-weight: 500;\n}\n\n/*\n * Weight rather than a Tailwind pair: `font-medium` and `font-semibold` on the\n * same element would leave the winner to source order in the stylesheet.\n */\n.ft-threadlist-title.ft-threadlist-strong {\n\tfont-weight: 600;\n}\n\n.ft-threadlist-delete {\n\tposition: absolute;\n\ttop: 0.375rem;\n\tright: 0.25rem;\n\topacity: 0;\n}\n\n/*\n * Hidden until wanted, but never *removed*: it stays in the tab order, and\n * tabbing to it lights it up through `:focus-within` on the row.\n */\n.ft-threadlist-row:hover .ft-threadlist-delete,\n.ft-threadlist-row:focus-within .ft-threadlist-delete {\n\topacity: 1;\n}\n\n/* Nothing hovers on a touch screen, so there the button is simply always on. */\n@media (hover: none) {\n\t.ft-threadlist-delete {\n\t\topacity: 1;\n\t}\n}\n\n/*\n * The fade lives entirely inside `no-preference`, so reduced motion is not a\n * degraded variant to keep in sync: with the rule gone the button appears and\n * disappears at full opacity, on the same hover and focus.\n */\n@media (prefers-reduced-motion: no-preference) {\n\t.ft-threadlist-delete {\n\t\ttransition:\n\t\t\topacity var(--ft-threadlist-reveal-duration) ease,\n\t\t\tcolor var(--ft-threadlist-reveal-duration) ease;\n\t}\n}\n","type":"registry:ui","target":"@ui/fancy/components/thread-list/thread-list.css"}],"meta":{"license":"MIT","package":"fancy-ui-react","version":"0.7.0","source":"https://fancy-ui.rama.app/docs/components/thread-list?framework=react"}}