Improve chat navigation, model selection, and streaming

This commit is contained in:
Rami Bitar
2026-09-16 13:14:55 -04:00
parent 8790cfb1e2
commit dd04493e25
73 changed files with 1272 additions and 1367 deletions
+37 -230
View File
@@ -1,20 +1,12 @@
'use client';
import { useState, useCallback, useRef, useEffect } from 'react';
import type { ChatStatus } from 'ai';
import { ChatInput, type PromptInputMessage } from './ChatInput';
import { ChatInput } from './ChatInput';
import { ChatMessages } from './ChatMessages';
import { ChatPrompt, PromptSuggestion } from './ChatPrompt';
import { ChatSidebar, type Chat } from './ChatSidebar';
import { useChat, UIMessage } from '@ai-sdk/react';
import { DefaultChatTransport } from 'ai';
import {
createChat,
deleteChat,
getMessages,
listChats,
saveMessages,
} from '@/services/indexed-db/chat-store';
import { ChatPrompt } from './ChatPrompt';
import { ChatSidebar } from './ChatSidebar';
import { useChatSession } from '@/hooks/use-chat-session';
import type { PromptSuggestion } from '@/config/suggestions';
import { DEFAULT_OPENROUTER_MODEL_ID, type OpenRouterModelId } from '@/lib/openrouter';
import {
SidebarProvider,
SidebarInset,
@@ -24,228 +16,32 @@ import {
interface ChatAgentProps {
suggestions?: PromptSuggestion[];
webSearchEnabled?: boolean;
}
async function convertToDataUrl(url: string): Promise<string> {
const response = await fetch(url);
if (!response.ok) throw new Error('Could not read the selected file');
const blob = await response.blob();
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result === 'string') {
resolve(reader.result);
} else {
reject(new Error('Could not encode the selected file'));
}
};
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(blob);
});
defaultModelId?: OpenRouterModelId;
}
export function ChatAgent({
suggestions,
webSearchEnabled = false,
defaultModelId = DEFAULT_OPENROUTER_MODEL_ID,
}: ChatAgentProps) {
const [inputValue, setInputValue] = useState('');
const [chatId, setChatId] = useState<string | null>(null);
const [chats, setChats] = useState<Chat[]>([]);
const chatIdRef = useRef(chatId);
const pendingChatIdRef = useRef<string | null>(null);
chatIdRef.current = chatId;
const fetchChats = useCallback(async () => {
try {
setChats(await listChats());
} catch (error) {
console.error('Failed to fetch chats:', error);
}
}, []);
const transportRef = useRef(
new DefaultChatTransport({
api: '/api/chat',
})
);
const {
inputValue,
setInputValue,
modelId,
setModelId,
chatId,
chats,
chatMessages,
status,
messages: chatMessages,
setMessages: setChatMessages,
sendMessage,
stop,
error: aiError,
addToolApprovalResponse,
} = useChat({
transport: transportRef.current,
onFinish: async ({ messages }) => {
const activeChatId = pendingChatIdRef.current ?? chatIdRef.current;
if (!activeChatId) return;
try {
await saveMessages(activeChatId, messages);
await fetchChats();
} catch (error) {
console.error('Failed to save messages:', error);
} finally {
if (pendingChatIdRef.current === activeChatId) {
pendingChatIdRef.current = null;
}
}
},
onError: (error) => {
console.error('Chat error:', error);
},
});
const [isPreparingFiles, setIsPreparingFiles] = useState(false);
const chatStatus: ChatStatus = isPreparingFiles
? 'submitted'
: status === 'submitted' || status === 'streaming'
? status
: 'ready';
useEffect(() => {
fetchChats();
}, [fetchChats]);
const handleSelectChat = useCallback(
async (selectedChatId: string) => {
try {
const messages = await getMessages(selectedChatId);
chatIdRef.current = selectedChatId;
setChatId(selectedChatId);
setChatMessages(messages);
} catch (error) {
console.error('Failed to load chat:', error);
}
},
[setChatMessages]
);
const handleNewChat = useCallback(() => {
chatIdRef.current = null;
setChatId(null);
setChatMessages([]);
setInputValue('');
}, [setChatMessages]);
const handleDeleteChat = useCallback(
async (deletedChatId: string) => {
try {
await deleteChat(deletedChatId);
setChats((prev) => prev.filter((c) => c.id !== deletedChatId));
if (chatId === deletedChatId) {
chatIdRef.current = null;
setChatId(null);
setChatMessages([]);
}
} catch (error) {
console.error('Failed to delete chat:', error);
}
},
[chatId, setChatMessages]
);
const handleSendMessage = useCallback(
async (message: PromptInputMessage) => {
const trimmedValue = message.text?.trim();
if (!trimmedValue && !message.files?.length) return;
if (
status === 'submitted' ||
status === 'streaming' ||
isPreparingFiles
)
return;
let activeChatId = chatIdRef.current;
if (!chatIdRef.current) {
const newChatId = crypto.randomUUID();
const title = trimmedValue ? trimmedValue.slice(0, 100) : 'New Chat';
activeChatId = newChatId;
chatIdRef.current = newChatId;
setChatId(newChatId);
try {
await createChat(newChatId, title);
await fetchChats();
} catch (error) {
console.error('Failed to create chat:', error);
}
}
const parts: Array<
| { type: 'text'; text: string }
| { type: 'file'; url: string; mediaType: string; filename: string }
> = [];
if (trimmedValue) {
parts.push({ type: 'text', text: trimmedValue });
}
// PromptInput supplies files as base64 data URLs. Keep them inline so the
// demo never needs a remote storage bucket.
if (message.files?.length) {
setIsPreparingFiles(true);
for (const file of message.files) {
try {
const dataUrl = file.url.startsWith('data:')
? file.url
: await convertToDataUrl(file.url);
parts.push({
type: 'file',
url: dataUrl,
mediaType: file.mediaType || 'application/octet-stream',
filename: file.filename || 'file',
});
} catch (err) {
console.error('File encoding error:', err);
}
}
setIsPreparingFiles(false);
}
if (parts.length === 0) return;
const userMessage: UIMessage = {
id: crypto.randomUUID(),
role: 'user',
parts,
};
setInputValue('');
if (activeChatId) {
try {
await saveMessages(activeChatId, [...chatMessages, userMessage]);
} catch (error) {
console.error('Failed to save user message:', error);
}
}
pendingChatIdRef.current = activeChatId;
await sendMessage(userMessage);
},
[
status,
sendMessage,
chatMessages,
isPreparingFiles,
fetchChats,
]
);
const handleStop = useCallback(() => {
stop();
}, [stop]);
chatStatus,
aiError,
isPreparingFiles,
handleSelectChat,
handleNewChat,
handleDeleteChat,
handleSendMessage,
handleStop,
} = useChatSession(defaultModelId);
const hasMessages = chatMessages.length > 0;
@@ -268,6 +64,9 @@ export function ChatAgent({
value={inputValue}
onChange={setInputValue}
onSubmit={handleSendMessage}
modelId={modelId}
onModelChange={setModelId}
onStop={isPreparingFiles ? undefined : handleStop}
status={chatStatus}
suggestions={suggestions}
webSearchEnabled={webSearchEnabled}
@@ -281,15 +80,23 @@ export function ChatAgent({
<div className="flex-1 overflow-hidden">
<ChatMessages
messages={chatMessages}
isLoading={status === 'submitted' || status === 'streaming'}
status={status}
/>
</div>
<div className="sticky bottom-0 bg-muted p-4">
<div className="mx-auto max-w-3xl">
{aiError && (
<p role="alert" className="mb-3 text-sm text-destructive">
The response could not be completed. Please try again.
</p>
)}
<ChatInput
value={inputValue}
onChange={setInputValue}
onSubmit={handleSendMessage}
modelId={modelId}
onModelChange={setModelId}
onStop={isPreparingFiles ? undefined : handleStop}
status={chatStatus}
/>
</div>
@@ -301,4 +108,4 @@ export function ChatAgent({
);
}
export type { UIMessage as Message };
export type { UIMessage as Message } from 'ai';
+18 -4
View File
@@ -1,7 +1,9 @@
'use client';
import type { ChatStatus } from 'ai';
import { PlusIcon } from 'lucide-react';
import { RiAddLine } from '@remixicon/react';
import { ChatModelSelector } from './ChatModelSelector';
import type { OpenRouterModelId } from '@/lib/openrouter';
import {
PromptInput,
PromptInputActionAddAttachments,
@@ -23,6 +25,9 @@ interface ChatInputProps {
value: string;
onChange: (value: string) => void;
onSubmit: (message: PromptInputMessage) => void;
modelId: OpenRouterModelId;
onModelChange: (modelId: OpenRouterModelId) => void;
onStop?: () => void;
status?: ChatStatus;
placeholder?: string;
}
@@ -31,9 +36,14 @@ export function ChatInput({
value,
onChange,
onSubmit,
modelId,
onModelChange,
onStop,
status = 'ready',
placeholder,
}: ChatInputProps) {
const isGenerating = status === 'submitted' || status === 'streaming';
return (
<PromptInput
className="[&_[data-slot=input-group]]:bg-background"
@@ -55,18 +65,22 @@ export function ChatInput({
/>
</PromptInputBody>
<PromptInputFooter className="pt-0 pb-2">
<PromptInputTools>
<PromptInputTools className="min-w-0">
<PromptInputActionMenu>
<PromptInputActionMenuTrigger>
<PlusIcon className="size-4" />
<RiAddLine className="size-4" />
</PromptInputActionMenuTrigger>
<PromptInputActionMenuContent>
<PromptInputActionAddAttachments />
</PromptInputActionMenuContent>
</PromptInputActionMenu>
<ChatModelSelector value={modelId} onChange={onModelChange} disabled={isGenerating} />
</PromptInputTools>
<PromptInputSubmit
disabled={status === 'streaming'}
aria-label={isGenerating ? 'Stop response' : 'Send message'}
type={isGenerating ? 'button' : 'submit'}
onClick={isGenerating ? onStop : undefined}
disabled={isGenerating && !onStop}
status={status}
/>
</PromptInputFooter>
-54
View File
@@ -1,54 +0,0 @@
"use client";
import { cn } from "@/lib/utils";
import { Tool, UIMessage, UIMessagePart } from "ai";
import { MessagePartRenderer } from "./ChatMessageParts";
interface ChatMessageProps {
message: UIMessage;
}
function getTextContent(parts: UIMessagePart<any, any>[]): string {
return parts
.filter((p) => p.type === "text")
.map((p) => p.text || "")
.join("");
}
export function ChatMessage({ message }: ChatMessageProps) {
const isUser = message.role === "user";
return (
<div
className={cn(
"flex w-full",
isUser ? "justify-end" : "justify-start"
)}
>
<div
className={cn(
"max-w-[80%] rounded-2xl px-4 py-3",
isUser
? "bg-primary text-primary-foreground"
: "bg-muted text-foreground"
)}
>
<div
className={cn(
"prose prose-sm max-w-none",
isUser
? "prose-invert prose-p:text-primary-foreground prose-headings:text-primary-foreground prose-strong:text-primary-foreground prose-code:text-primary-foreground"
: "prose-p:text-foreground prose-headings:text-foreground prose-strong:text-foreground"
)}
>
{message.parts.map((part, index) => (
<MessagePartRenderer key={index} part={part} />
))}
</div>
</div>
</div>
);
}
export { getTextContent };
export type { UIMessage as Message };
-96
View File
@@ -1,96 +0,0 @@
"use client";
import type { UIMessage } from "ai";
import {
Tool,
ToolContent,
ToolHeader,
ToolInput,
ToolOutput,
} from "@/components/ai-elements/tool";
import { Loader } from "@/components/ai-elements/loader";
import { toolComponents } from "@/chat.config";
type MessagePart = UIMessage["parts"][number];
type ReasoningPart = Extract<MessagePart, { type: "reasoning" }>;
// Helper functions
export function getTextContent(parts: MessagePart[]): string {
return parts
.filter((p) => p.type === "text")
.map((p) => p.text || "")
.join("");
}
export function getReasoningPart(parts: MessagePart[]): ReasoningPart | undefined {
return parts.find((p): p is ReasoningPart => p.type === "reasoning");
}
function extractToolName(part: MessagePart): string {
if (part.type === "dynamic-tool") {
return (part as any).toolName || "unknown";
}
if (part.type.startsWith("tool-")) {
return part.type.slice(5);
}
return "unknown";
}
function DefaultToolRenderer({ part, toolName }: { part: MessagePart; toolName: string }) {
const { input, output, state, errorText } = part as any;
return (
<Tool>
<ToolHeader
title={toolName}
type={part.type as any}
state={state || "input-streaming"}
/>
<ToolContent>
{input && <ToolInput input={input} />}
<ToolOutput output={output} errorText={errorText} />
</ToolContent>
</Tool>
);
}
function ToolLoadingIndicator({ toolName }: { toolName: string }) {
return (
<div className="mb-4 flex items-center gap-2 text-muted-foreground text-sm">
<Loader size={14} />
<span>Running {toolName}...</span>
</div>
);
}
function RenderTool({ part }: { part: MessagePart }) {
const toolName = extractToolName(part);
const state = (part as any).state;
const isComplete = state === "output-available";
const CustomRenderer = (
toolComponents as Record<
string,
React.ComponentType<{ part: MessagePart; toolName: string }>
>
)[toolName];
if (CustomRenderer) {
return <CustomRenderer part={part} toolName={toolName} />;
}
if (!isComplete && !(part as any).input) {
return <ToolLoadingIndicator toolName={toolName} />;
}
return <DefaultToolRenderer part={part} toolName={toolName} />;
}
export function ToolPartRenderer({ part }: { part: MessagePart }) {
return <RenderTool part={part} />;
}
export function MessagePartRenderer({ part }: { part: MessagePart }) {
if (part.type.startsWith("tool-") || part.type === "dynamic-tool") {
return <RenderTool part={part} />;
}
return null;
}
+28 -13
View File
@@ -1,6 +1,6 @@
"use client";
import type { UIMessage } from "ai";
import { isToolUIPart, type ChatStatus, type UIMessage } from "ai";
import {
Conversation,
ConversationContent,
@@ -19,14 +19,14 @@ import {
ReasoningTrigger,
} from "@/components/ai-elements/reasoning";
import { Loader } from "@/components/ai-elements/loader";
import { ToolPartRenderer, getTextContent, getReasoningPart } from "./ChatMessageParts";
import { ToolPartRenderer } from "@/components/tools/tool-part-renderer";
import { getTextContent, getReasoningPart } from "@/lib/chat/messages";
interface ChatMessagesProps {
messages: UIMessage[];
isLoading?: boolean;
status?: ChatStatus;
}
function LoadingIndicator() {
return (
<Message from="assistant">
@@ -37,8 +37,7 @@ function LoadingIndicator() {
);
}
export function ChatMessages({ messages, isLoading }: ChatMessagesProps) {
const isToolPart = (type: string) => type === "file" || type.startsWith("tool-");
export function ChatMessages({ messages, status = "ready" }: ChatMessagesProps) {
const hasReasoningPart = (parts: typeof messages[0]["parts"]) =>
parts.some((p) => p.type === "reasoning" && p.text);
@@ -46,26 +45,31 @@ export function ChatMessages({ messages, isLoading }: ChatMessagesProps) {
(message) =>
message.role === "user" ||
getTextContent(message.parts).trim() !== "" ||
message.parts.some((p) => isToolPart(p.type)) ||
message.parts.some((p) => p.type === "file" || isToolUIPart(p)) ||
hasReasoningPart(message.parts)
);
const lastMessage = messages[messages.length - 1];
const hasContent =
getTextContent(lastMessage?.parts || []).trim() !== "" ||
lastMessage?.parts.some((p) => isToolPart(p.type)) ||
lastMessage?.parts.some((p) => p.type === "file" || isToolUIPart(p)) ||
hasReasoningPart(lastMessage?.parts || []);
const isStreaming = lastMessage?.role === "assistant" && hasContent;
const showLoading = isLoading && !isStreaming;
const isLoading = status === "submitted" || status === "streaming";
const showLoading =
isLoading && !(lastMessage?.role === "assistant" && hasContent);
return (
<Conversation>
<ConversationContent className="mx-auto max-w-3xl px-4 pb-4">
{visibleMessages.map((message) => {
const isStreaming =
status === "streaming" &&
message.role === "assistant" &&
message.id === lastMessage?.id;
const textContent = getTextContent(message.parts);
const reasoningPart = getReasoningPart(message.parts);
const fileParts = message.parts.filter((p) => p.type === "file");
const toolParts = message.parts.filter((p) => p.type.startsWith("tool-"));
const toolParts = message.parts.filter((p) => isToolUIPart(p));
return (
<Message key={message.id} from={message.role}>
{fileParts.length > 0 && (
@@ -80,7 +84,9 @@ export function ChatMessages({ messages, isLoading }: ChatMessagesProps) {
)}
{message.role === "assistant" && reasoningPart && (
<Reasoning isStreaming={reasoningPart.state !== "done"}>
<Reasoning
isStreaming={isStreaming && reasoningPart.state === "streaming"}
>
<ReasoningTrigger />
<ReasoningContent>{reasoningPart.text || ""}</ReasoningContent>
</Reasoning>
@@ -92,7 +98,16 @@ export function ChatMessages({ messages, isLoading }: ChatMessagesProps) {
{textContent && (
<MessageContent from={message.role}>
<MessageResponse>{textContent}</MessageResponse>
<MessageResponse
mode={message.role === "assistant" ? "streaming" : "static"}
isAnimating={
isStreaming && message.parts.some(
(part) => part.type === "text" && part.state === "streaming"
)
}
>
{textContent}
</MessageResponse>
</MessageContent>
)}
</Message>
+93
View File
@@ -0,0 +1,93 @@
'use client';
import { useState } from 'react';
import { RiArrowDownSLine, RiCheckLine } from '@remixicon/react';
import {
ModelSelector,
ModelSelectorContent,
ModelSelectorEmpty,
ModelSelectorGroup,
ModelSelectorInput,
ModelSelectorItem,
ModelSelectorList,
ModelSelectorName,
ModelSelectorTrigger,
} from '@/components/ai-elements/model-selector';
import { Button } from '@/components/ui/button';
import { openRouterModels, type OpenRouterModelId } from '@/lib/openrouter';
const providers: Record<string, string> = {
anthropic: 'Anthropic',
openai: 'OpenAI',
google: 'Google',
'x-ai': 'SpaceXAI',
'z-ai': 'Z.ai',
moonshotai: 'MoonshotAI',
minimax: 'MiniMax',
deepseek: 'DeepSeek',
meta: 'Meta',
qwen: 'Qwen',
};
const modelGroups = Object.entries(providers).map(([id, name]) => ({
name,
models: openRouterModels.filter((model) => model.id.startsWith(`${id}/`)),
}));
interface ChatModelSelectorProps {
value: OpenRouterModelId;
onChange: (modelId: OpenRouterModelId) => void;
disabled?: boolean;
}
export function ChatModelSelector({ value, onChange, disabled }: ChatModelSelectorProps) {
const [open, setOpen] = useState(false);
const selectedModel = openRouterModels.find((model) => model.id === value);
return (
<ModelSelector open={open} onOpenChange={setOpen}>
<ModelSelectorTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="min-w-0 max-w-[min(16rem,calc(100vw-10rem))] text-muted-foreground"
aria-label={`Select model, current model: ${selectedModel?.name}`}
disabled={disabled}
>
<ModelSelectorName>{selectedModel?.name}</ModelSelectorName>
<RiArrowDownSLine className="size-4 shrink-0" aria-hidden="true" />
</Button>
</ModelSelectorTrigger>
<ModelSelectorContent title="Choose a model" className="max-w-[calc(100%-2rem)] sm:max-w-lg">
<ModelSelectorInput placeholder="Search models..." aria-label="Search models" className="pr-10" />
<ModelSelectorList className="max-h-[min(24rem,60dvh)]">
<ModelSelectorEmpty>No models found.</ModelSelectorEmpty>
{modelGroups.map((group) => (
<ModelSelectorGroup heading={group.name} key={group.name}>
{group.models.map((model) => (
<ModelSelectorItem
key={model.id}
value={model.id}
keywords={[model.name, group.name]}
onSelect={() => {
onChange(model.id);
setOpen(false);
}}
>
<ModelSelectorName>{model.name}</ModelSelectorName>
{model.id === value && (
<>
<RiCheckLine className="ml-auto size-4" aria-hidden="true" />
<span className="sr-only">Selected</span>
</>
)}
</ModelSelectorItem>
))}
</ModelSelectorGroup>
))}
</ModelSelectorList>
</ModelSelectorContent>
</ModelSelector>
);
}
+17 -46
View File
@@ -5,72 +5,40 @@ import { ChatInput, type PromptInputMessage } from './ChatInput';
import { Suggestion } from '@/components/ai-elements/suggestion';
import { RiOpenaiLine } from '@remixicon/react';
import { Shimmer } from '@/components/ai-elements/shimmer';
import type { OpenRouterModelId } from '@/lib/openrouter';
import {
offlineSuggestions,
webSearchSuggestions,
type PromptSuggestion,
} from '@/config/suggestions';
export interface PromptSuggestion {
key: string;
value: string;
}
export type { PromptSuggestion } from '@/config/suggestions';
interface ChatPromptProps {
value: string;
onChange: (value: string) => void;
onSubmit: (message: PromptInputMessage) => void;
modelId: OpenRouterModelId;
onModelChange: (modelId: OpenRouterModelId) => void;
onStop?: () => void;
status?: ChatStatus;
suggestions?: PromptSuggestion[];
webSearchEnabled?: boolean;
}
const defaultSuggestions: PromptSuggestion[] = [
{
key: 'Latest AI news',
value:
'What are the latest developments in AI in 2026? Search for recent news.',
},
{
key: 'Research a company',
value: 'Research OpenAI: their latest products, funding, and news.',
},
{
key: 'Fact check',
value:
'What is the current population of the world and how is it changing?',
},
{
key: 'Compare topics',
value:
'Compare the latest iPhone vs Samsung Galaxy flagship models with sources.',
},
];
const offlineSuggestions: PromptSuggestion[] = [
{
key: 'Explain a concept',
value: 'Explain retrieval-augmented generation in plain language.',
},
{
key: 'Compare approaches',
value: 'Compare REST and GraphQL, including when to choose each.',
},
{
key: 'Draft an outline',
value: 'Draft an outline for a practical guide to evaluating AI agents.',
},
{
key: 'Brainstorm ideas',
value: 'Brainstorm useful AI agent ideas for a small business.',
},
];
export function ChatPrompt({
value,
onChange,
onSubmit,
modelId,
onModelChange,
onStop,
status = 'ready',
suggestions,
webSearchEnabled = false,
}: ChatPromptProps) {
const displayedSuggestions =
suggestions ?? (webSearchEnabled ? defaultSuggestions : offlineSuggestions);
suggestions ?? (webSearchEnabled ? webSearchSuggestions : offlineSuggestions);
const handleSuggestionClick = (suggestion: string) => {
onChange(suggestion);
@@ -99,6 +67,9 @@ export function ChatPrompt({
value={value}
onChange={onChange}
onSubmit={onSubmit}
modelId={modelId}
onModelChange={onModelChange}
onStop={onStop}
status={status}
/>
</div>
+76
View File
@@ -0,0 +1,76 @@
'use client';
import { useState } from 'react';
import { RiCheckLine, RiSearchLine } from '@remixicon/react';
import { Button } from '@/components/ui/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import type { StoredChat } from '@/services/indexed-db/chat-store';
interface ChatSearchProps {
chats: StoredChat[];
activeChatId: string | null;
onSelectChat: (chatId: string) => void;
}
export function ChatSearch({ chats, activeChatId, onSelectChat }: ChatSearchProps) {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button type="button" variant="ghost" size="icon" className="size-7" aria-label="Search chats">
<RiSearchLine className="size-5" aria-hidden="true" />
</Button>
</DialogTrigger>
<DialogContent className="max-w-[calc(100%-2rem)] overflow-hidden p-0 sm:max-w-lg [&>button]:top-2.5">
<DialogTitle className="sr-only">Search chats</DialogTitle>
<DialogDescription className="sr-only">
Search your saved chats by title and select one to continue the conversation.
</DialogDescription>
<Command>
<CommandInput placeholder="Search chats..." aria-label="Search chats" className="pr-10" />
<CommandList className="max-h-[min(24rem,60dvh)]">
<CommandEmpty>
{chats.length === 0 ? 'No chats yet. Start a new chat to get started.' : 'No chats found.'}
</CommandEmpty>
<CommandGroup heading="Recent chats">
{chats.map((chat) => (
<CommandItem
key={chat.id}
value={chat.id}
keywords={[chat.title]}
onSelect={() => {
setOpen(false);
onSelectChat(chat.id);
}}
>
<span className="min-w-0 flex-1 truncate">{chat.title}</span>
{chat.id === activeChatId && (
<>
<RiCheckLine className="size-4" aria-hidden="true" />
<span className="sr-only">Current chat</span>
</>
)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</DialogContent>
</Dialog>
);
}
+26 -14
View File
@@ -1,6 +1,6 @@
'use client';
import { PlusIcon, MoreHorizontalIcon, TrashIcon } from 'lucide-react';
import { RiAddLine, RiMoreLine, RiDeleteBinLine } from '@remixicon/react';
import {
Sidebar,
SidebarContent,
@@ -12,6 +12,7 @@ import {
SidebarMenuItem,
SidebarMenuButton,
SidebarMenuAction,
useSidebar,
} from '@/components/ui/sidebar';
import {
DropdownMenu,
@@ -20,12 +21,10 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Button } from '@/components/ui/button';
import { ChatSearch } from './ChatSearch';
import type { StoredChat } from '@/services/indexed-db/chat-store';
export interface Chat {
id: string;
title: string;
created_at: string;
}
export type Chat = StoredChat;
interface ChatSidebarProps {
chats: Chat[];
@@ -42,15 +41,29 @@ export function ChatSidebar({
onNewChat,
onDeleteChat,
}: ChatSidebarProps) {
const { setOpenMobile } = useSidebar();
const selectChat = (chatId: string) => {
onSelectChat(chatId);
setOpenMobile(false);
};
return (
<Sidebar>
<SidebarHeader>
<div className="flex h-7 items-center justify-between gap-2 pl-2">
<span className="text-xl font-semibold tracking-tight">ChatGPT</span>
<ChatSearch chats={chats} activeChatId={activeChatId} onSelectChat={selectChat} />
</div>
<Button
onClick={onNewChat}
onClick={() => {
onNewChat();
setOpenMobile(false);
}}
variant="outline"
className="w-full justify-start gap-2"
className="mt-2 w-full justify-start gap-2"
>
<PlusIcon className="size-4" />
<RiAddLine className="size-4" />
New Chat
</Button>
</SidebarHeader>
@@ -63,23 +76,22 @@ export function ChatSidebar({
<SidebarMenuItem key={chat.id}>
<SidebarMenuButton
isActive={chat.id === activeChatId}
onClick={() => onSelectChat(chat.id)}
onClick={() => selectChat(chat.id)}
tooltip={chat.title}
>
<span>{chat.title}</span>
</SidebarMenuButton>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuAction showOnHover>
<MoreHorizontalIcon className="size-4" />
<SidebarMenuAction showOnHover aria-label={`Options for ${chat.title}`}>
<RiMoreLine className="size-4" />
</SidebarMenuAction>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" align="start">
<DropdownMenuItem
onClick={() => onDeleteChat(chat.id)}
className=""
>
<TrashIcon className="size-4" />
<RiDeleteBinLine className="size-4" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
+1 -2
View File
@@ -1,9 +1,8 @@
export { ChatAgent } from "./ChatAgent";
export { ChatInput } from "./ChatInput";
export { ChatMessage } from "./ChatMessage";
export { ChatMessages } from "./ChatMessages";
export { ChatPrompt } from "./ChatPrompt";
export { ChatSidebar } from "./ChatSidebar";
export type { Chat } from "./ChatSidebar";
export type { Message } from "./ChatMessage";
export type { UIMessage as Message } from "ai";
export type { PromptSuggestion } from "./ChatPrompt";