{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"chat-panel","type":"registry:ui","title":"ChatPanel","description":"The shell a conversation lives in: a sticky header, a transcript that pins itself to the bottom while an answer arrives and offers the way back once you scroll up, and a sticky composer row","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/internals-motion.json","https://fancy-ui.rama.app/r/sound.json","https://fancy-ui.rama.app/r/utils.json"],"files":[{"path":"react/src/components/chat-panel/ChatEmptyState.tsx","content":"\"use client\";\n\nimport { forwardRef } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { cn } from \"../../utils\";\n\n/**\n * Props for ChatEmptyState\n */\nexport interface ChatEmptyStateProps {\n\t/** The greeting, read as the heading of the empty conversation. */\n\ttitle?: string;\n\t/** A line under the greeting saying what this assistant is for. */\n\tdescription?: string;\n\t/** Decorative mark above the greeting, replacing the default sparkle. */\n\ticon?: ReactNode;\n\t/** Rendered under the description — where `PromptSuggestions` belongs. */\n\tchildren?: ReactNode;\n\t/** Additional CSS classes */\n\tclassName?: string;\n}\n\nexport const ChatEmptyState = forwardRef<HTMLDivElement, ChatEmptyStateProps>(\n\tfunction ChatEmptyState(\n\t\t{ title = \"How can I help?\", description, icon, children, className },\n\t\tref\n\t) {\n\t\treturn (\n\t\t\t<div\n\t\t\t\tref={ref}\n\t\t\t\tclassName={cn(\n\t\t\t\t\t\"ft-empty text-muted-foreground flex h-full flex-col items-center justify-center gap-3 px-6 py-16 text-center\",\n\t\t\t\t\tclassName\n\t\t\t\t)}\n\t\t\t>\n\t\t\t\t{/*\n\t\t\t\t * Decorative by definition: the greeting below carries the meaning, so\n\t\t\t\t * the mark is hidden from the accessibility tree whether it is ours or\n\t\t\t\t * a consumer's.\n\t\t\t\t */}\n\t\t\t\t<span className=\"ft-empty-icon\" aria-hidden=\"true\">\n\t\t\t\t\t{icon ?? (\n\t\t\t\t\t\t<svg\n\t\t\t\t\t\t\tclassName=\"size-8 opacity-60\"\n\t\t\t\t\t\t\tviewBox=\"0 0 24 24\"\n\t\t\t\t\t\t\tfill=\"none\"\n\t\t\t\t\t\t\tstroke=\"currentColor\"\n\t\t\t\t\t\t\tstrokeWidth=\"1.5\"\n\t\t\t\t\t\t\tstrokeLinecap=\"round\"\n\t\t\t\t\t\t\tstrokeLinejoin=\"round\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<path d=\"M11 5 12.6 10.9 18.5 12.5 12.6 14.1 11 20 9.4 14.1 3.5 12.5 9.4 10.9Z\" />\n\t\t\t\t\t\t\t<path d=\"M18.5 3 19.1 5.4 21.5 6 19.1 6.6 18.5 9 17.9 6.6 15.5 6 17.9 5.4Z\" />\n\t\t\t\t\t\t</svg>\n\t\t\t\t\t)}\n\t\t\t\t</span>\n\n\t\t\t\t<h2 className=\"ft-empty-title text-foreground text-lg font-semibold text-balance\">\n\t\t\t\t\t{title}\n\t\t\t\t</h2>\n\n\t\t\t\t{description ? (\n\t\t\t\t\t<p className=\"ft-empty-description max-w-prose text-sm leading-relaxed text-balance\">\n\t\t\t\t\t\t{description}\n\t\t\t\t\t</p>\n\t\t\t\t) : null}\n\n\t\t\t\t{children ? (\n\t\t\t\t\t<div className=\"ft-empty-extra mt-2 flex w-full flex-col items-center\">{children}</div>\n\t\t\t\t) : null}\n\t\t\t</div>\n\t\t);\n\t}\n);\n","type":"registry:ui","target":"@ui/fancy/components/chat-panel/ChatEmptyState.tsx"},{"path":"react/src/components/chat-panel/ChatPanel.tsx","content":"\"use client\";\n\nimport { forwardRef, useCallback, useEffect, useRef, useState } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { cn } from \"../../utils\";\nimport { scrollToBottom, useAutoscroll } from \"../../internals/use-autoscroll\";\nimport { useElementRef } from \"../../internals/dom/use-element-ref\";\nimport { useLiveRef } from \"../../internals/dom/use-live-ref\";\nimport { useIsomorphicLayoutEffect } from \"../../internals/dom/ssr\";\nimport { prefersReducedMotion } from \"../../internals/motion/anchored\";\nimport { useSoundCue } from \"../../sound/use-sound\";\nimport \"./chat-panel.css\";\n\n/**\n * Props for ChatPanel\n */\nexport interface ChatPanelProps {\n\t/** Whether a reply is still arriving. Pins the scroll region to its bottom while true. */\n\tstreaming?: boolean;\n\t/** Renders `emptyState` in place of `children` — a conversation with no turns yet. */\n\tempty?: boolean;\n\t/** Label on the pill that appears once the reader scrolls away from the bottom. */\n\treturnLabel?: string;\n\t/** Accessible name for the panel as a whole. */\n\tlabel?: string;\n\t/** Sticky top region: a title, a model name, a close button. */\n\theader?: ReactNode;\n\t/** The message stream, filling the scroll region. */\n\tchildren?: ReactNode;\n\t/** Sticky bottom region — where `Composer` belongs. */\n\tcomposer?: ReactNode;\n\t/** Rendered instead of `children` while `empty` — where `ChatEmptyState` belongs. */\n\temptyState?: ReactNode;\n\t/** Additional CSS classes */\n\tclassName?: string;\n\t/**\n\t * Plays the press cue through the sound controller when the\n\t * jump-to-latest pill is activated. Off by default; only audible\n\t * once the user has enabled sound.\n\t */\n\tsound?: boolean;\n}\n\n/** How close to the bottom (px) still counts as pinned — the autoscroll hook's own default. */\nconst BOTTOM_THRESHOLD = 40;\n\nexport const ChatPanel = forwardRef<HTMLDivElement, ChatPanelProps>(function ChatPanel(\n\t{\n\t\tstreaming = false,\n\t\tempty = false,\n\t\treturnLabel = \"Jump to latest\",\n\t\tlabel = \"Conversation\",\n\t\theader,\n\t\tchildren,\n\t\tcomposer,\n\t\temptyState,\n\t\tclassName,\n\t\tsound = false,\n\t},\n\tref\n) {\n\tconst [scrollEl, scrollRef] = useElementRef<HTMLDivElement>();\n\t// The transcript itself. The scroll region has a fixed height, so it never\n\t// resizes; its content is what grows when an image lands or a font swaps in.\n\tconst [contentEl, contentRef] = useElementRef<HTMLDivElement>();\n\n\t// Content that fits its container counts as pinned, so the pill starts hidden\n\t// and the first sync corrects it if the transcript is already taller.\n\tconst [stuck, setStuck] = useState(true);\n\n\t/**\n\t * Whether the reader has taken the scrollbar over. Until they have, the panel\n\t * still counts as opening, and every batch of arriving content lands it at the\n\t * latest turn — a transcript that renders a frame after mount would otherwise\n\t * open at the top of a conversation nobody asked to re-read.\n\t */\n\tconst touched = useRef(false);\n\t/** Suppresses the scroll event our own snap is about to produce. */\n\tconst selfScroll = useRef(false);\n\t/** Batches a burst of mutations into one read of the geometry. */\n\tconst frame = useRef<number | null>(null);\n\t/** The observer callback outlives any single render; it reads `empty` here. */\n\tconst emptyLive = useLiveRef(empty);\n\n\tconst playCue = useSoundCue(sound);\n\n\t/**\n\t * The pill's visibility is tracked here rather than through the autoscroll\n\t * hook's `onStickChange`, because the hook releases every listener the moment\n\t * `enabled` goes false — which is most of the time, since it is only enabled\n\t * while a reply streams. A reader scrolling up between replies would\n\t * otherwise never be offered the way back.\n\t */\n\tconst syncStuck = useCallback((): void => {\n\t\tconst el = scrollEl;\n\t\tif (!el) return;\n\t\tsetStuck(el.scrollHeight - el.scrollTop - el.clientHeight <= BOTTOM_THRESHOLD);\n\t}, [scrollEl]);\n\n\t/**\n\t * Every scroll the panel performs on its own behalf goes through here, so the\n\t * scroll event it produces is not mistaken for the reader reaching for the\n\t * scrollbar. One frame is enough to tell the two apart: a scroll event is\n\t * delivered before the next frame's callbacks run. The guard is only armed if\n\t * the scrollbar actually moved — a panel that was already at its bottom edge\n\t * emits no event to suppress, and suppressing nothing would swallow the\n\t * reader's next real scroll instead.\n\t */\n\tconst snap = useCallback((el: HTMLElement, behavior: ScrollBehavior): void => {\n\t\tconst before = el.scrollTop;\n\t\tscrollToBottom(el, behavior);\n\t\tif (el.scrollTop === before) return;\n\t\tselfScroll.current = true;\n\t\trequestAnimationFrame(() => {\n\t\t\tselfScroll.current = false;\n\t\t});\n\t}, []);\n\n\tfunction handleScroll(): void {\n\t\tif (!selfScroll.current) touched.current = true;\n\t\tsyncStuck();\n\t}\n\n\t/**\n\t * Content growing under a scrollbar that never moved fires no scroll event, so\n\t * without this the pill would stay hidden through an entire reply arriving\n\t * below a reader who had scrolled up — and a transcript rendered after mount\n\t * would leave the panel at the top of the thread.\n\t */\n\tconst handleMutation = useCallback((): void => {\n\t\tif (frame.current !== null) return;\n\t\tframe.current = requestAnimationFrame(() => {\n\t\t\tframe.current = null;\n\t\t\tconst el = scrollEl;\n\t\t\tif (!el) return;\n\t\t\tif (!touched.current && !emptyLive.current) snap(el, \"instant\");\n\t\t\tsyncStuck();\n\t\t});\n\t}, [scrollEl, emptyLive, snap, syncStuck]);\n\n\tfunction jumpToLatest(): void {\n\t\tconst el = scrollEl;\n\t\tif (!el) return;\n\t\tplayCue(\"press\");\n\t\t// The pill is about to disappear under the pointer, which would leave the\n\t\t// keyboard with nothing focused. Handing focus to the scroll region keeps\n\t\t// the reader where they were and lets them carry on with the arrow keys.\n\t\tel.focus({ preventScroll: true });\n\t\tsnap(el, prefersReducedMotion() ? \"instant\" : \"smooth\");\n\t}\n\n\tuseAutoscroll(scrollEl, { enabled: streaming });\n\n\t// A conversation opens at its latest turn, the way every conversation does.\n\t// Instant rather than smooth: there is no journey to show on first paint, and\n\t// a smooth scroll still in flight reads as \"not at the bottom\" to the stick\n\t// tracking above. Keyed on the node and nothing else, so it runs once.\n\tuseIsomorphicLayoutEffect(() => {\n\t\tconst el = scrollEl;\n\t\tif (!el) return;\n\t\tsnap(el, \"instant\");\n\t\tsyncStuck();\n\t}, [scrollEl, snap, syncStuck]);\n\n\t// The empty state taking the transcript's place, or a reply starting to grow\n\t// it, both change what the region holds without moving its scrollbar.\n\t// `syncStuck` writes `stuck` and never reads it, so this cannot feed itself.\n\tuseEffect(() => {\n\t\tsyncStuck();\n\t}, [streaming, empty, syncStuck]);\n\n\tuseEffect(() => {\n\t\tconst el = scrollEl;\n\t\tif (!el) return;\n\t\tconst growth = new MutationObserver(handleMutation);\n\t\tgrowth.observe(el, { childList: true, subtree: true, characterData: true });\n\n\t\t// A mutation is not the only way the transcript gets taller: an image\n\t\t// finishing its download, a web font swapping in, or a block expanding\n\t\t// under CSS changes the height with the DOM untouched, and the pill would\n\t\t// go on claiming the reader is at the end.\n\t\tlet resize: ResizeObserver | null = null;\n\t\tif (contentEl && typeof ResizeObserver !== \"undefined\") {\n\t\t\tresize = new ResizeObserver(handleMutation);\n\t\t\tresize.observe(contentEl);\n\t\t}\n\n\t\treturn () => {\n\t\t\tgrowth.disconnect();\n\t\t\tresize?.disconnect();\n\t\t\tif (frame.current !== null) cancelAnimationFrame(frame.current);\n\t\t\tframe.current = null;\n\t\t};\n\t}, [scrollEl, contentEl, handleMutation]);\n\n\treturn (\n\t\t<div\n\t\t\tref={ref}\n\t\t\tclassName={cn(\n\t\t\t\t\"ft-panel bg-card text-card-foreground flex h-full flex-col overflow-hidden rounded-xl border\",\n\t\t\t\tclassName\n\t\t\t)}\n\t\t\trole=\"region\"\n\t\t\taria-label={label}\n\t\t\tdata-streaming={streaming ? \"\" : undefined}\n\t\t\tdata-empty={empty ? \"\" : undefined}\n\t\t>\n\t\t\t{header ? (\n\t\t\t\t/*\n\t\t\t\t * Sticky without `position: sticky`: the shell is a flex column whose\n\t\t\t\t * middle row is the only one allowed to grow, so the two ends simply\n\t\t\t\t * never scroll.\n\t\t\t\t */\n\t\t\t\t<div className=\"ft-panel-header border-border flex-none border-b\">{header}</div>\n\t\t\t) : null}\n\n\t\t\t<div className=\"ft-panel-viewport relative min-h-0 flex-1\">\n\t\t\t\t{/*\n\t\t\t\t * `min-h-0` on the wrapper is what lets this row shrink below its\n\t\t\t\t * content: without it a flex item refuses to go under its intrinsic\n\t\t\t\t * height and the whole panel grows instead of scrolling. The region is\n\t\t\t\t * focusable because a keyboard has no other way to scroll it, and its\n\t\t\t\t * ring is drawn inset — the region fills the wrapper edge to edge, so a\n\t\t\t\t * ring outside its border box would be clipped away by the shell's\n\t\t\t\t * `overflow-hidden`. It carries no role of its own: the panel around it\n\t\t\t\t * is already the named `Conversation` region, and a `log` here would\n\t\t\t\t * have a screen reader read every streamed token back out loud.\n\t\t\t\t */}\n\t\t\t\t<div\n\t\t\t\t\tref={scrollRef}\n\t\t\t\t\tclassName=\"ft-panel-scroll focus-visible:ring-ring h-full overflow-y-auto focus-visible:ring-2 focus-visible:outline-none focus-visible:ring-inset\"\n\t\t\t\t\ttabIndex={0}\n\t\t\t\t\tonScroll={handleScroll}\n\t\t\t\t>\n\t\t\t\t\t{/*\n\t\t\t\t\t * A stable wrapper around whichever branch renders, so there is one\n\t\t\t\t\t * element to watch for height changes whether the panel is empty or\n\t\t\t\t\t * not.\n\t\t\t\t\t */}\n\t\t\t\t\t<div ref={contentRef}>{empty ? emptyState : children}</div>\n\t\t\t\t</div>\n\n\t\t\t\t{!stuck ? (\n\t\t\t\t\t<button\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\tclassName=\"ft-panel-return bg-card text-foreground border-border hover:bg-muted focus-visible:ring-ring absolute inset-x-0 z-10 mx-auto flex w-fit cursor-pointer items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs font-medium shadow-md transition-colors focus-visible:ring-2 focus-visible:outline-none\"\n\t\t\t\t\t\tonClick={jumpToLatest}\n\t\t\t\t\t>\n\t\t\t\t\t\t<svg\n\t\t\t\t\t\t\tclassName=\"size-3.5\"\n\t\t\t\t\t\t\tviewBox=\"0 0 24 24\"\n\t\t\t\t\t\t\tfill=\"none\"\n\t\t\t\t\t\t\tstroke=\"currentColor\"\n\t\t\t\t\t\t\tstrokeWidth=\"2\"\n\t\t\t\t\t\t\tstrokeLinecap=\"round\"\n\t\t\t\t\t\t\tstrokeLinejoin=\"round\"\n\t\t\t\t\t\t\taria-hidden=\"true\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<path d=\"M12 5v14\" />\n\t\t\t\t\t\t\t<path d=\"m19 12-7 7-7-7\" />\n\t\t\t\t\t\t</svg>\n\t\t\t\t\t\t{returnLabel}\n\t\t\t\t\t</button>\n\t\t\t\t) : null}\n\t\t\t</div>\n\n\t\t\t{composer ? (\n\t\t\t\t<div className=\"ft-panel-composer border-border bg-card flex-none border-t\">\n\t\t\t\t\t{composer}\n\t\t\t\t</div>\n\t\t\t) : null}\n\t\t</div>\n\t);\n});\n","type":"registry:ui","target":"@ui/fancy/components/chat-panel/ChatPanel.tsx"},{"path":"react/src/components/chat-panel/chat-panel.css","content":"/*\n * How far the pill floats above the composer. The only geometry worth\n * exposing: everything else about the pill is a utility a consumer can\n * out-specify through `className` on the panel.\n */\n.ft-panel-return {\n\tbottom: var(--ft-panel-return-offset, 0.75rem);\n}\n\n@media (prefers-reduced-motion: no-preference) {\n\t.ft-panel-return {\n\t\tanimation: ft-panel-return-in 180ms cubic-bezier(0.4, 0, 0.2, 1) both;\n\t}\n}\n\n@keyframes ft-panel-return-in {\n\tfrom {\n\t\topacity: 0;\n\t\ttransform: translateY(0.375rem);\n\t}\n\tto {\n\t\topacity: 1;\n\t\ttransform: translateY(0);\n\t}\n}\n","type":"registry:ui","target":"@ui/fancy/components/chat-panel/chat-panel.css"},{"path":"react/src/components/chat-panel/index.ts","content":"\"use client\";\n\nexport { ChatPanel } from \"./ChatPanel\";\nexport type { ChatPanelProps } from \"./ChatPanel\";\nexport { ChatEmptyState } from \"./ChatEmptyState\";\nexport type { ChatEmptyStateProps } from \"./ChatEmptyState\";\n","type":"registry:ui","target":"@ui/fancy/components/chat-panel/index.ts"}],"meta":{"license":"MIT","package":"fancy-ui-react","version":"0.7.0","source":"https://fancy-ui.rama.app/docs/components/chat-panel?framework=react"}}