snapshot of https://github.com/shadcn-ui/ui.git (main, 2026-09-15; blobs > 131072B omitted)
This commit is contained in:
@@ -0,0 +1,252 @@
|
||||
"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/radix/components/example"
|
||||
import { Bubble, BubbleContent } from "@/registry/bases/radix/ui/bubble"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/registry/bases/radix/ui/card"
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupTextarea,
|
||||
} from "@/registry/bases/radix/ui/input-group"
|
||||
import {
|
||||
Marker,
|
||||
MarkerContent,
|
||||
MarkerIcon,
|
||||
} from "@/registry/bases/radix/ui/marker"
|
||||
import { Message, MessageContent } from "@/registry/bases/radix/ui/message"
|
||||
import {
|
||||
MessageScroller,
|
||||
MessageScrollerButton,
|
||||
MessageScrollerContent,
|
||||
MessageScrollerItem,
|
||||
MessageScrollerProvider,
|
||||
MessageScrollerViewport,
|
||||
} from "@/registry/bases/radix/ui/message-scroller"
|
||||
import { Spinner } from "@/registry/bases/radix/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(0)
|
||||
const transport = chat.transport({
|
||||
delayMs: 10,
|
||||
})
|
||||
|
||||
function MessageScrollerDemo() {
|
||||
const { messages, sendMessage, status, stop } = useChat({
|
||||
messages: initialMessages,
|
||||
transport,
|
||||
})
|
||||
const nextMessage = chat.next(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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user