snapshot of https://github.com/shadcn-ui/ui.git (main, 2026-09-15; blobs > 131072B omitted)
This commit is contained in:
254
apps/v4/examples/base/tanstack-ai-helper-demo.tsx
Normal file
254
apps/v4/examples/base/tanstack-ai-helper-demo.tsx
Normal file
@@ -0,0 +1,254 @@
|
||||
"use client"
|
||||
|
||||
import { createChat } from "@shadcn/helpers/tanstack-ai"
|
||||
import type { UIMessage } from "@tanstack/ai-client"
|
||||
import { useChat } from "@tanstack/ai-react"
|
||||
import {
|
||||
ArrowUpIcon,
|
||||
GlobeIcon,
|
||||
ImageIcon,
|
||||
MessageCircleDashedIcon,
|
||||
PaperclipIcon,
|
||||
PlusIcon,
|
||||
RotateCwIcon,
|
||||
TelescopeIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
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,
|
||||
InputGroupTextarea,
|
||||
} 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(({ writer }) => {
|
||||
writer.reasoning(
|
||||
"They are describing a streaming transcript that keeps taking control of the viewport. I should explain when auto-scroll follows and when it stops."
|
||||
)
|
||||
writer.sleep(1000)
|
||||
writer.text(
|
||||
"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 connection = chat.transport({ delayMs: 20 })
|
||||
|
||||
function getMessageText(message: UIMessage) {
|
||||
return message.parts
|
||||
.map((part) => (part.type === "text" ? part.content : ""))
|
||||
.join("")
|
||||
}
|
||||
|
||||
export function TanStackAiHelperDemo() {
|
||||
const { messages, append, status, setMessages } = useChat({
|
||||
initialMessages,
|
||||
connection,
|
||||
})
|
||||
const nextMessage = chat.next(messages)
|
||||
const nextMessageText = nextMessage ? getMessageText(nextMessage) : null
|
||||
const isBusy = status === "submitted" || status === "streaming"
|
||||
|
||||
return (
|
||||
<MessageScrollerProvider>
|
||||
<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>New Chat</CardTitle>
|
||||
<CardDescription>How can I help you today?</CardDescription>
|
||||
<CardAction>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
aria-label="Reset conversation"
|
||||
onClick={() => setMessages(initialMessages)}
|
||||
disabled={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>Morning, shadcn!</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
What are we working on today? Press send to start a new
|
||||
conversation
|
||||
</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={(event) => {
|
||||
event.preventDefault()
|
||||
if (!nextMessage || isBusy) {
|
||||
return
|
||||
}
|
||||
void append(nextMessage)
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
<InputGroup>
|
||||
<InputGroupTextarea
|
||||
aria-label="Next predefined message"
|
||||
className="h-14 min-h-14 overflow-hidden px-3 py-2.5 opacity-60 data-[status=ready]:opacity-100"
|
||||
data-status={status}
|
||||
placeholder="No messages queued. Reset the conversation."
|
||||
value={nextMessageText ?? ""}
|
||||
readOnly
|
||||
/>
|
||||
<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">
|
||||
Demo is read only. Press send to send messages.
|
||||
</div>
|
||||
</div>
|
||||
</MessageScrollerProvider>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user