feat(web): integrate AI Elements chat primitives

This commit is contained in:
sai karthik
2026-07-23 17:41:16 +05:30
parent 3836d70704
commit 1d18ec1b32
19 changed files with 1987 additions and 175 deletions

View File

@@ -12,7 +12,7 @@
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"components": "@code/ui/components",
"utils": "@code/ui/lib/utils",
"ui": "@code/ui/components",
"lib": "@/lib",

View File

@@ -1,14 +1,5 @@
import { ConversationEmptyState } from "@code/ui/components/ai-elements/conversation";
import { Button } from "@code/ui/components/button";
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@code/ui/components/empty";
import { MessageGroup } from "@code/ui/components/message";
import { MessageScrollerItem } from "@code/ui/components/message-scroller";
import { MobileChatMark } from "@code/ui/components/mobile-chat";
import { SUGGESTIONS } from "@/lib/chat/constants";
@@ -25,19 +16,17 @@ export const ChatConversation = ({
}: ChatConversationProps) => {
if (historyReady && messages.length === 0) {
return (
<Empty className="min-h-full items-start justify-end px-0 pt-14 pb-5">
<EmptyMedia className="size-10 bg-transparent" variant="icon">
<MobileChatMark className="size-10 rounded-[12px] [&_svg]:size-5" />
</EmptyMedia>
<EmptyHeader className="items-start text-left">
<EmptyTitle className="text-[22px] font-semibold tracking-[-0.035em]">
<ConversationEmptyState className="min-h-full items-start justify-end px-0 pt-14 pb-5 text-left">
<MobileChatMark className="size-10 rounded-[12px] [&_svg]:size-5" />
<div className="space-y-1">
<h2 className="text-[22px] font-semibold tracking-[-0.035em]">
What can I help with?
</EmptyTitle>
<EmptyDescription className="max-w-[320px] text-[14px] leading-5">
</h2>
<p className="max-w-[320px] text-[14px] leading-5 text-muted-foreground">
Think, write, plan, or explore an idea with Zopu.
</EmptyDescription>
</EmptyHeader>
<EmptyContent className="mt-3 grid w-full gap-2">
</p>
</div>
<div className="mt-3 grid w-full gap-2">
{SUGGESTIONS.map(([title, prompt]) => (
<Button
key={title}
@@ -51,27 +40,17 @@ export const ChatConversation = ({
</span>
</Button>
))}
</EmptyContent>
</Empty>
</div>
</ConversationEmptyState>
);
}
return (
<MessageGroup className="gap-5 pb-5">
{messages.map((message, index) => (
<MessageScrollerItem
key={message.id}
messageId={message.id}
scrollAnchor={index === messages.length - 1}
>
<ChatMessage message={message} />
</MessageScrollerItem>
<div className="flex min-w-0 flex-col gap-5 pb-5">
{messages.map((message) => (
<ChatMessage key={message.id} message={message} />
))}
{status === "submitted" && (
<MessageScrollerItem scrollAnchor>
<ChatThinkingResponse />
</MessageScrollerItem>
)}
</MessageGroup>
{status === "submitted" && <ChatThinkingResponse />}
</div>
);
};

View File

@@ -1,9 +1,13 @@
import {
Message,
MessageContent,
MessageResponse,
} from "@code/ui/components/ai-elements/message";
import {
MobileChatBubble,
MobileChatMessage,
} from "@code/ui/components/mobile-chat";
import type { ReactNode } from "react";
import { Streamdown } from "streamdown";
import { getMessageText, isMessageStreaming } from "@/lib/chat/transforms";
import type { ChatMessageProps } from "@/lib/chat/types";
@@ -23,13 +27,13 @@ export const ChatMessage = ({ message }: ChatMessageProps) => {
let content: ReactNode = text;
if (!isUser) {
content = text ? (
<Streamdown
<MessageResponse
caret={isStreaming ? "block" : undefined}
className="chat-markdown min-w-0"
isAnimating={isStreaming}
>
{text}
</Streamdown>
</MessageResponse>
) : (
<div className="thinking-line" aria-label="Zopu is preparing a response">
<span />
@@ -42,23 +46,27 @@ export const ChatMessage = ({ message }: ChatMessageProps) => {
const sender = isUser ? "user" : "assistant";
return (
<MobileChatMessage className="chat-message" sender={sender}>
<div className={isUser ? "max-w-full" : "w-full min-w-0"}>
{!isUser && (
<AssistantIdentity state={isStreaming ? "writing" : undefined} />
)}
<MobileChatBubble
className={isUser ? "whitespace-pre-wrap" : undefined}
sender={sender}
>
{message.parts.map((part) =>
part.type === "dynamic-tool" ? (
<ChatToolCall key={part.toolCallId} part={part} />
) : null
)}
{content}
</MobileChatBubble>
</div>
</MobileChatMessage>
<Message className="max-w-full gap-0" from={message.role}>
<MessageContent className="w-full max-w-none gap-0 overflow-visible rounded-none bg-transparent p-0 group-[.is-user]:rounded-none group-[.is-user]:bg-transparent group-[.is-user]:p-0">
<MobileChatMessage className="chat-message" sender={sender}>
<div className={isUser ? "max-w-full" : "w-full min-w-0"}>
{!isUser && (
<AssistantIdentity state={isStreaming ? "writing" : undefined} />
)}
<MobileChatBubble
className={isUser ? "whitespace-pre-wrap" : undefined}
sender={sender}
>
{message.parts.map((part) =>
part.type === "dynamic-tool" ? (
<ChatToolCall key={part.toolCallId} part={part} />
) : null
)}
{content}
</MobileChatBubble>
</div>
</MobileChatMessage>
</MessageContent>
</Message>
);
};

View File

@@ -1,10 +1,8 @@
import {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerProvider,
MessageScrollerViewport,
} from "@code/ui/components/message-scroller";
Conversation,
ConversationContent,
ConversationScrollButton,
} from "@code/ui/components/ai-elements/conversation";
import { useChatAgent } from "@/hooks/chat/use-chat-agent";
@@ -21,32 +19,20 @@ export const ChatPage = () => {
<ChatHeader status={agent.status} />
<main aria-label="Conversation" className="min-h-0 flex-1">
<MessageScrollerProvider
autoScroll
defaultScrollPosition="end"
scrollEdgeThreshold={72}
scrollMargin={18}
>
<MessageScroller>
<MessageScrollerViewport aria-label="Chat messages">
<MessageScrollerContent className="w-full px-4 pt-4 pb-6">
<ChatConversation
historyReady={agent.historyReady}
messages={agent.messages}
onSuggestion={(suggestion) =>
void handleSendMessage(suggestion)
}
status={agent.status}
/>
</MessageScrollerContent>
</MessageScrollerViewport>
<MessageScrollerButton
aria-label="Scroll to latest message"
className="bottom-3 size-9 rounded-full border border-[#dededb] bg-[#fefefe]/95 shadow-sm backdrop-blur"
direction="end"
<Conversation className="ai-conversation-mobile h-full">
<ConversationContent className="min-h-full w-full gap-0 px-4 pt-4 pb-6">
<ChatConversation
historyReady={agent.historyReady}
messages={agent.messages}
onSuggestion={(suggestion) => void handleSendMessage(suggestion)}
status={agent.status}
/>
</MessageScroller>
</MessageScrollerProvider>
</ConversationContent>
<ConversationScrollButton
aria-label="Scroll to latest message"
className="bottom-3 size-9 rounded-full border border-[#dededb] bg-[#fefefe]/95 shadow-sm backdrop-blur"
/>
</Conversation>
</main>
<ChatComposer

View File

@@ -1,3 +1,7 @@
import {
Message,
MessageContent,
} from "@code/ui/components/ai-elements/message";
import {
MobileChatBubble,
MobileChatMessage,
@@ -6,16 +10,20 @@ import {
import { AssistantIdentity } from "./assistant-identity";
export const ChatThinkingResponse = () => (
<MobileChatMessage className="chat-message" sender="assistant">
<div className="w-full min-w-0">
<AssistantIdentity state="thinking" />
<MobileChatBubble sender="assistant">
<div className="thinking-line" aria-label="Zopu is thinking">
<span />
<span />
<span />
<Message className="max-w-full gap-0" from="assistant">
<MessageContent className="w-full max-w-none gap-0 overflow-visible p-0">
<MobileChatMessage className="chat-message" sender="assistant">
<div className="w-full min-w-0">
<AssistantIdentity state="thinking" />
<MobileChatBubble sender="assistant">
<div className="thinking-line" aria-label="Zopu is thinking">
<span />
<span />
<span />
</div>
</MobileChatBubble>
</div>
</MobileChatBubble>
</div>
</MobileChatMessage>
</MobileChatMessage>
</MessageContent>
</Message>
);

View File

@@ -1,3 +1,10 @@
import {
Tool,
ToolContent,
ToolHeader,
ToolInput,
ToolOutput,
} from "@code/ui/components/ai-elements/tool";
import { MobileChatToolCall } from "@code/ui/components/mobile-chat";
import { Search, SquareTerminal } from "lucide-react";
@@ -25,20 +32,36 @@ export const ChatToolCall = ({ part }: ChatToolCallProps) => {
}
const isSearch = part.toolName.toLowerCase().includes("search");
const output = part.state === "output-available" ? part.output : undefined;
const errorText = part.state === "output-error" ? part.errorText : undefined;
return (
<MobileChatToolCall
detail={detail}
icon={
isSearch ? (
<Search className="size-3" strokeWidth={2.2} />
) : (
<SquareTerminal className="size-3" strokeWidth={2.2} />
)
}
status={status}
tone={tone}
toolName={part.toolName}
/>
<Tool className="mb-0 w-full border-0" defaultOpen>
<ToolHeader
className="sr-only"
state={part.state}
toolName={part.toolName}
type="dynamic-tool"
/>
<ToolContent className="space-y-0 p-0">
<MobileChatToolCall
detail={detail}
icon={
isSearch ? (
<Search className="size-3" strokeWidth={2.2} />
) : (
<SquareTerminal className="size-3" strokeWidth={2.2} />
)
}
status={status}
tone={tone}
toolName={part.toolName}
/>
<div className="sr-only">
<ToolInput input={part.input} />
<ToolOutput errorText={errorText} output={output} />
</div>
</ToolContent>
</Tool>
);
};

View File

@@ -7,6 +7,10 @@ body {
overflow: hidden;
}
.ai-conversation-mobile > div {
scrollbar-gutter: auto !important;
}
.chat-message {
animation: chat-message-enter 220ms cubic-bezier(0.22, 1, 0.36, 1) both;
}

View File

@@ -1,3 +1,16 @@
import {
Conversation,
ConversationContent,
} from "@code/ui/components/ai-elements/conversation";
import {
Message,
MessageContent,
} from "@code/ui/components/ai-elements/message";
import {
Tool,
ToolContent,
ToolHeader,
} from "@code/ui/components/ai-elements/tool";
import {
MobileChatAssistantLabel,
MobileChatBubble,
@@ -21,39 +34,59 @@ export const meta = (_args: Route.MetaArgs) => [
const MobileChatShowcase = () => (
<section className="flex h-full min-h-0 flex-col bg-[#fefefe]">
<MobileChatHeader active statusLabel="Ready" />
<main className="min-h-0 flex-1 overflow-y-auto px-4 pt-4 pb-6">
<MobileChatMessage sender="user">
<MobileChatBubble sender="user">
Summarize Q3 and find recent AI news.
</MobileChatBubble>
</MobileChatMessage>
<Conversation className="ai-conversation-mobile min-h-0 flex-1">
<ConversationContent className="min-h-full w-full gap-0 px-4 pt-4 pb-6">
<Message className="max-w-full gap-0" from="user">
<MessageContent className="w-full max-w-none gap-0 overflow-visible rounded-none bg-transparent p-0 group-[.is-user]:rounded-none group-[.is-user]:bg-transparent group-[.is-user]:p-0">
<MobileChatMessage sender="user">
<MobileChatBubble sender="user">
Summarize Q3 and find recent AI news.
</MobileChatBubble>
</MobileChatMessage>
</MessageContent>
</Message>
<MobileChatMessage className="mt-5" sender="assistant">
<div className="w-full min-w-0">
<MobileChatAssistantLabel />
<MobileChatBubble sender="assistant">
<MobileChatToolCall
status="done"
tone="success"
toolName="Web Search"
>
<MobileChatToolResult
source="news.ycombinator.com"
title="AI funding hits record highs in 2026"
/>
<MobileChatToolResult
source="news.ycombinator.com"
title="Show HN: Series B raised for new AI lab"
/>
</MobileChatToolCall>
<p>
Revenue grew 18% QoQ to $4.2M. Top risks: customer concentration,
sales cycles, and infra costs.
</p>
</MobileChatBubble>
</div>
</MobileChatMessage>
</main>
<Message className="mt-5 max-w-full gap-0" from="assistant">
<MessageContent className="w-full max-w-none gap-0 overflow-visible p-0">
<MobileChatMessage sender="assistant">
<div className="w-full min-w-0">
<MobileChatAssistantLabel />
<MobileChatBubble sender="assistant">
<Tool className="mb-0 w-full border-0" defaultOpen>
<ToolHeader
className="sr-only"
state="output-available"
toolName="Web Search"
type="dynamic-tool"
/>
<ToolContent className="space-y-0 p-0">
<MobileChatToolCall
status="done"
tone="success"
toolName="Web Search"
>
<MobileChatToolResult
source="news.ycombinator.com"
title="AI funding hits record highs in 2026"
/>
<MobileChatToolResult
source="news.ycombinator.com"
title="Show HN: Series B raised for new AI lab"
/>
</MobileChatToolCall>
</ToolContent>
</Tool>
<p>
Revenue grew 18% QoQ to $4.2M. Top risks: customer
concentration, sales cycles, and infra costs.
</p>
</MobileChatBubble>
</div>
</MobileChatMessage>
</MessageContent>
</Message>
</ConversationContent>
</Conversation>
<MobileChatComposer
canSend