chore: init gsd
This commit is contained in:
@@ -0,0 +1,507 @@
|
||||
# Chat & Messaging
|
||||
|
||||
Components for conversation and chat UI. Compose these instead of hand-rolling
|
||||
bubbles, scroll containers, dividers, or attachment cards.
|
||||
|
||||
Install: `npx shadcn@latest add message-scroller message bubble attachment marker trace code-block`
|
||||
|
||||
The same component names and props ship for both `base` and `radix`; only
|
||||
composition differs (`render` vs `asChild`). See [base-vs-radix.md](./base-vs-radix.md).
|
||||
|
||||
## Contents
|
||||
|
||||
- Scrollable threads use MessageScroller
|
||||
- Message rows use Message
|
||||
- Message surfaces use Bubble
|
||||
- Attachments use Attachment
|
||||
- System notes and dividers use Marker
|
||||
- Assistant activity uses Trace
|
||||
- Code and tool output use CodeBlock
|
||||
- Streaming, anchoring, and jump-to-latest are built in
|
||||
- Escape hatch: the scroller hooks
|
||||
|
||||
---
|
||||
|
||||
## Scrollable threads use MessageScroller
|
||||
|
||||
A conversation that scrolls, follows new messages, restores position, or jumps
|
||||
to a message uses `MessageScroller`. Don't build a raw overflow container with
|
||||
manual scroll wiring, and don't reach for `ScrollArea`.
|
||||
|
||||
The parts nest in a fixed order. Every direct child of the content is wrapped in
|
||||
a `MessageScrollerItem` so the scroller can measure, anchor, preserve position,
|
||||
track visibility, and jump to it. `MessageScrollerButton` sits inside
|
||||
`MessageScroller`, after the viewport.
|
||||
|
||||
**Incorrect:**
|
||||
|
||||
```tsx
|
||||
// Hand-rolled scroll container with manual stick-to-bottom logic.
|
||||
<div ref={scrollRef} onScroll={handleScroll} className="flex-1 overflow-y-auto">
|
||||
<div className="flex flex-col gap-6 p-4">
|
||||
{messages.map((m) => (
|
||||
<ChatMessage key={m.id} message={m} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
|
||||
```tsx
|
||||
<MessageScrollerProvider autoScroll>
|
||||
<MessageScroller>
|
||||
<MessageScrollerViewport>
|
||||
<MessageScrollerContent>
|
||||
{messages.map((message) => (
|
||||
<MessageScrollerItem
|
||||
key={message.id}
|
||||
messageId={message.id}
|
||||
scrollAnchor={message.role === "user"}
|
||||
>
|
||||
<Message align={message.role === "user" ? "end" : "start"}>
|
||||
{/* ...message content... */}
|
||||
</Message>
|
||||
</MessageScrollerItem>
|
||||
))}
|
||||
</MessageScrollerContent>
|
||||
</MessageScrollerViewport>
|
||||
<MessageScrollerButton />
|
||||
</MessageScroller>
|
||||
</MessageScrollerProvider>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Message rows use Message
|
||||
|
||||
`Message` lays out a single row: avatar, header, content, footer, with
|
||||
alignment. Group consecutive rows from one sender with `MessageGroup`. Don't
|
||||
rebuild the row from flex divs.
|
||||
|
||||
`align="end"` is the current user's side; `align="start"` is everyone else.
|
||||
|
||||
```tsx
|
||||
<Message align="start">
|
||||
<MessageAvatar>
|
||||
<Avatar>
|
||||
<AvatarImage src={sender.avatar} alt={sender.name} />
|
||||
<AvatarFallback>{initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
</MessageAvatar>
|
||||
<MessageContent>
|
||||
<MessageHeader>{sender.name}</MessageHeader>
|
||||
<Bubble>
|
||||
<BubbleContent>{text}</BubbleContent>
|
||||
</Bubble>
|
||||
<MessageFooter>{time}</MessageFooter>
|
||||
</MessageContent>
|
||||
</Message>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Message surfaces use Bubble
|
||||
|
||||
The colored message surface is `Bubble` + `BubbleContent`, never a styled `div`
|
||||
with `bg-muted` / `bg-primary` and hand-managed corners.
|
||||
|
||||
- `variant`: `default`, `secondary`, `muted`, `tinted`, `outline`, `ghost`, `destructive`.
|
||||
- `align`: `start` or `end` (matches the `Message` side).
|
||||
|
||||
`BubbleReactions` renders the reaction cluster. `side` (`top` | `bottom`) and
|
||||
`align` (`start` | `end`) position it against the bubble. Don't lay reactions out
|
||||
with absolutely-positioned `Badge`s.
|
||||
|
||||
**Incorrect:**
|
||||
|
||||
```tsx
|
||||
<div className="w-fit rounded-2xl bg-primary px-3 py-2 text-primary-foreground">
|
||||
{text}
|
||||
</div>
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
|
||||
```tsx
|
||||
<Bubble variant="default" align="end">
|
||||
<BubbleContent>{text}</BubbleContent>
|
||||
<BubbleReactions side="bottom" align="end">
|
||||
<Badge variant="secondary">👍 2</Badge>
|
||||
</BubbleReactions>
|
||||
</Bubble>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Attachments use Attachment
|
||||
|
||||
File and image attachments use `Attachment`, not `Item` or a custom card. It
|
||||
carries upload state, so wire `state` to the real status rather than rendering a
|
||||
separate spinner.
|
||||
|
||||
- `state`: `idle`, `uploading`, `processing`, `error`, `done`. `uploading` and
|
||||
`processing` apply the `shimmer` animation to the title automatically.
|
||||
- `size`: `default`, `sm`, `xs`. `orientation`: `horizontal`, `vertical`.
|
||||
- Use `AttachmentGroup` to lay out several attachments in a scrolling row.
|
||||
|
||||
```tsx
|
||||
<Attachment state="done">
|
||||
<AttachmentMedia variant="icon">
|
||||
<FileTextIcon />
|
||||
</AttachmentMedia>
|
||||
<AttachmentContent>
|
||||
<AttachmentTitle>homepage-feedback.pdf</AttachmentTitle>
|
||||
<AttachmentDescription>PDF · 2.4 MB</AttachmentDescription>
|
||||
</AttachmentContent>
|
||||
<AttachmentActions>
|
||||
<AttachmentAction>
|
||||
<DownloadIcon />
|
||||
</AttachmentAction>
|
||||
</AttachmentActions>
|
||||
</Attachment>
|
||||
```
|
||||
|
||||
For an image, use `<AttachmentMedia variant="image">` with an `img` child.
|
||||
|
||||
---
|
||||
|
||||
## System notes and dividers use Marker
|
||||
|
||||
Status lines ("Sarah joined the conversation"), date dividers ("Today"), and
|
||||
labeled separators are `Marker`, not a `Separator` plus a centered span.
|
||||
|
||||
- `variant`: `default` (plain row), `separator` (centered label with rules on
|
||||
each side), `border` (bottom-bordered row).
|
||||
- `MarkerIcon` holds a leading icon; `MarkerContent` holds the label.
|
||||
|
||||
**Incorrect:**
|
||||
|
||||
```tsx
|
||||
<div className="flex items-center gap-3 py-2">
|
||||
<Separator className="flex-1" />
|
||||
<span className="text-xs text-muted-foreground">Today</span>
|
||||
<Separator className="flex-1" />
|
||||
</div>
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
|
||||
```tsx
|
||||
<Marker variant="separator">
|
||||
<MarkerContent>Today</MarkerContent>
|
||||
</Marker>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Assistant activity uses Trace
|
||||
|
||||
What the assistant did or is doing (reasoning, tool calls, chain of thought,
|
||||
steps, sources, file reads) is a `TraceLine` in a `Trace`, not a hand-rolled
|
||||
`Collapsible` or a `Marker`. One line reports one thing; it may have detail.
|
||||
|
||||
- `TraceLine` is always the line: a `TraceIcon` and a `TraceTitle`, then an
|
||||
optional `TraceDescription` for a count, a duration or a diff stat. `status`
|
||||
goes on the `TraceLine`. Never hand-roll a span for secondary text.
|
||||
- Behavior wraps the line. To make a line do something, wrap it in that thing's
|
||||
trigger:
|
||||
- nothing to open: the `TraceLine` alone;
|
||||
- expands: `TraceCollapsible` > `TraceCollapsibleTrigger` > `TraceLine`, then
|
||||
`TraceCollapsibleContent`. The trigger adds its own chevron;
|
||||
- link: `<a href>` > `TraceLine`;
|
||||
- overlay: `<SheetTrigger>` > `TraceLine` (Dialog, Drawer and Popover the
|
||||
same). Aria: `<DialogTrigger><Button>` > `TraceLine`, with React Aria's
|
||||
`Button`.
|
||||
- Never make the line the trigger: no `asChild` or `render={<TraceLine />}` on
|
||||
a trigger. The trigger must stay a real button.
|
||||
- A `TraceCollapsibleTrigger` always has a `TraceCollapsibleContent`. A tool row
|
||||
is a `TraceLine` until its output arrives, then a `TraceCollapsible`.
|
||||
- Rows rendered from message parts go in one `Trace` per run of parts. A text
|
||||
part or a pending approval ends the run. A heading for a run is plain text
|
||||
above it, or a collapsible summary row.
|
||||
- A pending approval (`approval-requested`) is a prompt, not a row. Render it
|
||||
outside `Trace` as an `Item` with Approve and Deny in `ItemActions`. See
|
||||
/docs/ai-elements/base/confirmation. Once answered, the tool part is a line
|
||||
again.
|
||||
- A summary (sources, a task, a whole turn) is a `TraceCollapsible` whose
|
||||
`TraceCollapsibleContent` holds a `Trace`. Set the parent line's status and
|
||||
title from its children yourself.
|
||||
- `status` is `"running"`, `"error"` or `"pending"` (a plan step that has not
|
||||
started), or omitted. The words agree: Running, Ran, Failed to run. Never dim
|
||||
a line with `opacity-*`. Map SDK states yourself; never pass one through.
|
||||
- Add a trailing `TraceIcon` only for a link (arrow-up-right for a new tab, with
|
||||
sr-only " (opens in a new tab)" in the `TraceTitle`) or an overlay
|
||||
(chevron-right).
|
||||
- Open state goes on `TraceCollapsible`: `defaultOpen`, or `open` with
|
||||
`onOpenChange` (aria: `defaultExpanded`, or `isExpanded` with
|
||||
`onExpandedChange`).
|
||||
- Use `Marker` for notes and dividers between turns, not for activity.
|
||||
- A checkpoint marks a turn the reader can return to. Render a `Marker`
|
||||
separator after that message, with a Button that calls `setMessages` with the
|
||||
messages up to and including it. See /docs/ai-elements/base/checkpoint.
|
||||
- A folder of files is nested `Collapsible`s, one folder per row, and a file is
|
||||
a `Button`. See /docs/ai-elements/base/file-tree. A single file the assistant
|
||||
read is still a `TraceLine`.
|
||||
- A person's turn is a `Message` with `align="end"` and a `Bubble`. The
|
||||
assistant's answer is a `Message`. See /docs/ai-elements/base/message.
|
||||
- A thread is those turns in `MessageScroller`. The person's turn is the
|
||||
`scrollAnchor`, and `autoScroll` follows the reply. See
|
||||
/docs/ai-elements/base/chat.
|
||||
- A prompt is an `InputGroup`: a textarea, an attachment chip, and a submit
|
||||
button. See /docs/ai-elements/base/prompt-input.
|
||||
- A suggestion is a `Button` that sends its label with `sendMessage({ text })`.
|
||||
See /docs/ai-elements/base/suggestion.
|
||||
- A model picker is a `DropdownMenu` with a `DropdownMenuRadioGroup`, next to a
|
||||
second one for effort. Send the selected ids on `sendMessage` as
|
||||
`body.model` and `body.effort`. See /docs/ai-elements/base/model-selector.
|
||||
- An inline citation is a `HoverCard` on a short `Button` in the sentence, from
|
||||
a `source-url` part. See /docs/ai-elements/base/inline-citation. A list of
|
||||
sources after the answer is still Sources.
|
||||
- Context usage is a `HoverCard` with `Progress`, read from message metadata
|
||||
`usage`. The window size is the model's limit. See
|
||||
/docs/ai-elements/base/context.
|
||||
- Asked for an AI Elements component such as Tool, Reasoning, Chain of Thought,
|
||||
Task, Plan, Sources or Subagent? It is a component page built with Trace (for example
|
||||
/docs/ai-elements/base/tool). Install `trace` and copy the composition.
|
||||
Message is one turn (/docs/ai-elements/base/message). Chat is the thread in
|
||||
Message Scroller (/docs/ai-elements/base/chat). Checkpoint is a Marker
|
||||
(/docs/ai-elements/base/checkpoint). File Tree is nested
|
||||
Collapsibles (/docs/ai-elements/base/file-tree). Confirmation is an Item
|
||||
(/docs/ai-elements/base/confirmation). Prompt Input is an Input Group
|
||||
(/docs/ai-elements/base/prompt-input). Suggestion is a Button
|
||||
(/docs/ai-elements/base/suggestion). Model Selector is a Dropdown Menu
|
||||
(/docs/ai-elements/base/model-selector). Inline Citation is a Hover Card
|
||||
(/docs/ai-elements/base/inline-citation). Context is a Hover Card and Progress
|
||||
(/docs/ai-elements/base/context).
|
||||
|
||||
**Incorrect:**
|
||||
|
||||
```tsx
|
||||
const [open, setOpen] = useState(true)
|
||||
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
<CollapsibleTrigger className="text-sm text-muted-foreground">
|
||||
{isStreaming ? "Thinking..." : "Thought for a few seconds"}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>{part.text}</CollapsibleContent>
|
||||
</Collapsible>
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
|
||||
```tsx
|
||||
<TraceCollapsible>
|
||||
<TraceCollapsibleTrigger>
|
||||
<TraceLine status={running ? "running" : undefined}>
|
||||
<TraceIcon>
|
||||
<BrainIcon />
|
||||
</TraceIcon>
|
||||
<TraceTitle>{running ? "Thinking" : "Thought"}</TraceTitle>
|
||||
</TraceLine>
|
||||
</TraceCollapsibleTrigger>
|
||||
<TraceCollapsibleContent>{part.text}</TraceCollapsibleContent>
|
||||
</TraceCollapsible>
|
||||
```
|
||||
|
||||
**Incorrect:** a tool row whose output may not exist yet.
|
||||
|
||||
```tsx
|
||||
<TraceCollapsible>
|
||||
<TraceCollapsibleTrigger>
|
||||
<TraceLine status={status}>…</TraceLine>
|
||||
</TraceCollapsibleTrigger>
|
||||
{output && <TraceCollapsibleContent>{output}</TraceCollapsibleContent>}
|
||||
</TraceCollapsible>
|
||||
```
|
||||
|
||||
**Correct:** a `TraceLine` until there is something to open.
|
||||
|
||||
```tsx
|
||||
function ToolRow({
|
||||
icon,
|
||||
title,
|
||||
status,
|
||||
output,
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
title: string
|
||||
status?: "running" | "error"
|
||||
output?: string
|
||||
}) {
|
||||
const line = (
|
||||
<TraceLine status={status}>
|
||||
<TraceIcon>{icon}</TraceIcon>
|
||||
<TraceTitle>{title}</TraceTitle>
|
||||
</TraceLine>
|
||||
)
|
||||
|
||||
if (output === undefined) {
|
||||
return line
|
||||
}
|
||||
|
||||
return (
|
||||
<TraceCollapsible>
|
||||
<TraceCollapsibleTrigger>{line}</TraceCollapsibleTrigger>
|
||||
<TraceCollapsibleContent>
|
||||
<CodeBlock size="sm">
|
||||
<CodeBlockContent wrap className="max-h-64">
|
||||
{output}
|
||||
</CodeBlockContent>
|
||||
</CodeBlock>
|
||||
</TraceCollapsibleContent>
|
||||
</TraceCollapsible>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Incorrect:** the trigger renders as the line, so it is a span.
|
||||
|
||||
```tsx
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<TraceLine>…</TraceLine>
|
||||
</SheetTrigger>
|
||||
</Sheet>
|
||||
```
|
||||
|
||||
**Correct:** sources, each link wrapping its own line.
|
||||
|
||||
```tsx
|
||||
<TraceCollapsible>
|
||||
<TraceCollapsibleTrigger>
|
||||
<TraceLine>
|
||||
<TraceIcon>
|
||||
<GlobeIcon />
|
||||
</TraceIcon>
|
||||
<TraceTitle>Used {sources.length} sources</TraceTitle>
|
||||
</TraceLine>
|
||||
</TraceCollapsibleTrigger>
|
||||
<TraceCollapsibleContent>
|
||||
<Trace>
|
||||
{sources.map((source) => (
|
||||
<a key={source.url} href={source.url} target="_blank" rel="noreferrer">
|
||||
<TraceLine>
|
||||
<TraceIcon>
|
||||
<GlobeIcon />
|
||||
</TraceIcon>
|
||||
<TraceTitle>
|
||||
{source.title}
|
||||
<span className="sr-only"> (opens in a new tab)</span>
|
||||
</TraceTitle>
|
||||
<TraceIcon>
|
||||
<ArrowUpRightIcon />
|
||||
</TraceIcon>
|
||||
</TraceLine>
|
||||
</a>
|
||||
))}
|
||||
</Trace>
|
||||
</TraceCollapsibleContent>
|
||||
</TraceCollapsible>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Code and tool output use CodeBlock
|
||||
|
||||
Code, terminal output, tool input and output, logs, stack traces and diffs are
|
||||
a `CodeBlock`, never a hand-rolled `<pre>`.
|
||||
|
||||
- `CodeBlockContent` takes a string. Add `wrap` for tool output and errors,
|
||||
`lineNumbers` for source, and a `max-h-*` class to cap the height.
|
||||
- Errors use `variant="destructive"` on `CodeBlock`. Don't recolor the block.
|
||||
- For diffs and highlighted lines, map each line to a `CodeBlockLine` with
|
||||
`variant="added"`, `"removed"` or `"highlighted"`.
|
||||
- `CodeBlockCopyButton` takes the text to copy as `value`.
|
||||
- Output inside a Trace row is `size="sm"` with `wrap` and `max-h-64`, so long
|
||||
output wraps and scrolls inside the row. Docs, Markdown fences and chat
|
||||
answers use the default size.
|
||||
- Header buttons go in `CodeBlockActions`. With no header, put
|
||||
`CodeBlockActions` directly in `CodeBlock`; it floats and shows on hover.
|
||||
- `code-block` installs `use-highlighted-code`. For source code, map the lines
|
||||
of `useHighlightedCode(code, language)` into `CodeBlockLine`. In a Server
|
||||
Component, use `await highlightCode(code, language)` instead. Don't
|
||||
highlight tool output; pass the string.
|
||||
- `startLine` numbers a snippet from the middle of a file.
|
||||
|
||||
**Incorrect:**
|
||||
|
||||
```tsx
|
||||
<pre className="rounded-md bg-muted p-3 font-mono text-xs whitespace-pre-wrap">
|
||||
{output}
|
||||
</pre>
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
|
||||
```tsx
|
||||
<CodeBlock>
|
||||
<CodeBlockHeader>
|
||||
<CodeBlockTitle>Output</CodeBlockTitle>
|
||||
<CodeBlockActions>
|
||||
<CodeBlockCopyButton value={output} />
|
||||
</CodeBlockActions>
|
||||
</CodeBlockHeader>
|
||||
<CodeBlockContent wrap className="max-h-72">
|
||||
{output}
|
||||
</CodeBlockContent>
|
||||
</CodeBlock>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Streaming, anchoring, and jump-to-latest are built in
|
||||
|
||||
`MessageScroller` handles the behavior that chat UIs usually reinvent. Don't
|
||||
write a `useStickToBottom` hook, a `ResizeObserver`, or manual `scrollTop` math.
|
||||
|
||||
- **Follow the live edge while streaming.** `MessageScrollerProvider` with
|
||||
`autoScroll` keeps the view pinned to new content and yields the moment the
|
||||
user scrolls up. Streaming token updates that grow the last message are
|
||||
followed automatically.
|
||||
- **Anchor a turn.** `scrollAnchor` on a `MessageScrollerItem` marks the row to
|
||||
hold in view (typically the user's message that started the turn).
|
||||
- **Jump to latest.** `MessageScrollerButton` appears when the user scrolls away
|
||||
and scrolls back on click. `direction="end"` (default) or `direction="start"`.
|
||||
It is a self-managing control, so don't gate it behind your own scroll-position
|
||||
state.
|
||||
- **Open a saved transcript without a flash.** `defaultScrollPosition` applies
|
||||
after mount. When it is `"end"` or `"last-anchor"`, the viewport has
|
||||
`data-pending-scroll` until that position is applied. The styled viewport
|
||||
hides. For `"end"` on first paint when messages are in the server HTML, copy
|
||||
this script into the page, not into the primitive. Skip it for `"last-anchor"`
|
||||
and for client-fetched messages. Keep `suppressHydrationWarning` on the
|
||||
viewport. Pass a `nonce` if you use a Content Security Policy.
|
||||
|
||||
```tsx
|
||||
const scrollToEndScript = `(function () {
|
||||
var viewport = document.getElementById("messages")
|
||||
if (!viewport) {
|
||||
return
|
||||
}
|
||||
viewport.scrollTop = viewport.scrollHeight
|
||||
viewport.removeAttribute("data-pending-scroll")
|
||||
})()`
|
||||
|
||||
<MessageScroller>
|
||||
<MessageScrollerViewport id="messages" suppressHydrationWarning>
|
||||
<MessageScrollerContent>{/* transcript */}</MessageScrollerContent>
|
||||
</MessageScrollerViewport>
|
||||
<script dangerouslySetInnerHTML={{ __html: scrollToEndScript }} />
|
||||
<MessageScrollerButton />
|
||||
</MessageScroller>
|
||||
```
|
||||
|
||||
For a "thinking…" indicator while the model generates, apply the `shimmer`
|
||||
utility to text. Don't author a custom keyframe animation. See
|
||||
[styling.md](./styling.md).
|
||||
|
||||
---
|
||||
|
||||
## Escape hatch: the scroller hooks
|
||||
|
||||
For behavior the parts don't expose, read state from the hooks rather than
|
||||
re-implementing the scroller: `useMessageScroller`,
|
||||
`useMessageScrollerVisibility`, and `useMessageScrollerScrollable`. They come
|
||||
from the auto-installed `@shadcn/react` dependency, so there's nothing extra to
|
||||
install. Reach for them only when composition can't express what you need.
|
||||
Reference in New Issue
Block a user