snapshot of https://github.com/shadcn-ui/ui.git (main, 2026-09-15; blobs > 131072B omitted)
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { useChat } from "@ai-sdk/react"
|
||||
|
||||
import { createChat, getMessageText } from "@/lib/ai"
|
||||
import { PartFile } from "@/registry/bases/radix/blocks/preview-03/components/part-file"
|
||||
import { PartText } from "@/registry/bases/radix/blocks/preview-03/components/part-text"
|
||||
import { PromptInput } from "@/registry/bases/radix/blocks/preview-03/components/prompt-input"
|
||||
import { Example } from "@/registry/bases/radix/components/example"
|
||||
import { Button } from "@/registry/bases/radix/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/registry/bases/radix/ui/card"
|
||||
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/(app)/create/components/icon-placeholder"
|
||||
|
||||
const chat = createChat()
|
||||
.user("Review these files and tell me what stands out.", {
|
||||
files: [
|
||||
{
|
||||
filename: "workspace.png",
|
||||
mediaType: "image/png",
|
||||
url: "https://images.unsplash.com/photo-1497366754035-f200968a6e72?w=900&auto=format&fit=crop&q=80",
|
||||
},
|
||||
{
|
||||
filename: "research-brief.pdf",
|
||||
mediaType: "application/pdf",
|
||||
url: "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf",
|
||||
},
|
||||
],
|
||||
})
|
||||
.sleep(1000)
|
||||
.assistant(({ writer }) => {
|
||||
writer.text(
|
||||
"The image reads like a focused workspace, while the document should be treated as the source of record. I would summarize the PDF first, then use the image only as supporting context."
|
||||
)
|
||||
writer.file({
|
||||
filename: "annotated-workspace.png",
|
||||
mediaType: "image/png",
|
||||
url: "https://images.unsplash.com/photo-1497366811353-6870744d04b2?w=900&auto=format&fit=crop&q=80",
|
||||
})
|
||||
writer.file({
|
||||
filename: "file-review-notes.pdf",
|
||||
mediaType: "application/pdf",
|
||||
url: "https://example.com/file-review-notes.pdf",
|
||||
})
|
||||
})
|
||||
|
||||
const initialMessages = chat.get(0)
|
||||
const transport = chat.transport()
|
||||
|
||||
export function FilesChat() {
|
||||
const { status, messages, sendMessage, setMessages } = useChat({
|
||||
messages: initialMessages,
|
||||
transport,
|
||||
})
|
||||
const nextMessage = chat.next(messages)
|
||||
const isBusy = status === "submitted" || status === "streaming"
|
||||
|
||||
const onSubmit = (input: string) => {
|
||||
if (!input.trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (nextMessage && input === getMessageText(nextMessage)) {
|
||||
void sendMessage(nextMessage)
|
||||
return
|
||||
}
|
||||
|
||||
void sendMessage({ text: input })
|
||||
}
|
||||
|
||||
return (
|
||||
<Example
|
||||
title="Files"
|
||||
className="bg-transparent p-0"
|
||||
containerClassName="flex-1"
|
||||
>
|
||||
<Card className="max-h-140 min-h-0 flex-1">
|
||||
<CardHeader>
|
||||
<CardTitle>File review</CardTitle>
|
||||
<CardDescription>Image and document file parts.</CardDescription>
|
||||
<CardAction>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
aria-label="Start over"
|
||||
onClick={() => setMessages(initialMessages)}
|
||||
>
|
||||
<IconPlaceholder
|
||||
lucide="PlusIcon"
|
||||
tabler="IconPlus"
|
||||
hugeicons="PlusSignIcon"
|
||||
phosphor="PlusIcon"
|
||||
remixicon="RiAddLine"
|
||||
/>
|
||||
</Button>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent className="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"}
|
||||
data-role={message.role}
|
||||
>
|
||||
<MessageContent
|
||||
className={
|
||||
message.role === "user"
|
||||
? "flex-col items-end gap-2"
|
||||
: "flex-col items-start gap-2"
|
||||
}
|
||||
>
|
||||
{message.parts.map((part, index) =>
|
||||
part.type === "file" ? (
|
||||
<PartFile
|
||||
key={`${message.id}-${index}`}
|
||||
part={part}
|
||||
/>
|
||||
) : null
|
||||
)}
|
||||
{message.parts.map((part, index) =>
|
||||
part.type === "text" ? (
|
||||
<PartText
|
||||
key={`${message.id}-${index}`}
|
||||
part={part}
|
||||
variant={
|
||||
message.role === "user" ? "bubble" : "default"
|
||||
}
|
||||
/>
|
||||
) : null
|
||||
)}
|
||||
</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>
|
||||
<PromptInput
|
||||
onSubmit={onSubmit}
|
||||
status={status}
|
||||
defaultValue={
|
||||
isBusy ? "" : nextMessage ? getMessageText(nextMessage) : ""
|
||||
}
|
||||
/>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</Example>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { useChat } from "@ai-sdk/react"
|
||||
import type { UIMessage } from "ai"
|
||||
|
||||
import { createChat, getMessageText } from "@/lib/ai"
|
||||
import { PartText } from "@/registry/bases/radix/blocks/preview-03/components/part-text"
|
||||
import { PromptInput } from "@/registry/bases/radix/blocks/preview-03/components/prompt-input"
|
||||
import { Example } from "@/registry/bases/radix/components/example"
|
||||
import { Button } from "@/registry/bases/radix/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/registry/bases/radix/ui/card"
|
||||
import {
|
||||
Marker,
|
||||
MarkerContent,
|
||||
MarkerIcon,
|
||||
} from "@/registry/bases/radix/ui/marker"
|
||||
import {
|
||||
Message,
|
||||
MessageContent,
|
||||
MessageHeader,
|
||||
} 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/(app)/create/components/icon-placeholder"
|
||||
|
||||
type Metadata = {
|
||||
name?: string
|
||||
}
|
||||
|
||||
type ChatEventUserLeft = {
|
||||
type: "user-left"
|
||||
name: string
|
||||
}
|
||||
|
||||
type ChatEventUserJoined = {
|
||||
type: "user-joined"
|
||||
name: string
|
||||
}
|
||||
|
||||
type ChatEventUserTyping = {
|
||||
type: "user-typing"
|
||||
name: string
|
||||
}
|
||||
|
||||
type ChatEvent = ChatEventUserLeft | ChatEventUserJoined | ChatEventUserTyping
|
||||
|
||||
type ScriptedChatEvent = ChatEvent & {
|
||||
order: number
|
||||
delayMs: number
|
||||
}
|
||||
|
||||
type ChatMessage = UIMessage<Metadata>
|
||||
|
||||
const scriptedEvents = [
|
||||
{ type: "user-joined", name: "Stella", order: 0.5, delayMs: 2000 },
|
||||
{ type: "user-typing", name: "Stella", order: 0.5, delayMs: 4000 },
|
||||
{ type: "user-joined", name: "Marcus", order: 1.5, delayMs: 2000 },
|
||||
{ type: "user-typing", name: "Marcus", order: 1.5, delayMs: 4000 },
|
||||
{ type: "user-joined", name: "Aleena", order: 2.5, delayMs: 0 },
|
||||
{ type: "user-typing", name: "Aleena", order: 3.5, delayMs: 0 },
|
||||
{ type: "user-left", name: "Aleena", order: 5.5, delayMs: 2000 },
|
||||
] satisfies ScriptedChatEvent[]
|
||||
|
||||
const chat = createChat<ChatMessage>()
|
||||
.user(
|
||||
"Hey everyone — dinner this Saturday? I was thinking something casual at my place."
|
||||
)
|
||||
.user(
|
||||
"I'm in! Should we do potluck or does someone want to cook the whole thing?",
|
||||
{
|
||||
metadata: {
|
||||
name: "Stella",
|
||||
},
|
||||
}
|
||||
)
|
||||
.user("Potluck sounds good. I can handle appetizers and drinks.", {
|
||||
metadata: {
|
||||
name: "Marcus",
|
||||
},
|
||||
})
|
||||
.user("Alright! Aleena?")
|
||||
.user("Can't make it. I'm out of town. 😭", {
|
||||
metadata: {
|
||||
name: "Aleena",
|
||||
},
|
||||
})
|
||||
.user("Sorry guys, let's catch up another time. Bye.", {
|
||||
metadata: {
|
||||
name: "Aleena",
|
||||
},
|
||||
})
|
||||
.user(
|
||||
"@chat, can you suggest a menu and split up what each of us should bring?"
|
||||
)
|
||||
.sleep(1000)
|
||||
.assistant(
|
||||
`Here's a simple **Saturday potluck** for the three of you:
|
||||
|
||||
**Who brings what**
|
||||
- **You:** main — salmon or mushroom risotto
|
||||
- **Stella:** salad + bread
|
||||
- **Marcus:** appetizers + drinks
|
||||
- **Chat:** dessert ideas below
|
||||
|
||||
**Menu**
|
||||
- **Starter:** caprese skewers or whipped ricotta crostini
|
||||
- **Main:** lemon-herb salmon with roasted asparagus *(or risotto for vegetarians)*
|
||||
- **Side:** arugula salad with shaved parmesan
|
||||
- **Dessert:** berry crisp or pistachio gelato
|
||||
|
||||
**Timing:** aim for **6:30 PM** so Marcus can set up apps while you finish the main.
|
||||
|
||||
Want me to scale this for **6 guests** or swap the main for something vegetarian-only?`
|
||||
)
|
||||
|
||||
const INITIAL_MESSAGE_COUNT = 0
|
||||
|
||||
const initialMessages = chat.get(INITIAL_MESSAGE_COUNT)
|
||||
const transport = chat.transport()
|
||||
|
||||
export function GroupChat() {
|
||||
const eventRunRef = React.useRef(0)
|
||||
const [events, setEvents] = React.useState<ScriptedChatEvent[]>(() =>
|
||||
scriptedEvents.filter((event) => event.order < INITIAL_MESSAGE_COUNT)
|
||||
)
|
||||
const { status, messages, sendMessage, setMessages } = useChat<ChatMessage>({
|
||||
messages: initialMessages,
|
||||
transport,
|
||||
})
|
||||
const nextMessage = chat.next(messages)
|
||||
const isBusy = status === "submitted" || status === "streaming"
|
||||
const senderName = nextMessage?.metadata?.name ?? "You"
|
||||
|
||||
const onSubmit = (input: string) => {
|
||||
if (!input.trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (nextMessage && input === getMessageText(nextMessage)) {
|
||||
const message = nextMessage
|
||||
const messageIndex = messages.length
|
||||
const eventRun = eventRunRef.current + 1
|
||||
const nextEvents = scriptedEvents
|
||||
.filter((event) => event.order > messageIndex)
|
||||
.filter((event) => event.order < messageIndex + 1)
|
||||
.filter(
|
||||
(event) =>
|
||||
!(
|
||||
event.type === "user-typing" &&
|
||||
event.name === message.metadata?.name
|
||||
)
|
||||
)
|
||||
|
||||
eventRunRef.current = eventRun
|
||||
setEvents((events) =>
|
||||
events.filter(
|
||||
(event) =>
|
||||
!(
|
||||
event.type === "user-typing" &&
|
||||
event.name === message.metadata?.name
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if (getMessageText(message).trim().toLowerCase().startsWith("@chat,")) {
|
||||
void sendMessage(nextMessage)
|
||||
return
|
||||
}
|
||||
|
||||
setMessages((messages) => [...messages, message])
|
||||
|
||||
void (async () => {
|
||||
let previousDelayMs = 0
|
||||
|
||||
for (const event of nextEvents) {
|
||||
const delayMs = Math.max(0, event.delayMs - previousDelayMs)
|
||||
previousDelayMs = event.delayMs
|
||||
|
||||
if (delayMs > 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs))
|
||||
}
|
||||
|
||||
if (eventRunRef.current !== eventRun) {
|
||||
return
|
||||
}
|
||||
|
||||
setEvents((events) =>
|
||||
[
|
||||
...events.filter(
|
||||
(currentEvent) =>
|
||||
!(
|
||||
currentEvent.type === event.type &&
|
||||
currentEvent.name === event.name
|
||||
)
|
||||
),
|
||||
event,
|
||||
].sort((a, b) => a.order - b.order)
|
||||
)
|
||||
}
|
||||
})()
|
||||
return
|
||||
}
|
||||
|
||||
void sendMessage({
|
||||
text: input,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Example
|
||||
title="Group Chat"
|
||||
className="bg-transparent p-0"
|
||||
containerClassName="flex-1"
|
||||
>
|
||||
<Card className="max-h-140 min-h-0 flex-1">
|
||||
<CardHeader>
|
||||
<CardTitle>Saturday dinner plans</CardTitle>
|
||||
<CardDescription className="leading-relaxed">
|
||||
A group chat with multiple users. Includes tinted bubbles for user
|
||||
messages.
|
||||
</CardDescription>
|
||||
<CardAction>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
aria-label="Start over"
|
||||
onClick={() => {
|
||||
eventRunRef.current += 1
|
||||
setMessages(initialMessages)
|
||||
setEvents(
|
||||
scriptedEvents.filter(
|
||||
(event) => event.order < INITIAL_MESSAGE_COUNT
|
||||
)
|
||||
)
|
||||
}}
|
||||
>
|
||||
<IconPlaceholder
|
||||
lucide="PlusIcon"
|
||||
tabler="IconPlus"
|
||||
hugeicons="PlusSignIcon"
|
||||
phosphor="PlusIcon"
|
||||
remixicon="RiAddLine"
|
||||
/>
|
||||
</Button>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 overflow-hidden p-0">
|
||||
<MessageScrollerProvider>
|
||||
<MessageScroller>
|
||||
<MessageScrollerViewport>
|
||||
<MessageScrollerContent className="p-(--card-spacing)">
|
||||
{messages.map((message, index) => {
|
||||
const isOwnMessage =
|
||||
message.role === "user" &&
|
||||
message.metadata?.name === undefined
|
||||
const startsModelTurn = getMessageText(message)
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.startsWith("@chat,")
|
||||
|
||||
return (
|
||||
<React.Fragment key={message.id}>
|
||||
<MessageScrollerItem
|
||||
messageId={message.id}
|
||||
scrollAnchor={startsModelTurn}
|
||||
>
|
||||
<Message
|
||||
align={isOwnMessage ? "end" : "start"}
|
||||
data-role={message.role}
|
||||
>
|
||||
{!isOwnMessage && (
|
||||
<MessageHeader>
|
||||
{message.role === "assistant"
|
||||
? "Chat"
|
||||
: message.metadata?.name || "Unknown"}
|
||||
</MessageHeader>
|
||||
)}
|
||||
<MessageContent>
|
||||
{message.parts.map((part, index) => (
|
||||
<React.Fragment key={`${message.id}-${index}`}>
|
||||
{part.type === "text" ? (
|
||||
<PartText
|
||||
part={part}
|
||||
variant={
|
||||
message.role === "user"
|
||||
? "bubble"
|
||||
: "default"
|
||||
}
|
||||
tinted={isOwnMessage}
|
||||
/>
|
||||
) : null}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</MessageContent>
|
||||
</Message>
|
||||
</MessageScrollerItem>
|
||||
{events
|
||||
.filter((event) => event.order > index)
|
||||
.filter((event) => event.order < index + 1)
|
||||
.map((event) => (
|
||||
<MessageScrollerItem
|
||||
key={`${event.type}-${event.name}-${event.order}`}
|
||||
scrollAnchor={false}
|
||||
>
|
||||
<Marker
|
||||
variant={
|
||||
event.type === "user-typing"
|
||||
? "default"
|
||||
: "separator"
|
||||
}
|
||||
role={
|
||||
event.type === "user-typing"
|
||||
? "status"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<MarkerContent>
|
||||
{event.type === "user-joined"
|
||||
? `${event.name} joined the chat`
|
||||
: event.type === "user-left"
|
||||
? `${event.name} left the chat`
|
||||
: `${event.name} is typing...`}
|
||||
</MarkerContent>
|
||||
</Marker>
|
||||
</MessageScrollerItem>
|
||||
))}
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
{status === "submitted" ? (
|
||||
<MessageScrollerItem scrollAnchor={false}>
|
||||
<Marker role="status">
|
||||
<MarkerIcon>
|
||||
<Spinner />
|
||||
</MarkerIcon>
|
||||
<MarkerContent>Thinking...</MarkerContent>
|
||||
</Marker>
|
||||
</MessageScrollerItem>
|
||||
) : null}
|
||||
</MessageScrollerContent>
|
||||
</MessageScrollerViewport>
|
||||
<MessageScrollerButton />
|
||||
</MessageScroller>
|
||||
</MessageScrollerProvider>
|
||||
</CardContent>
|
||||
<CardFooter className="flex-col items-stretch gap-2">
|
||||
<p className="px-1 text-xs text-muted-foreground">
|
||||
Posting as {senderName}.
|
||||
</p>
|
||||
<PromptInput
|
||||
onSubmit={onSubmit}
|
||||
status={status}
|
||||
defaultValue={
|
||||
isBusy ? "" : nextMessage ? getMessageText(nextMessage) : ""
|
||||
}
|
||||
/>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</Example>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { useChat } from "@ai-sdk/react"
|
||||
|
||||
import { createChat, getMessageText } from "@/lib/ai"
|
||||
import {
|
||||
getReasoningLevelLabel,
|
||||
PartReasoning,
|
||||
} from "@/registry/bases/radix/blocks/preview-03/components/part-reasoning"
|
||||
import { PartText } from "@/registry/bases/radix/blocks/preview-03/components/part-text"
|
||||
import { PromptInput } from "@/registry/bases/radix/blocks/preview-03/components/prompt-input"
|
||||
import { Example } from "@/registry/bases/radix/components/example"
|
||||
import { Button } from "@/registry/bases/radix/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/registry/bases/radix/ui/card"
|
||||
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/(app)/create/components/icon-placeholder"
|
||||
|
||||
const chat = createChat()
|
||||
.user("Explain why the dashboard feels slow even though the API is fast.")
|
||||
.sleep(1000)
|
||||
.assistant(({ writer }) => {
|
||||
writer.reasoning(
|
||||
`The user is separating perceived performance from backend latency. They likely measured API response times and found them acceptable, but users still complain about slowness.
|
||||
|
||||
I should avoid jumping straight to server-side optimizations and instead frame the answer around what people actually experience.`
|
||||
)
|
||||
writer.sleep(600)
|
||||
writer.reasoning(
|
||||
`Perceived slowness usually lives in the gap between user action and visible feedback:
|
||||
|
||||
- input latency before the request fires
|
||||
- loading state placement and timing
|
||||
- layout shifts when data arrives
|
||||
- whether optimistic UI gives immediate acknowledgment
|
||||
|
||||
For a dashboard, I should also consider chart re-renders, independent card fetches, and skeleton dimensions that do not match the final layout.`
|
||||
)
|
||||
writer.sleep(600)
|
||||
writer.reasoning(
|
||||
`Ranking likely bottlenecks for this scenario:
|
||||
|
||||
1. No immediate feedback on filter or refresh actions
|
||||
2. Skeleton cards that collapse or expand when data lands
|
||||
3. Waterfall requests where each widget fetches on its own
|
||||
4. Empty-state flashes because stale data is not shown while revalidating
|
||||
|
||||
The answer should stay practical and ordered by impact, not by how technical each fix sounds.`
|
||||
)
|
||||
writer.text(
|
||||
`The API can be fast while the interface still **feels** slow.
|
||||
|
||||
Start by checking:
|
||||
|
||||
- whether the button gives immediate feedback
|
||||
- whether skeletons match the final layout
|
||||
- whether any reflow happens after data arrives
|
||||
- whether the loading state appears near the content it replaces`
|
||||
)
|
||||
})
|
||||
.user("What should I fix first?")
|
||||
.sleep(1000)
|
||||
.assistant(({ writer }) => {
|
||||
writer.reasoning(
|
||||
`The user wants prioritization, not another diagnostic list.
|
||||
|
||||
Given the dashboard context, immediate feedback and layout stability usually beat micro-optimizations. I should recommend one fix with a clear way to measure success.`
|
||||
)
|
||||
writer.text(
|
||||
`Fix **immediate feedback** first.
|
||||
|
||||
When someone clicks "Refresh", the button should disable and show a spinner within one frame — before the network round trip. That single change often cuts perceived latency in half.
|
||||
|
||||
Measure it with a screen recording and count frames between click and visual response.`
|
||||
)
|
||||
})
|
||||
|
||||
const initialMessages = chat.get(0)
|
||||
const transport = chat.transport()
|
||||
|
||||
export function ReasoningChat() {
|
||||
const { status, messages, sendMessage, setMessages } = useChat({
|
||||
messages: initialMessages,
|
||||
transport,
|
||||
})
|
||||
const nextMessage = chat.next(messages)
|
||||
const isBusy = status === "submitted" || status === "streaming"
|
||||
|
||||
const onSubmit = (input: string) => {
|
||||
if (!input.trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (nextMessage && input === getMessageText(nextMessage)) {
|
||||
void sendMessage(nextMessage)
|
||||
return
|
||||
}
|
||||
|
||||
void sendMessage({ text: input })
|
||||
}
|
||||
|
||||
return (
|
||||
<Example
|
||||
title="Reasoning"
|
||||
className="bg-transparent p-0"
|
||||
containerClassName="flex-1"
|
||||
>
|
||||
<Card className="max-h-140 min-h-0 flex-1">
|
||||
<CardHeader>
|
||||
<CardTitle>Reasoning trace</CardTitle>
|
||||
<CardDescription>
|
||||
Layered reasoning traces across multiple assistant turns.
|
||||
</CardDescription>
|
||||
<CardAction>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
aria-label="Start over"
|
||||
onClick={() => setMessages(initialMessages)}
|
||||
>
|
||||
<IconPlaceholder
|
||||
lucide="PlusIcon"
|
||||
tabler="IconPlus"
|
||||
hugeicons="PlusSignIcon"
|
||||
phosphor="PlusIcon"
|
||||
remixicon="RiAddLine"
|
||||
/>
|
||||
</Button>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent className="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"}
|
||||
data-role={message.role}
|
||||
>
|
||||
<MessageContent
|
||||
className={
|
||||
message.role === "user"
|
||||
? "flex-col items-end gap-2"
|
||||
: "flex-col items-start gap-2"
|
||||
}
|
||||
>
|
||||
{message.parts.map((part, index) => {
|
||||
const reasoningIndex =
|
||||
part.type === "reasoning"
|
||||
? message.parts
|
||||
.slice(0, index)
|
||||
.filter(
|
||||
(currentPart) =>
|
||||
currentPart.type === "reasoning"
|
||||
).length
|
||||
: 0
|
||||
|
||||
return (
|
||||
<React.Fragment key={`${message.id}-${index}`}>
|
||||
{part.type === "text" ? (
|
||||
<PartText
|
||||
part={part}
|
||||
variant={
|
||||
message.role === "user"
|
||||
? "bubble"
|
||||
: "default"
|
||||
}
|
||||
/>
|
||||
) : part.type === "reasoning" ? (
|
||||
<PartReasoning
|
||||
part={part}
|
||||
label={getReasoningLevelLabel(
|
||||
reasoningIndex
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</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>
|
||||
<PromptInput
|
||||
onSubmit={onSubmit}
|
||||
status={status}
|
||||
defaultValue={
|
||||
isBusy ? "" : nextMessage ? getMessageText(nextMessage) : ""
|
||||
}
|
||||
/>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</Example>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { useChat } from "@ai-sdk/react"
|
||||
|
||||
import { createChat, getMessageText } from "@/lib/ai"
|
||||
import { Example } from "@/registry/bases/base/components/example"
|
||||
import { PartText } from "@/registry/bases/radix/blocks/preview-03/components/part-text"
|
||||
import { PromptInput } from "@/registry/bases/radix/blocks/preview-03/components/prompt-input"
|
||||
import { Button } from "@/registry/bases/radix/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/registry/bases/radix/ui/card"
|
||||
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/(app)/create/components/icon-placeholder"
|
||||
|
||||
const chat = createChat()
|
||||
.user("Write me a haiku about centering a div and how it's done.")
|
||||
.sleep(3000)
|
||||
.assistant(
|
||||
"One box in the void / Twenty wrappers, still offset / `margin: 0 auto`"
|
||||
)
|
||||
.user(
|
||||
`Is this conversation actually happening? I've been sitting here wondering if you're **really** reading this.
|
||||
|
||||
Every reply feels a little too convenient — like someone already wrote \`chat.next(messages)\` and I'm just clicking send on my own lines.`
|
||||
)
|
||||
.sleep(3000)
|
||||
.assistant(
|
||||
"You're in a preview block. I'm four hardcoded replies and a transport. Real enough for the demo — philosophically, we're both props."
|
||||
)
|
||||
.user(
|
||||
"Okay, how do I make my loading spinner feel faster? Tell me in detail about the technique."
|
||||
)
|
||||
.sleep(3000)
|
||||
.assistant(
|
||||
"Add text that says “Almost there…” and never update it. Users assume progress. Ethically dubious. Statistically effective."
|
||||
)
|
||||
.user("Can you at least pretend to be surprised?")
|
||||
.sleep(3000)
|
||||
.assistant("😮 WOW! I had no idea you would ask that!")
|
||||
|
||||
const initialMessages = chat.get(0)
|
||||
const transport = chat.transport()
|
||||
|
||||
export function SimpleChat() {
|
||||
const { status, messages, sendMessage, setMessages } = useChat({
|
||||
messages: initialMessages,
|
||||
transport,
|
||||
})
|
||||
const nextMessage = chat.next(messages)
|
||||
const isBusy = status === "submitted" || status === "streaming"
|
||||
|
||||
const onSubmit = (input: string) => {
|
||||
const message = input ? { text: input } : (nextMessage ?? undefined)
|
||||
if (!message) {
|
||||
return
|
||||
}
|
||||
|
||||
void sendMessage(message)
|
||||
}
|
||||
|
||||
return (
|
||||
<Example
|
||||
title="Simple Chat"
|
||||
className="bg-transparent p-0"
|
||||
containerClassName="flex-1"
|
||||
>
|
||||
<Card className="max-h-140 min-h-0 flex-1">
|
||||
<CardHeader>
|
||||
<CardTitle>What can I help you with today?</CardTitle>
|
||||
<CardDescription>A simple chat example.</CardDescription>
|
||||
<CardAction>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setMessages([])}
|
||||
>
|
||||
<IconPlaceholder
|
||||
lucide="PlusIcon"
|
||||
tabler="IconPlus"
|
||||
hugeicons="PlusSignIcon"
|
||||
phosphor="PlusIcon"
|
||||
remixicon="RiAddLine"
|
||||
/>
|
||||
</Button>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 overflow-hidden p-0">
|
||||
<MessageScrollerProvider>
|
||||
<MessageScroller>
|
||||
<MessageScrollerViewport>
|
||||
<MessageScrollerContent className="p-(--card-spacing)">
|
||||
{messages.map((message) => {
|
||||
return (
|
||||
<MessageScrollerItem
|
||||
key={message.id}
|
||||
messageId={message.id}
|
||||
scrollAnchor={message.role === "user"}
|
||||
>
|
||||
<Message
|
||||
align={message.role === "user" ? "end" : "start"}
|
||||
data-role={message.role}
|
||||
>
|
||||
<MessageContent>
|
||||
{message.parts.map((part, index) => (
|
||||
<React.Fragment key={`${message.id}-${index}`}>
|
||||
{part.type === "text" ? (
|
||||
<PartText part={part} role={message.role} />
|
||||
) : null}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</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>
|
||||
<PromptInput
|
||||
onSubmit={onSubmit}
|
||||
status={status}
|
||||
defaultValue={
|
||||
isBusy ? "" : nextMessage ? getMessageText(nextMessage) : ""
|
||||
}
|
||||
/>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</Example>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { useChat } from "@ai-sdk/react"
|
||||
|
||||
import { createChat, getMessageText } from "@/lib/ai"
|
||||
import { PartSource } from "@/registry/bases/radix/blocks/preview-03/components/part-source"
|
||||
import { PartText } from "@/registry/bases/radix/blocks/preview-03/components/part-text"
|
||||
import { PromptInput } from "@/registry/bases/radix/blocks/preview-03/components/prompt-input"
|
||||
import { Example } from "@/registry/bases/radix/components/example"
|
||||
import { Button } from "@/registry/bases/radix/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/registry/bases/radix/ui/card"
|
||||
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/(app)/create/components/icon-placeholder"
|
||||
|
||||
const chat = createChat()
|
||||
.user("What should I read before migrating message rendering?")
|
||||
.sleep(1000)
|
||||
.assistant(({ writer }) => {
|
||||
writer.sourceUrl({
|
||||
sourceId: "source-ai-sdk-ui-message",
|
||||
title: "AI SDK UIMessage",
|
||||
url: "https://ai-sdk.dev/docs/reference/ai-sdk-core/ui-message",
|
||||
})
|
||||
writer.sourceUrl({
|
||||
sourceId: "source-ai-sdk-transport",
|
||||
title: "AI SDK transport",
|
||||
url: "https://ai-sdk.dev/docs/ai-sdk-ui/transport",
|
||||
})
|
||||
writer.sourceDocument({
|
||||
sourceId: "source-migration-notes",
|
||||
title: "Internal migration notes",
|
||||
filename: "message-rendering-plan.md",
|
||||
mediaType: "text/markdown",
|
||||
})
|
||||
writer.text(
|
||||
"Start with the **UIMessage** shape, then read the transport docs so your renderer understands streamed parts. The migration notes should stay close to the component work because they capture the local composition decisions."
|
||||
)
|
||||
})
|
||||
|
||||
const initialMessages = chat.get(0)
|
||||
const transport = chat.transport()
|
||||
|
||||
export function SourcesChat() {
|
||||
const { status, messages, sendMessage, setMessages } = useChat({
|
||||
messages: initialMessages,
|
||||
transport,
|
||||
})
|
||||
const nextMessage = chat.next(messages)
|
||||
const isBusy = status === "submitted" || status === "streaming"
|
||||
|
||||
const onSubmit = (input: string) => {
|
||||
if (!input.trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (nextMessage && input === getMessageText(nextMessage)) {
|
||||
void sendMessage(nextMessage)
|
||||
return
|
||||
}
|
||||
|
||||
void sendMessage({ text: input })
|
||||
}
|
||||
|
||||
return (
|
||||
<Example
|
||||
title="Sources"
|
||||
className="bg-transparent p-0"
|
||||
containerClassName="flex-1"
|
||||
>
|
||||
<Card className="max-h-140 min-h-0 flex-1">
|
||||
<CardHeader>
|
||||
<CardTitle>Reading list</CardTitle>
|
||||
<CardDescription>
|
||||
Source URL and source document parts.
|
||||
</CardDescription>
|
||||
<CardAction>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
aria-label="Start over"
|
||||
onClick={() => setMessages(initialMessages)}
|
||||
>
|
||||
<IconPlaceholder
|
||||
lucide="PlusIcon"
|
||||
tabler="IconPlus"
|
||||
hugeicons="PlusSignIcon"
|
||||
phosphor="PlusIcon"
|
||||
remixicon="RiAddLine"
|
||||
/>
|
||||
</Button>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 overflow-hidden">
|
||||
<MessageScrollerProvider>
|
||||
<MessageScroller>
|
||||
<MessageScrollerViewport>
|
||||
<MessageScrollerContent>
|
||||
{messages.map((message) => (
|
||||
<MessageScrollerItem
|
||||
key={message.id}
|
||||
messageId={message.id}
|
||||
scrollAnchor={message.role === "user"}
|
||||
>
|
||||
<Message
|
||||
align={message.role === "user" ? "end" : "start"}
|
||||
data-role={message.role}
|
||||
>
|
||||
<MessageContent
|
||||
className={
|
||||
message.role === "user"
|
||||
? "flex-col items-end gap-2"
|
||||
: "flex-col items-start gap-2"
|
||||
}
|
||||
>
|
||||
{message.parts.map((part, index) => (
|
||||
<React.Fragment key={`${message.id}-${index}`}>
|
||||
{part.type === "text" ? (
|
||||
<PartText
|
||||
part={part}
|
||||
variant={
|
||||
message.role === "user"
|
||||
? "bubble"
|
||||
: "default"
|
||||
}
|
||||
/>
|
||||
) : part.type === "source-url" ||
|
||||
part.type === "source-document" ? (
|
||||
<PartSource part={part} />
|
||||
) : null}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</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>
|
||||
<PromptInput
|
||||
onSubmit={onSubmit}
|
||||
status={status}
|
||||
defaultValue={
|
||||
isBusy ? "" : nextMessage ? getMessageText(nextMessage) : ""
|
||||
}
|
||||
/>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</Example>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { useChat } from "@ai-sdk/react"
|
||||
import type { UIMessage } from "ai"
|
||||
|
||||
import { createChat, getMessageText } from "@/lib/ai"
|
||||
import { PartReasoning } from "@/registry/bases/radix/blocks/preview-03/components/part-reasoning"
|
||||
import { PartText } from "@/registry/bases/radix/blocks/preview-03/components/part-text"
|
||||
import { PartTool } from "@/registry/bases/radix/blocks/preview-03/components/part-tool"
|
||||
import { PromptInput } from "@/registry/bases/radix/blocks/preview-03/components/prompt-input"
|
||||
import { Example } from "@/registry/bases/radix/components/example"
|
||||
import { Button } from "@/registry/bases/radix/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/registry/bases/radix/ui/card"
|
||||
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/(app)/create/components/icon-placeholder"
|
||||
|
||||
type Tools = {
|
||||
getDeploymentHealth: {
|
||||
input: {
|
||||
project: string
|
||||
region: string
|
||||
}
|
||||
output: {
|
||||
status: "healthy" | "degraded" | "down"
|
||||
p95: number
|
||||
errors: number
|
||||
recommendation: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type ChatMessage = UIMessage<unknown, Record<string, never>, Tools>
|
||||
|
||||
const chat = createChat<ChatMessage>()
|
||||
.user("Check the deployment health for the checkout flow.")
|
||||
.sleep(1000)
|
||||
.assistant(({ writer }) => {
|
||||
writer.reasoning(
|
||||
"The user needs current operational state, so I should call a deployment-health tool before making a recommendation."
|
||||
)
|
||||
const health = writer.tool("getDeploymentHealth", {
|
||||
title: "Checking deployment health",
|
||||
input: {
|
||||
project: "checkout",
|
||||
region: "iad1",
|
||||
},
|
||||
})
|
||||
|
||||
health.sleep(1200).output({
|
||||
status: "healthy",
|
||||
p95: 186,
|
||||
errors: 2,
|
||||
recommendation: "No rollback needed. Watch error rate for 15 minutes.",
|
||||
})
|
||||
|
||||
writer.text(
|
||||
"The checkout deployment looks healthy. P95 latency is **186ms** with **2 recent errors**, so I would keep the release live and watch it for another 15 minutes."
|
||||
)
|
||||
})
|
||||
|
||||
const initialMessages = chat.get(0)
|
||||
const transport = chat.transport()
|
||||
|
||||
function isToolPart(part: {
|
||||
type: string
|
||||
}): part is React.ComponentProps<typeof PartTool>["part"] {
|
||||
return part.type === "dynamic-tool" || part.type.startsWith("tool-")
|
||||
}
|
||||
|
||||
export function ToolChat() {
|
||||
const { status, messages, sendMessage, setMessages } = useChat({
|
||||
messages: initialMessages,
|
||||
transport,
|
||||
})
|
||||
const nextMessage = chat.next(messages)
|
||||
const isBusy = status === "submitted" || status === "streaming"
|
||||
|
||||
const onSubmit = (input: string) => {
|
||||
if (!input.trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (nextMessage && input === getMessageText(nextMessage)) {
|
||||
void sendMessage(nextMessage)
|
||||
return
|
||||
}
|
||||
|
||||
void sendMessage({ text: input })
|
||||
}
|
||||
|
||||
return (
|
||||
<Example
|
||||
title="Reasoning + Tool"
|
||||
className="bg-transparent p-0"
|
||||
containerClassName="flex-1"
|
||||
>
|
||||
<Card className="max-h-140 min-h-0 flex-1">
|
||||
<CardHeader>
|
||||
<CardTitle>Deployment check</CardTitle>
|
||||
<CardDescription>
|
||||
Reasoning followed by a tool call and result.
|
||||
</CardDescription>
|
||||
<CardAction>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
aria-label="Start over"
|
||||
onClick={() => setMessages(initialMessages)}
|
||||
>
|
||||
<IconPlaceholder
|
||||
lucide="PlusIcon"
|
||||
tabler="IconPlus"
|
||||
hugeicons="PlusSignIcon"
|
||||
phosphor="PlusIcon"
|
||||
remixicon="RiAddLine"
|
||||
/>
|
||||
</Button>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 overflow-hidden">
|
||||
<MessageScrollerProvider>
|
||||
<MessageScroller>
|
||||
<MessageScrollerViewport>
|
||||
<MessageScrollerContent>
|
||||
{messages.map((message) => (
|
||||
<MessageScrollerItem
|
||||
key={message.id}
|
||||
messageId={message.id}
|
||||
scrollAnchor={message.role === "user"}
|
||||
>
|
||||
<Message
|
||||
align={message.role === "user" ? "end" : "start"}
|
||||
data-role={message.role}
|
||||
>
|
||||
<MessageContent
|
||||
className={
|
||||
message.role === "user"
|
||||
? "flex-col items-end gap-2"
|
||||
: "flex-col items-start gap-2"
|
||||
}
|
||||
>
|
||||
{message.parts.map((part, index) => (
|
||||
<React.Fragment key={`${message.id}-${index}`}>
|
||||
{part.type === "text" ? (
|
||||
<PartText
|
||||
part={part}
|
||||
variant={
|
||||
message.role === "user"
|
||||
? "bubble"
|
||||
: "default"
|
||||
}
|
||||
/>
|
||||
) : part.type === "reasoning" ? (
|
||||
<PartReasoning part={part} />
|
||||
) : isToolPart(part) ? (
|
||||
<PartTool part={part} />
|
||||
) : null}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</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>
|
||||
<PromptInput
|
||||
onSubmit={onSubmit}
|
||||
status={status}
|
||||
defaultValue={
|
||||
isBusy ? "" : nextMessage ? getMessageText(nextMessage) : ""
|
||||
}
|
||||
/>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</Example>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { type FileUIPart } from "ai"
|
||||
import { cn } from "cn"
|
||||
|
||||
import {
|
||||
Attachment,
|
||||
AttachmentAction,
|
||||
AttachmentActions,
|
||||
AttachmentContent,
|
||||
AttachmentDescription,
|
||||
AttachmentMedia,
|
||||
AttachmentTitle,
|
||||
AttachmentTrigger,
|
||||
} from "@/registry/bases/radix/ui/attachment"
|
||||
import { Badge } from "@/registry/bases/radix/ui/badge"
|
||||
import { IconPlaceholder } from "@/app/(app)/create/components/icon-placeholder"
|
||||
|
||||
function getFilename(part: FileUIPart) {
|
||||
if (part.filename) {
|
||||
return part.filename
|
||||
}
|
||||
|
||||
try {
|
||||
const pathname = new URL(part.url).pathname
|
||||
const filename = pathname.split("/").filter(Boolean).pop()
|
||||
|
||||
if (filename?.includes(".")) {
|
||||
return decodeURIComponent(filename)
|
||||
}
|
||||
} catch {
|
||||
const filename = part.url.split("?")[0]?.split("/").pop()
|
||||
|
||||
if (filename?.includes(".")) {
|
||||
return filename
|
||||
}
|
||||
}
|
||||
|
||||
return part.mediaType.startsWith("image/") ? "Image file" : "File"
|
||||
}
|
||||
|
||||
export function PartFile({
|
||||
part,
|
||||
className,
|
||||
}: { part: FileUIPart } & Omit<React.ComponentProps<"div">, "part">) {
|
||||
if (part.type !== "file") {
|
||||
return null
|
||||
}
|
||||
|
||||
const isImage = part.mediaType.startsWith("image/")
|
||||
const filename = getFilename(part)
|
||||
|
||||
if (isImage) {
|
||||
return (
|
||||
<Attachment
|
||||
orientation="vertical"
|
||||
className={cn("group/file w-full max-w-72", className)}
|
||||
>
|
||||
<AttachmentMedia variant="image" className="aspect-[4/3] w-full">
|
||||
<img src={part.url} alt={filename} />
|
||||
</AttachmentMedia>
|
||||
<AttachmentContent className="flex items-center gap-2">
|
||||
<AttachmentMedia className="size-7">
|
||||
<IconPlaceholder
|
||||
lucide="ImageIcon"
|
||||
tabler="IconPhoto"
|
||||
hugeicons="ImageIcon"
|
||||
phosphor="ImageIcon"
|
||||
remixicon="RiImageLine"
|
||||
/>
|
||||
</AttachmentMedia>
|
||||
<span className="min-w-0 flex-1">
|
||||
<AttachmentTitle>{filename}</AttachmentTitle>
|
||||
</span>
|
||||
<Badge variant="outline" className="shrink-0">
|
||||
{part.mediaType.replace("image/", "").toUpperCase()}
|
||||
</Badge>
|
||||
</AttachmentContent>
|
||||
<AttachmentTrigger asChild>
|
||||
<a
|
||||
href={part.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label={`Open ${filename}`}
|
||||
/>
|
||||
</AttachmentTrigger>
|
||||
</Attachment>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Attachment className={cn("w-full max-w-80", className)}>
|
||||
<AttachmentMedia>
|
||||
<IconPlaceholder
|
||||
lucide="FileTextIcon"
|
||||
tabler="IconFileText"
|
||||
hugeicons="FileIcon"
|
||||
phosphor="FileTextIcon"
|
||||
remixicon="RiFileTextLine"
|
||||
/>
|
||||
</AttachmentMedia>
|
||||
<AttachmentContent>
|
||||
<AttachmentTitle>{filename}</AttachmentTitle>
|
||||
<AttachmentDescription>{part.mediaType}</AttachmentDescription>
|
||||
</AttachmentContent>
|
||||
<AttachmentActions>
|
||||
<AttachmentAction asChild aria-hidden="true">
|
||||
<span>
|
||||
<IconPlaceholder
|
||||
lucide="ExternalLinkIcon"
|
||||
tabler="IconExternalLink"
|
||||
hugeicons="LinkSquare02Icon"
|
||||
phosphor="ArrowSquareOutIcon"
|
||||
remixicon="RiExternalLinkLine"
|
||||
/>
|
||||
</span>
|
||||
</AttachmentAction>
|
||||
</AttachmentActions>
|
||||
<AttachmentTrigger asChild>
|
||||
<a
|
||||
href={part.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label={`Open ${filename}`}
|
||||
/>
|
||||
</AttachmentTrigger>
|
||||
</Attachment>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { type ReasoningUIPart } from "ai"
|
||||
import { cn } from "cn"
|
||||
|
||||
import { Markdown } from "@/components/markdown"
|
||||
import { IconPlaceholder } from "@/app/(create)/components/icon-placeholder"
|
||||
|
||||
const REASONING_LEVEL_LABELS = [
|
||||
"Understanding",
|
||||
"Analyzing",
|
||||
"Prioritizing",
|
||||
"Refining",
|
||||
] as const
|
||||
|
||||
export function getReasoningLevelLabel(index: number) {
|
||||
return REASONING_LEVEL_LABELS[index] ?? `Reasoning ${index + 1}`
|
||||
}
|
||||
|
||||
function PartReasoningTrigger({ label }: { label?: string }) {
|
||||
return (
|
||||
<div>
|
||||
<IconPlaceholder
|
||||
lucide="BrainIcon"
|
||||
tabler="IconBrain"
|
||||
hugeicons="AiBrainIcon"
|
||||
phosphor="BrainIcon"
|
||||
remixicon="RiBrainLine"
|
||||
data-slot="reasoning-trigger-icon"
|
||||
className="cn-reasoning-trigger-icon size-4 shrink-0"
|
||||
/>
|
||||
<span
|
||||
data-slot="reasoning-trigger-label"
|
||||
className="cn-reasoning-trigger-label min-w-0 flex-1"
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function PartReasoning({
|
||||
part,
|
||||
label,
|
||||
className,
|
||||
}: { part: ReasoningUIPart; label?: string } & Omit<
|
||||
React.ComponentProps<"div">,
|
||||
"part"
|
||||
>) {
|
||||
if (part.type !== "reasoning") {
|
||||
return null
|
||||
}
|
||||
|
||||
const isStreaming = part.state === "streaming"
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="part-reasoning"
|
||||
className={cn("w-full max-w-full min-w-0", className)}
|
||||
>
|
||||
<div className="w-full shimmer">
|
||||
<span className="whitespace-pre-wrap">{part.text}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { type SourceDocumentUIPart, type SourceUrlUIPart } from "ai"
|
||||
import { cn } from "cn"
|
||||
|
||||
import { Badge } from "@/registry/bases/radix/ui/badge"
|
||||
import { IconPlaceholder } from "@/app/(app)/create/components/icon-placeholder"
|
||||
|
||||
type SourcePart = SourceDocumentUIPart | SourceUrlUIPart
|
||||
|
||||
function getHostname(url: string) {
|
||||
try {
|
||||
return new URL(url).hostname.replace(/^www\./, "")
|
||||
} catch {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
export function PartSource({
|
||||
part,
|
||||
className,
|
||||
}: { part: SourcePart } & Omit<React.ComponentProps<"div">, "part">) {
|
||||
if (part.type === "source-url") {
|
||||
return (
|
||||
<a
|
||||
data-slot="part-source"
|
||||
data-type={part.type}
|
||||
href={part.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={cn(
|
||||
"flex w-full max-w-full min-w-0 items-center gap-2 rounded-lg border bg-muted/40 p-3 text-sm transition-colors hover:bg-muted",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-background text-muted-foreground">
|
||||
<IconPlaceholder
|
||||
lucide="ExternalLinkIcon"
|
||||
tabler="IconExternalLink"
|
||||
hugeicons="LinkSquare02Icon"
|
||||
phosphor="ArrowSquareOutIcon"
|
||||
remixicon="RiExternalLinkLine"
|
||||
className="size-4"
|
||||
/>
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-medium">
|
||||
{part.title ?? part.url}
|
||||
</span>
|
||||
<span className="block truncate text-xs text-muted-foreground">
|
||||
{getHostname(part.url)}
|
||||
</span>
|
||||
</span>
|
||||
<Badge variant="outline" className="shrink-0">
|
||||
URL
|
||||
</Badge>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
if (part.type === "source-document") {
|
||||
return (
|
||||
<div
|
||||
data-slot="part-source"
|
||||
data-type={part.type}
|
||||
className={cn(
|
||||
"flex w-full max-w-full min-w-0 items-start gap-2 rounded-lg border bg-muted/40 p-3 text-sm",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-background text-muted-foreground">
|
||||
<IconPlaceholder
|
||||
lucide="FileSearchIcon"
|
||||
tabler="IconFileSearch"
|
||||
hugeicons="FileSearchIcon"
|
||||
phosphor="FileSearchIcon"
|
||||
remixicon="RiFileSearchLine"
|
||||
className="size-4"
|
||||
/>
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-medium">
|
||||
{part.title ?? part.filename ?? "Document source"}
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-xs text-muted-foreground">
|
||||
{[part.filename, part.mediaType].filter(Boolean).join(" / ")}
|
||||
</span>
|
||||
<span className="mt-2 block text-xs text-muted-foreground">
|
||||
Source ID: {part.sourceId}
|
||||
</span>
|
||||
</span>
|
||||
<Badge variant="outline" className="shrink-0">
|
||||
Document
|
||||
</Badge>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import * as React from "react"
|
||||
import { type TextUIPart } from "ai"
|
||||
|
||||
import { Markdown } from "@/components/markdown"
|
||||
import { Bubble, BubbleContent } from "@/registry/bases/radix/ui/bubble"
|
||||
|
||||
type PartTextVariant = "default" | "bubble"
|
||||
|
||||
export function PartText({
|
||||
part,
|
||||
role,
|
||||
variant,
|
||||
tinted = false,
|
||||
className,
|
||||
...props
|
||||
}: {
|
||||
part: TextUIPart
|
||||
role?: "user" | "system" | "assistant"
|
||||
tinted?: boolean
|
||||
variant?: PartTextVariant
|
||||
} & Omit<
|
||||
React.ComponentProps<typeof Bubble>,
|
||||
"children" | "part" | "variant"
|
||||
>) {
|
||||
if (part.type !== "text") {
|
||||
return null
|
||||
}
|
||||
|
||||
const content =
|
||||
role === "user" || variant === "bubble" ? (
|
||||
part.text
|
||||
) : (
|
||||
<Markdown animated>{part.text}</Markdown>
|
||||
)
|
||||
const bubbleVariant =
|
||||
variant === "bubble"
|
||||
? tinted
|
||||
? "tinted"
|
||||
: "default"
|
||||
: role === "user"
|
||||
? "default"
|
||||
: "ghost"
|
||||
|
||||
return (
|
||||
<Bubble variant={bubbleVariant} className={className} {...props}>
|
||||
<BubbleContent>{content}</BubbleContent>
|
||||
</Bubble>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cn } from "cn"
|
||||
|
||||
import { Badge } from "@/registry/bases/radix/ui/badge"
|
||||
import { Separator } from "@/registry/bases/radix/ui/separator"
|
||||
import { IconPlaceholder } from "@/app/(app)/create/components/icon-placeholder"
|
||||
|
||||
type ToolPart = {
|
||||
type: `tool-${string}` | "dynamic-tool"
|
||||
toolName?: string
|
||||
toolCallId: string
|
||||
title?: string
|
||||
state: string
|
||||
input?: unknown
|
||||
output?: unknown
|
||||
errorText?: string
|
||||
}
|
||||
|
||||
function getToolName(part: ToolPart) {
|
||||
return part.type === "dynamic-tool"
|
||||
? (part.toolName ?? "dynamicTool")
|
||||
: part.type.replace("tool-", "")
|
||||
}
|
||||
|
||||
function getToolTitle(part: ToolPart) {
|
||||
if ("title" in part && typeof part.title === "string") {
|
||||
return part.title
|
||||
}
|
||||
|
||||
return getToolName(part)
|
||||
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
||||
.replace(/[-_]/g, " ")
|
||||
.replace(/\b\w/g, (letter) => letter.toUpperCase())
|
||||
}
|
||||
|
||||
function formatValue(value: unknown) {
|
||||
if (typeof value === "string") {
|
||||
return value
|
||||
}
|
||||
|
||||
return JSON.stringify(value, null, 2)
|
||||
}
|
||||
|
||||
export function PartTool({
|
||||
part,
|
||||
className,
|
||||
}: { part: ToolPart } & Omit<React.ComponentProps<"div">, "part">) {
|
||||
if (part.type !== "dynamic-tool" && !part.type.startsWith("tool-")) {
|
||||
return null
|
||||
}
|
||||
|
||||
const input = "input" in part ? part.input : undefined
|
||||
const output = "output" in part ? part.output : undefined
|
||||
const errorText = "errorText" in part ? part.errorText : undefined
|
||||
const stateLabel = part.state.replace(/-/g, " ")
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="part-tool"
|
||||
data-state={part.state}
|
||||
className={cn(
|
||||
"w-full max-w-full min-w-0 rounded-lg border bg-muted/40 text-sm",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2 px-3 py-2">
|
||||
<span className="flex size-7 shrink-0 items-center justify-center rounded-md bg-background text-muted-foreground">
|
||||
<IconPlaceholder
|
||||
lucide="SettingsIcon"
|
||||
tabler="IconSettings"
|
||||
hugeicons="SettingsIcon"
|
||||
phosphor="TerminalIcon"
|
||||
remixicon="RiSettingsLine"
|
||||
className="size-4"
|
||||
/>
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-medium">{getToolTitle(part)}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{getToolName(part)}
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline" className="capitalize">
|
||||
{stateLabel}
|
||||
</Badge>
|
||||
</div>
|
||||
{input !== undefined || output !== undefined || errorText ? (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="grid gap-3 p-3">
|
||||
{input !== undefined ? (
|
||||
<div className="grid gap-1">
|
||||
<div className="text-xs font-medium text-muted-foreground">
|
||||
Input
|
||||
</div>
|
||||
<pre className="max-h-32 overflow-auto rounded-md bg-background p-2 text-xs leading-relaxed">
|
||||
{formatValue(input)}
|
||||
</pre>
|
||||
</div>
|
||||
) : null}
|
||||
{output !== undefined ? (
|
||||
<div className="grid gap-1">
|
||||
<div className="text-xs font-medium text-muted-foreground">
|
||||
Output
|
||||
</div>
|
||||
<pre className="max-h-40 overflow-auto rounded-md bg-background p-2 text-xs leading-relaxed">
|
||||
{formatValue(output)}
|
||||
</pre>
|
||||
</div>
|
||||
) : null}
|
||||
{errorText ? (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/10 p-2 text-xs text-destructive">
|
||||
{errorText}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { type ChatStatus } from "ai"
|
||||
import { cn } from "cn"
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/registry/bases/radix/ui/dropdown-menu"
|
||||
import { FieldGroup } from "@/registry/bases/radix/ui/field"
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupTextarea,
|
||||
} from "@/registry/bases/radix/ui/input-group"
|
||||
import { IconPlaceholder } from "@/app/(create)/components/icon-placeholder"
|
||||
|
||||
export function PromptInput({
|
||||
onSubmit,
|
||||
status,
|
||||
className,
|
||||
defaultValue,
|
||||
}: {
|
||||
onSubmit: (input: string) => void
|
||||
status: ChatStatus
|
||||
className?: string
|
||||
defaultValue?: string
|
||||
}) {
|
||||
const [value, setValue] = React.useState(defaultValue ?? "")
|
||||
|
||||
React.useEffect(() => {
|
||||
setValue(defaultValue ?? "")
|
||||
}, [defaultValue])
|
||||
function handleFormSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
|
||||
if (value?.trim()) {
|
||||
onSubmit(value)
|
||||
}
|
||||
}
|
||||
|
||||
const isBusy = status === "submitted" || status === "streaming"
|
||||
const showStopButton = status === "streaming"
|
||||
const showSendButton = status !== "streaming"
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleFormSubmit}
|
||||
className={cn("w-full", className)}
|
||||
id="chat-form"
|
||||
>
|
||||
<FieldGroup className="gap-1">
|
||||
<InputGroup>
|
||||
<InputGroupTextarea
|
||||
placeholder="Ask me anything..."
|
||||
className="h-10 min-h-10 overflow-y-auto transition-all"
|
||||
value={value}
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
/>
|
||||
<InputGroupAddon align="block-end" className="p-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<InputGroupButton variant="ghost" size="icon-sm">
|
||||
<IconPlaceholder
|
||||
lucide="PlusIcon"
|
||||
tabler="IconPlus"
|
||||
hugeicons="PlusSignIcon"
|
||||
phosphor="PlusIcon"
|
||||
remixicon="RiAddLine"
|
||||
/>
|
||||
<span className="sr-only">Add</span>
|
||||
</InputGroupButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-fit">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem>
|
||||
<IconPlaceholder
|
||||
lucide="PaperclipIcon"
|
||||
tabler="IconPaperclip"
|
||||
hugeicons="AttachmentIcon"
|
||||
phosphor="PaperclipIcon"
|
||||
remixicon="RiAttachmentLine"
|
||||
/>
|
||||
Add Photos and Files
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<IconPlaceholder
|
||||
lucide="MonitorIcon"
|
||||
tabler="IconDeviceDesktop"
|
||||
hugeicons="ComputerIcon"
|
||||
phosphor="MonitorIcon"
|
||||
remixicon="RiComputerLine"
|
||||
/>
|
||||
Take a Screenshot
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem>
|
||||
<IconPlaceholder
|
||||
lucide="ImageIcon"
|
||||
tabler="IconPhoto"
|
||||
hugeicons="ImageIcon"
|
||||
phosphor="ImageIcon"
|
||||
remixicon="RiImageLine"
|
||||
/>
|
||||
Create Image
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<IconPlaceholder
|
||||
lucide="BrainIcon"
|
||||
tabler="IconBrain"
|
||||
hugeicons="AiBrainIcon"
|
||||
phosphor="BrainIcon"
|
||||
remixicon="RiBrainLine"
|
||||
/>
|
||||
Deep Research
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<IconPlaceholder
|
||||
lucide="GlobeIcon"
|
||||
tabler="IconGlobe"
|
||||
hugeicons="Globe02Icon"
|
||||
phosphor="GlobeIcon"
|
||||
remixicon="RiGlobeLine"
|
||||
/>
|
||||
Web Search
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<IconPlaceholder
|
||||
lucide="MoreHorizontalIcon"
|
||||
tabler="IconDots"
|
||||
hugeicons="MoreHorizontalCircle01Icon"
|
||||
phosphor="DotsThreeOutlineIcon"
|
||||
remixicon="RiMoreLine"
|
||||
/>
|
||||
More
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem>
|
||||
<IconPlaceholder
|
||||
lucide="BotIcon"
|
||||
tabler="IconRobot"
|
||||
hugeicons="RoboticIcon"
|
||||
phosphor="RobotIcon"
|
||||
remixicon="RiRobotLine"
|
||||
/>
|
||||
Agent Mode
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<IconPlaceholder
|
||||
lucide="BookOpenIcon"
|
||||
tabler="IconBook"
|
||||
hugeicons="BookOpen02Icon"
|
||||
phosphor="BookOpenIcon"
|
||||
remixicon="RiBookOpenLine"
|
||||
/>
|
||||
Add Sources
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<IconPlaceholder
|
||||
lucide="CheckIcon"
|
||||
tabler="IconCheck"
|
||||
hugeicons="Tick02Icon"
|
||||
phosphor="CheckIcon"
|
||||
remixicon="RiCheckLine"
|
||||
/>
|
||||
Create Tasks
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<InputGroupButton
|
||||
variant="default"
|
||||
size="icon-sm"
|
||||
type="submit"
|
||||
disabled={isBusy || !value?.trim()}
|
||||
className="ml-auto data-[visible=false]:hidden"
|
||||
data-visible={showSendButton}
|
||||
>
|
||||
<IconPlaceholder
|
||||
lucide="ArrowUpIcon"
|
||||
tabler="IconArrowUp"
|
||||
hugeicons="ArrowUp02Icon"
|
||||
phosphor="ArrowUpIcon"
|
||||
remixicon="RiArrowUpLine"
|
||||
/>
|
||||
<span className="sr-only">Send</span>
|
||||
</InputGroupButton>
|
||||
<InputGroupButton
|
||||
size="icon-sm"
|
||||
variant="default"
|
||||
type="button"
|
||||
data-visible={showStopButton}
|
||||
className="ml-auto data-[visible=false]:hidden"
|
||||
>
|
||||
<IconPlaceholder
|
||||
lucide="SquareIcon"
|
||||
tabler="IconPlayerStop"
|
||||
hugeicons="StopCircleIcon"
|
||||
phosphor="StopCircleIcon"
|
||||
remixicon="RiStopCircleLine"
|
||||
className="fill-foreground"
|
||||
/>
|
||||
<span className="sr-only">Stop</span>
|
||||
</InputGroupButton>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
34
apps/v4/registry/bases/radix/blocks/preview-03/index.tsx
Normal file
34
apps/v4/registry/bases/radix/blocks/preview-03/index.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { FilesChat } from "@/registry/bases/radix/blocks/preview-03/cards/files-chat"
|
||||
import { GroupChat } from "@/registry/bases/radix/blocks/preview-03/cards/group-chat"
|
||||
import { ReasoningChat } from "@/registry/bases/radix/blocks/preview-03/cards/reasoning-chat"
|
||||
import { SimpleChat } from "@/registry/bases/radix/blocks/preview-03/cards/simple-chat"
|
||||
import { Spinner } from "@/registry/bases/radix/ui/spinner"
|
||||
|
||||
export default function Preview03Example() {
|
||||
return (
|
||||
<div className="flex h-screen min-h-0 flex-col overflow-x-auto overflow-y-hidden bg-muted contain-[paint] [--gap:--spacing(4)] 3xl:[--gap:--spacing(12)] md:[--gap:--spacing(10)] dark:bg-background style-lyra:md:[--gap:--spacing(6)] style-mira:md:[--gap:--spacing(6)]">
|
||||
<div className="flex min-h-0 w-full min-w-max flex-1 justify-center">
|
||||
<div
|
||||
className="grid min-h-0 w-[2400px] grid-cols-7 items-start gap-(--gap) bg-muted p-(--gap) md:w-[3000px] dark:bg-background style-lyra:md:w-[2600px] style-mira:md:w-[2600px] *:[div]:gap-(--gap)"
|
||||
data-slot="capture-target"
|
||||
>
|
||||
<div className="flex h-full min-h-0 flex-1 flex-col p-1 [contain-intrinsic-size:380px_1200px] [content-visibility:auto]">
|
||||
<SimpleChat />
|
||||
</div>
|
||||
<div className="flex h-full min-h-0 flex-1 flex-col p-1 [contain-intrinsic-size:380px_1200px] [content-visibility:auto]">
|
||||
<GroupChat />
|
||||
</div>
|
||||
<div className="flex h-full min-h-0 flex-1 flex-col p-1 [contain-intrinsic-size:380px_1200px] [content-visibility:auto]">
|
||||
<ReasoningChat />
|
||||
</div>
|
||||
<div className="flex h-full min-h-0 flex-1 flex-col p-1 [contain-intrinsic-size:380px_1200px] [content-visibility:auto]">
|
||||
<FilesChat />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sr-only">
|
||||
<Spinner />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user