mirror of
https://github.com/shadcn-ui/ui.git
synced 2026-07-08 14:35:09 +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
246 lines
9.2 KiB
TypeScript
246 lines
9.2 KiB
TypeScript
"use client"
|
|
|
|
import { useChat } from "@ai-sdk/react"
|
|
import {
|
|
ArrowUpIcon,
|
|
GlobeIcon,
|
|
ImageIcon,
|
|
MessageCircleDashedIcon,
|
|
PaperclipIcon,
|
|
PlusIcon,
|
|
RotateCwIcon,
|
|
TelescopeIcon,
|
|
} from "lucide-react"
|
|
|
|
import { createChat, getMessageText } from "@/lib/ai"
|
|
import { MessageAnimated } from "@/components/message-animated"
|
|
import { Button } from "@/styles/base-rhea/ui/button"
|
|
import {
|
|
Card,
|
|
CardAction,
|
|
CardContent,
|
|
CardDescription,
|
|
CardFooter,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from "@/styles/base-rhea/ui/card"
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from "@/styles/base-rhea/ui/dropdown-menu"
|
|
import {
|
|
Empty,
|
|
EmptyDescription,
|
|
EmptyHeader,
|
|
EmptyMedia,
|
|
EmptyTitle,
|
|
} from "@/styles/base-rhea/ui/empty"
|
|
import {
|
|
InputGroup,
|
|
InputGroupAddon,
|
|
InputGroupButton,
|
|
} from "@/styles/base-rhea/ui/input-group"
|
|
import {
|
|
MessageScroller,
|
|
MessageScrollerButton,
|
|
MessageScrollerContent,
|
|
MessageScrollerProvider,
|
|
MessageScrollerViewport,
|
|
} from "@/styles/base-rhea/ui/message-scroller"
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipTrigger,
|
|
} from "@/styles/base-rhea/ui/tooltip"
|
|
|
|
const chat = createChat()
|
|
.user(
|
|
"I'm building a chat for our app and the scroll behavior is driving me nuts. Every time the AI streams a reply, the whole thread jumps around."
|
|
)
|
|
.sleep(1000)
|
|
.assistant(
|
|
"That's the classic streaming scroll problem. Wrap your message list in `MessageScroller` and turn on `autoScroll` — the viewport pins to the bottom as tokens arrive, so users always see the latest text land in place.\n\nThe important part: it only auto-scrolls while the reader is already at the bottom. The moment they scroll up to read something earlier, auto-scroll backs off and their position is preserved. You get smooth streaming without fighting the user's intent."
|
|
)
|
|
.user(
|
|
"Okay, but when someone sends a new message the view still feels jarring — like the whole conversation reloads from the top."
|
|
)
|
|
.sleep(1000)
|
|
.assistant(
|
|
"MessageScrollerItem fixes that with turn anchoring. Set `scrollAnchor` on the turn that should settle near the top instead of blindly snapping to the document bottom.\n\nIt also leaves a small peek of the previous exchange visible above the anchor, so context isn't lost. The reply starts in view without that disorienting jump you get from a plain overflow container."
|
|
)
|
|
.user(
|
|
"And if they've scrolled up to re-read an older answer? I don't want to yank them back down."
|
|
)
|
|
.sleep(1000)
|
|
.assistant(
|
|
"You won't. Auto-scroll only runs when the viewport is already pinned to the bottom, so scrolling up is a deliberate opt-out — their place in the thread stays put even as new tokens keep arriving below.\n\nWhen there is content they haven't seen yet, `MessageScrollerButton` appears at the bottom of the viewport. One tap jumps them back to the newest message and re-engages auto-scroll. Same pattern as Slack or iMessage: quiet when you're caught up, helpful when you're not."
|
|
)
|
|
.user("Last one — does this work with assistive tech?")
|
|
.sleep(1000)
|
|
.assistant(
|
|
'`MessageScrollerContent` sets `role="log"` and `aria-relevant="additions"` by default, so screen readers announce new messages as they stream in.\n\nThe scroll button is a real `<button>` with an sr-only label, and it\'s removed from the tab order when you\'re already at the bottom — no ghost focus stops.'
|
|
)
|
|
const initialMessages = chat.get({ count: 0 })
|
|
const transport = chat.transport({ chunkDelayMs: 20 })
|
|
|
|
export function MessageScrollerStreaming() {
|
|
const { messages, sendMessage, setMessages, status } = useChat({
|
|
messages: initialMessages,
|
|
transport,
|
|
})
|
|
const nextMessage = chat.next({ after: messages })
|
|
const isBusy = status === "submitted" || status === "streaming"
|
|
|
|
return (
|
|
<MessageScrollerProvider autoScroll>
|
|
<div className="relative flex flex-col gap-4">
|
|
<Card className="mx-auto h-140 w-full max-w-sm gap-0">
|
|
<CardHeader className="gap-1 border-b">
|
|
<CardTitle>Streaming Messages</CardTitle>
|
|
<CardDescription>
|
|
Auto-scroll follows the live edge of the conversation.
|
|
</CardDescription>
|
|
<CardAction>
|
|
<Tooltip>
|
|
<TooltipTrigger
|
|
render={
|
|
<Button
|
|
variant="outline"
|
|
size="icon"
|
|
aria-label="Reset stream"
|
|
onClick={() => setMessages(initialMessages)}
|
|
disabled={messages.length === 0 || isBusy}
|
|
/>
|
|
}
|
|
>
|
|
<RotateCwIcon />
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<p>Reset</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</CardAction>
|
|
</CardHeader>
|
|
<CardContent className="flex-1 overflow-hidden p-0">
|
|
{messages.length === 0 ? (
|
|
<Empty className="h-full">
|
|
<EmptyHeader>
|
|
<EmptyMedia variant="icon">
|
|
<MessageCircleDashedIcon />
|
|
</EmptyMedia>
|
|
<EmptyTitle>Ready to Stream</EmptyTitle>
|
|
<EmptyDescription>
|
|
Press send to stream a scripted launch summary.
|
|
</EmptyDescription>
|
|
</EmptyHeader>
|
|
</Empty>
|
|
) : (
|
|
<MessageScroller>
|
|
<MessageScrollerViewport>
|
|
<MessageScrollerContent
|
|
aria-busy={isBusy}
|
|
className="p-(--card-spacing)"
|
|
>
|
|
{messages.map((message) => (
|
|
<MessageAnimated
|
|
key={message.id}
|
|
message={message}
|
|
scrollAnchor={message.role === "user"}
|
|
/>
|
|
))}
|
|
</MessageScrollerContent>
|
|
</MessageScrollerViewport>
|
|
<MessageScrollerButton />
|
|
</MessageScroller>
|
|
)}
|
|
</CardContent>
|
|
<CardFooter className="flex-col gap-2">
|
|
<form
|
|
onSubmit={(e) => {
|
|
e.preventDefault()
|
|
if (!nextMessage || isBusy) {
|
|
return
|
|
}
|
|
void sendMessage(nextMessage)
|
|
}}
|
|
className="w-full"
|
|
>
|
|
<InputGroup>
|
|
<div className="h-14 w-full px-3 py-2.5">
|
|
<span
|
|
className="line-clamp-2 opacity-60 data-[status=ready]:opacity-100"
|
|
data-status={status}
|
|
>
|
|
{nextMessage ? (
|
|
getMessageText(nextMessage)
|
|
) : (
|
|
<span className="text-muted-foreground">
|
|
No messages queued. Reset the stream.
|
|
</span>
|
|
)}
|
|
</span>
|
|
</div>
|
|
<InputGroupAddon align="block-end" className="pt-1">
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger
|
|
render={
|
|
<InputGroupButton
|
|
aria-label="Add files"
|
|
type="button"
|
|
size="icon-sm"
|
|
variant="outline"
|
|
/>
|
|
}
|
|
>
|
|
<PlusIcon />
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent
|
|
align="start"
|
|
side="top"
|
|
className="w-44"
|
|
>
|
|
<DropdownMenuItem>
|
|
<PaperclipIcon />
|
|
Add Photos & Files
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem>
|
|
<ImageIcon />
|
|
Create Image
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem>
|
|
<TelescopeIcon />
|
|
Deep Research
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem>
|
|
<GlobeIcon />
|
|
Web Search
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
<InputGroupButton
|
|
type="submit"
|
|
variant="default"
|
|
size="icon-sm"
|
|
disabled={!nextMessage || isBusy}
|
|
className="ml-auto"
|
|
>
|
|
<ArrowUpIcon />
|
|
<span className="sr-only">Send</span>
|
|
</InputGroupButton>
|
|
</InputGroupAddon>
|
|
</InputGroup>
|
|
</form>
|
|
</CardFooter>
|
|
</Card>
|
|
<div className="px-0.5 text-center text-xs text-muted-foreground">
|
|
Streaming is simulated. `autoScroll` is enabled.
|
|
</div>
|
|
</div>
|
|
</MessageScrollerProvider>
|
|
)
|
|
}
|