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/aria/components/example"
|
|
import { Bubble, BubbleContent } from "@/registry/bases/aria/ui/bubble"
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardFooter,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from "@/registry/bases/aria/ui/card"
|
|
import {
|
|
InputGroup,
|
|
InputGroupAddon,
|
|
InputGroupButton,
|
|
InputGroupTextarea,
|
|
} from "@/registry/bases/aria/ui/input-group"
|
|
import {
|
|
Marker,
|
|
MarkerContent,
|
|
MarkerIcon,
|
|
} from "@/registry/bases/aria/ui/marker"
|
|
import { Message, MessageContent } from "@/registry/bases/aria/ui/message"
|
|
import {
|
|
MessageScroller,
|
|
MessageScrollerButton,
|
|
MessageScrollerContent,
|
|
MessageScrollerItem,
|
|
MessageScrollerProvider,
|
|
MessageScrollerViewport,
|
|
} from "@/registry/bases/aria/ui/message-scroller"
|
|
import { Spinner } from "@/registry/bases/aria/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"
|
|
isDisabled={!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>
|
|
)
|
|
}
|