mirror of
https://github.com/shadcn-ui/ui.git
synced 2026-07-07 22:18:39 +00:00
* feat(@shadcn/react): add message-scroller package Add the @shadcn/react headless primitives package with MessageScroller scroll anchoring, streaming follow, history prepend, and jump-to-message behavior. Includes geometry helpers, use-render utility, and unit, browser, and perf tests. * feat(registry): add chat components Add MessageScroller, Message, Bubble, Attachment, and Marker registry sources for base and radix, style variants, preview-03 chat blocks, and registry index wiring. * feat(v4): integrate chat components into docs site Wire chat components into the v4 app with docs routes, example preview pages, message part renderers, markdown support, registry build updates, and supporting lib utilities. * feat(examples): add chat component demos Add base and radix example demos for MessageScroller, Message, Bubble, Attachment, Marker, scroll-fade, and shimmer. * docs: add chat component documentation Add component and utility docs for the chat component set, update docs navigation, and add the June 2026 chat components changelog entry. * chore: regenerate registry JSON output Rebuild public registry artifacts for all style variants with the new chat components. * chore(release): add @shadcn/react publish and CI pipeline Add Changesets prerelease workflow, browser test job, RELEASING docs, and monorepo wiring for publishing @shadcn/react independently from the shadcn CLI. * docs: fix display of component preview on mobile * fix * fix * docs: add message scroller docs * style: format * fix
253 lines
8.8 KiB
TypeScript
253 lines
8.8 KiB
TypeScript
"use client"
|
|
|
|
import * as React from "react"
|
|
import { useChat } from "@ai-sdk/react"
|
|
|
|
import { createChat, getMessageText } from "@/lib/ai"
|
|
import {
|
|
Example,
|
|
ExampleWrapper,
|
|
} from "@/registry/bases/base/components/example"
|
|
import { Bubble, BubbleContent } from "@/registry/bases/base/ui/bubble"
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardFooter,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from "@/registry/bases/base/ui/card"
|
|
import {
|
|
InputGroup,
|
|
InputGroupAddon,
|
|
InputGroupButton,
|
|
InputGroupTextarea,
|
|
} from "@/registry/bases/base/ui/input-group"
|
|
import {
|
|
Marker,
|
|
MarkerContent,
|
|
MarkerIcon,
|
|
} from "@/registry/bases/base/ui/marker"
|
|
import { Message, MessageContent } from "@/registry/bases/base/ui/message"
|
|
import {
|
|
MessageScroller,
|
|
MessageScrollerButton,
|
|
MessageScrollerContent,
|
|
MessageScrollerItem,
|
|
MessageScrollerProvider,
|
|
MessageScrollerViewport,
|
|
} from "@/registry/bases/base/ui/message-scroller"
|
|
import { Spinner } from "@/registry/bases/base/ui/spinner"
|
|
import { IconPlaceholder } from "@/app/(create)/components/icon-placeholder"
|
|
|
|
export default function MessageScrollerExample() {
|
|
return (
|
|
<ExampleWrapper>
|
|
<MessageScrollerDemo />
|
|
</ExampleWrapper>
|
|
)
|
|
}
|
|
|
|
const chat = createChat()
|
|
.user("Hello there!")
|
|
.sleep(2000)
|
|
.assistant("Hey, how's it going?")
|
|
.user(
|
|
`I'm prototyping an AI chat surface for our product docs.
|
|
|
|
Can you sketch a sensible component breakdown and call out anything I'd regret baking into v1?`
|
|
)
|
|
.sleep(2000)
|
|
.assistant(
|
|
`## Recommended layout
|
|
|
|
Treat the chat as three layers so scrolling and the composer never fight each other:
|
|
|
|
| Layer | Responsibility |
|
|
| --- | --- |
|
|
| **Shell** | Card or page frame, title, status indicator |
|
|
| **Transcript** | \`overflow-y-auto\` message list |
|
|
| **Composer** | Input group, send, stop |
|
|
|
|
### Message rendering
|
|
|
|
- **User messages**: right-aligned, muted background, \`max-w-[80%]\`.
|
|
- **Assistant messages**: full width within the column; use \`whitespace-pre-wrap\` for text.
|
|
|
|
### Suggested component split
|
|
|
|
\`\`\`tsx
|
|
<ChatCard>
|
|
<ChatHeader status={status} />
|
|
<ChatTranscript messages={messages} />
|
|
<ChatComposer onSubmit={sendMessage} onStop={stop} />
|
|
</ChatCard>
|
|
\`\`\`
|
|
|
|
### v1 pitfalls to avoid
|
|
|
|
1. **Inlining transport logic in the UI** — keep scripted/demo transport beside your example, not inside shared components.
|
|
2. **Fixed transcript height without \`overflow-hidden\`** — parent must clip, child must scroll, or the composer jumps.
|
|
3. **Not giving the scroller a ref or auto-scroll flag** — long replies should stay pinned to the bottom unless the user scrolls up.
|
|
|
|
### Practical next step
|
|
|
|
Start with plain \`whitespace-pre-wrap\` text to validate spacing and scroll behavior. Once the transcript feels right, you can swap assistant text rendering to a richer renderer if you need it.
|
|
|
|
Want me to walk through auto-scroll vs. manual scroll-to-bottom next?`
|
|
)
|
|
.user(
|
|
"What about message spacing when one reply is short and the next is really long?"
|
|
)
|
|
.sleep(2000)
|
|
.assistant(
|
|
`Good question. The scroll container should own vertical rhythm, not individual bubbles.
|
|
|
|
Use a consistent \`gap\` on the message list (something like \`gap-4\` or \`gap-6\`) so short and long messages sit on the same grid. For assistant replies that span many paragraphs, keep everything in one bubble rather than splitting on every line break — otherwise the transcript feels choppy when you're skimming.
|
|
|
|
When a long reply streams in, pin the viewport to the bottom with \`MessageScroller autoScroll\` until the user scrolls up. If they've scrolled away, show a scroll-to-bottom affordance instead of yanking them back mid-read.
|
|
|
|
That's usually enough for v1. Fancy diff animations or per-paragraph fade-ins can wait until you've validated that the base scroll + composer layout holds up on real devices.`
|
|
)
|
|
.user("Thanks, that helps.")
|
|
.sleep(1000)
|
|
.assistant(
|
|
"Happy to help — send another message when you're ready to keep stepping through the demo."
|
|
)
|
|
|
|
const initialMessages = chat.get({ count: 0 })
|
|
const transport = chat.transport({
|
|
chunkDelayMs: 10,
|
|
})
|
|
|
|
function MessageScrollerDemo() {
|
|
const { messages, sendMessage, status, stop } = useChat({
|
|
messages: initialMessages,
|
|
transport,
|
|
})
|
|
const nextMessage = chat.next({ after: messages })
|
|
const isBusy = status === "submitted" || status === "streaming"
|
|
|
|
function handleFormSubmit(e: React.FormEvent<HTMLFormElement>) {
|
|
e.preventDefault()
|
|
if (!nextMessage || isBusy) {
|
|
return
|
|
}
|
|
|
|
void sendMessage(nextMessage)
|
|
}
|
|
|
|
return (
|
|
<Example title="Chat" containerClassName="self-start">
|
|
<Card className="h-140">
|
|
<CardHeader>
|
|
<CardTitle>How can I help you today?</CardTitle>
|
|
<CardDescription>Status: {status}</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="min-h-0 flex-1 overflow-hidden p-0">
|
|
<MessageScrollerProvider>
|
|
<MessageScroller>
|
|
<MessageScrollerViewport>
|
|
<MessageScrollerContent className="p-(--card-spacing)">
|
|
{messages.map((message) => (
|
|
<MessageScrollerItem
|
|
key={message.id}
|
|
messageId={message.id}
|
|
scrollAnchor={message.role === "user"}
|
|
>
|
|
<Message
|
|
align={message.role === "user" ? "end" : "start"}
|
|
>
|
|
<MessageContent>
|
|
{message.parts.map((part, index) => {
|
|
if (part.type !== "text") {
|
|
return null
|
|
}
|
|
|
|
return (
|
|
<Bubble
|
|
key={`${message.id}-${index}`}
|
|
variant={
|
|
message.role === "user" ? "default" : "muted"
|
|
}
|
|
>
|
|
<BubbleContent className="whitespace-pre-wrap">
|
|
{part.text}
|
|
</BubbleContent>
|
|
</Bubble>
|
|
)
|
|
})}
|
|
</MessageContent>
|
|
</Message>
|
|
</MessageScrollerItem>
|
|
))}
|
|
{status === "submitted" ? (
|
|
<MessageScrollerItem scrollAnchor={false}>
|
|
<Marker role="status">
|
|
<MarkerIcon>
|
|
<Spinner />
|
|
</MarkerIcon>
|
|
<MarkerContent>Thinking...</MarkerContent>
|
|
</Marker>
|
|
</MessageScrollerItem>
|
|
) : null}
|
|
</MessageScrollerContent>
|
|
</MessageScrollerViewport>
|
|
<MessageScrollerButton />
|
|
</MessageScroller>
|
|
</MessageScrollerProvider>
|
|
</CardContent>
|
|
<CardFooter>
|
|
<form onSubmit={handleFormSubmit} className="w-full" id="chat-form">
|
|
<InputGroup>
|
|
<InputGroupTextarea
|
|
placeholder="Ask me anything..."
|
|
className="h-10 min-h-10 overflow-y-auto"
|
|
value={
|
|
isBusy ? "" : nextMessage ? getMessageText(nextMessage) : ""
|
|
}
|
|
readOnly
|
|
/>
|
|
<InputGroupAddon align="block-end" className="p-2">
|
|
<InputGroupButton
|
|
variant="default"
|
|
size="icon-sm"
|
|
type="submit"
|
|
disabled={!nextMessage || isBusy}
|
|
className="ml-auto data-[hidden=true]:hidden"
|
|
data-hidden={isBusy}
|
|
>
|
|
<IconPlaceholder
|
|
lucide="ArrowUpIcon"
|
|
tabler="IconArrowUp"
|
|
hugeicons="ArrowUp02Icon"
|
|
phosphor="ArrowUpIcon"
|
|
remixicon="RiArrowUpLine"
|
|
/>
|
|
<span className="sr-only">Send</span>
|
|
</InputGroupButton>
|
|
<InputGroupButton
|
|
size="icon-sm"
|
|
type="button"
|
|
data-hidden={!isBusy}
|
|
className="ml-auto data-[hidden=true]:hidden"
|
|
onClick={() => stop()}
|
|
>
|
|
<IconPlaceholder
|
|
lucide="StopCircleIcon"
|
|
tabler="IconPlayerStop"
|
|
hugeicons="StopCircleIcon"
|
|
phosphor="StopCircleIcon"
|
|
remixicon="RiStopCircleLine"
|
|
/>
|
|
<span className="sr-only">Stop</span>
|
|
</InputGroupButton>
|
|
</InputGroupAddon>
|
|
</InputGroup>
|
|
</form>
|
|
</CardFooter>
|
|
</Card>
|
|
</Example>
|
|
)
|
|
}
|