snapshot of https://github.com/shadcn-ui/ui.git (main, 2026-09-15; blobs > 131072B omitted)
This commit is contained in:
228
apps/v4/examples/aria/message-scroller-streaming.tsx
Normal file
228
apps/v4/examples/aria/message-scroller-streaming.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
"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/aria-rhea/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/styles/aria-rhea/ui/card"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/styles/aria-rhea/ui/dropdown-menu"
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from "@/styles/aria-rhea/ui/empty"
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
} from "@/styles/aria-rhea/ui/input-group"
|
||||
import {
|
||||
MessageScroller,
|
||||
MessageScrollerButton,
|
||||
MessageScrollerContent,
|
||||
MessageScrollerProvider,
|
||||
MessageScrollerViewport,
|
||||
} from "@/styles/aria-rhea/ui/message-scroller"
|
||||
import { Tooltip, TooltipTrigger } from "@/styles/aria-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(0)
|
||||
const transport = chat.transport({ delayMs: 20 })
|
||||
|
||||
export function MessageScrollerStreaming() {
|
||||
const { messages, sendMessage, setMessages, status } = useChat({
|
||||
messages: initialMessages,
|
||||
transport,
|
||||
})
|
||||
const nextMessage = chat.next(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>
|
||||
<TooltipTrigger>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
aria-label="Reset stream"
|
||||
onPress={() => setMessages(initialMessages)}
|
||||
isDisabled={messages.length === 0 || isBusy}
|
||||
>
|
||||
<RotateCwIcon />
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<p>Reset</p>
|
||||
</Tooltip>
|
||||
</TooltipTrigger>
|
||||
</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">
|
||||
<DropdownMenuTrigger>
|
||||
<InputGroupButton
|
||||
aria-label="Add files"
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="outline"
|
||||
>
|
||||
<PlusIcon />
|
||||
</InputGroupButton>
|
||||
<DropdownMenu placement="top start" 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>
|
||||
</DropdownMenu>
|
||||
</DropdownMenuTrigger>
|
||||
<InputGroupButton
|
||||
type="submit"
|
||||
variant="default"
|
||||
size="icon-sm"
|
||||
isDisabled={!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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user