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
+3 -3
View File
@@ -8,7 +8,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { type LucideIcon, XIcon } from "lucide-react";
import { type RemixiconComponentType, RiCloseLine } from "@remixicon/react";
import type { ComponentProps, HTMLAttributes } from "react";
export type ArtifactProps = HTMLAttributes<HTMLDivElement>;
@@ -57,7 +57,7 @@ export const ArtifactClose = ({
variant={variant}
{...props}
>
{children ?? <XIcon className="size-4" />}
{children ?? <RiCloseLine className="size-4" />}
<span className="sr-only">Close</span>
</Button>
);
@@ -92,7 +92,7 @@ export const ArtifactActions = ({
export type ArtifactActionProps = ComponentProps<typeof Button> & {
tooltip?: string;
label?: string;
icon?: LucideIcon;
icon?: RemixiconComponentType;
};
export const ArtifactAction = ({
+9 -9
View File
@@ -9,11 +9,11 @@ import {
} from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
import {
BrainIcon,
ChevronDownIcon,
DotIcon,
type LucideIcon,
} from "lucide-react";
RiBrainLine,
RiArrowDownSLine,
RiCircleFill,
type RemixiconComponentType,
} from "@remixicon/react";
import type { ComponentProps, ReactNode } from "react";
import { createContext, memo, useContext, useMemo } from "react";
@@ -92,11 +92,11 @@ export const ChainOfThoughtHeader = memo(
)}
{...props}
>
<BrainIcon className="size-4" />
<RiBrainLine className="size-4" />
<span className="flex-1 text-left">
{children ?? "Chain of Thought"}
</span>
<ChevronDownIcon
<RiArrowDownSLine
className={cn(
"size-4 transition-transform",
isOpen ? "rotate-180" : "rotate-0"
@@ -109,7 +109,7 @@ export const ChainOfThoughtHeader = memo(
);
export type ChainOfThoughtStepProps = ComponentProps<"div"> & {
icon?: LucideIcon;
icon?: RemixiconComponentType;
label: ReactNode;
description?: ReactNode;
status?: "complete" | "active" | "pending";
@@ -118,7 +118,7 @@ export type ChainOfThoughtStepProps = ComponentProps<"div"> & {
export const ChainOfThoughtStep = memo(
({
className,
icon: Icon = DotIcon,
icon: Icon = RiCircleFill,
label,
description,
status = "complete",
+6 -4
View File
@@ -8,8 +8,8 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { BookmarkIcon, type LucideProps } from "lucide-react";
import type { ComponentProps, HTMLAttributes } from "react";
import { RiBookmarkLine } from "@remixicon/react";
import type { ComponentProps, HTMLAttributes, ReactNode } from "react";
export type CheckpointProps = HTMLAttributes<HTMLDivElement>;
@@ -27,7 +27,9 @@ export const Checkpoint = ({
</div>
);
export type CheckpointIconProps = LucideProps;
export type CheckpointIconProps = Omit<ComponentProps<typeof RiBookmarkLine>, "children"> & {
children?: ReactNode;
};
export const CheckpointIcon = ({
className,
@@ -35,7 +37,7 @@ export const CheckpointIcon = ({
...props
}: CheckpointIconProps) =>
children ?? (
<BookmarkIcon className={cn("size-4 shrink-0", className)} {...props} />
<RiBookmarkLine className={cn("size-4 shrink-0", className)} {...props} />
);
export type CheckpointTriggerProps = ComponentProps<typeof Button> & {
+2 -2
View File
@@ -2,7 +2,7 @@
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { CheckIcon, CopyIcon } from "lucide-react";
import { RiCheckLine, RiFileCopyLine } from "@remixicon/react";
import {
type ComponentProps,
createContext,
@@ -162,7 +162,7 @@ export const CodeBlockCopyButton = ({
}
};
const Icon = isCopied ? CheckIcon : CopyIcon;
const Icon = isCopied ? RiCheckLine : RiFileCopyLine;
return (
<Button
+2 -2
View File
@@ -2,7 +2,7 @@
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { ArrowDownIcon } from "lucide-react";
import { RiArrowDownLine } from "@remixicon/react";
import type { ComponentProps } from "react";
import { useCallback } from "react";
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
@@ -93,7 +93,7 @@ export const ConversationScrollButton = ({
variant="outline"
{...props}
>
<ArrowDownIcon className="size-4" />
<RiArrowDownLine className="size-4" />
</Button>
)
);
+3 -3
View File
@@ -13,7 +13,7 @@ import {
HoverCardTrigger,
} from "@/components/ui/hover-card";
import { cn } from "@/lib/utils";
import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react";
import { RiArrowLeftLine, RiArrowRightLine } from "@remixicon/react";
import {
type ComponentProps,
createContext,
@@ -206,7 +206,7 @@ export const InlineCitationCarouselPrev = ({
type="button"
{...props}
>
<ArrowLeftIcon className="size-4 text-muted-foreground" />
<RiArrowLeftLine className="size-4 text-muted-foreground" />
</button>
);
};
@@ -233,7 +233,7 @@ export const InlineCitationCarouselNext = ({
type="button"
{...props}
>
<ArrowRightIcon className="size-4 text-muted-foreground" />
<RiArrowRightLine className="size-4 text-muted-foreground" />
</button>
);
};
+11 -12
View File
@@ -14,11 +14,11 @@ import {
import { cn } from "@/lib/utils";
import type { FileUIPart, UIMessage } from "ai";
import {
ChevronLeftIcon,
ChevronRightIcon,
PaperclipIcon,
XIcon,
} from "lucide-react";
RiArrowLeftSLine,
RiArrowRightSLine,
RiAttachment2,
RiCloseLine,
} from "@remixicon/react";
import type { ComponentProps, HTMLAttributes, ReactElement, ReactNode } from "react";
import {
Children,
@@ -267,7 +267,7 @@ export const MessageBranchPrevious = ({
variant="ghost"
{...props}
>
{children ?? <ChevronLeftIcon size={14} />}
{children ?? <RiArrowLeftSLine size={14} />}
</Button>
);
};
@@ -291,7 +291,7 @@ export const MessageBranchNext = ({
variant="ghost"
{...props}
>
{children ?? <ChevronRightIcon size={14} />}
{children ?? <RiArrowRightSLine size={14} />}
</Button>
);
};
@@ -338,8 +338,7 @@ export const MessageResponse = memo(
}}
{...props}
/>
),
(prevProps, nextProps) => prevProps.children === nextProps.children
)
);
MessageResponse.displayName = "MessageResponse";
@@ -390,7 +389,7 @@ export function MessageAttachment({
type="button"
variant="ghost"
>
<XIcon />
<RiCloseLine />
<span className="sr-only">Remove</span>
</Button>
)}
@@ -400,7 +399,7 @@ export function MessageAttachment({
<Tooltip>
<TooltipTrigger asChild>
<div className="flex size-full shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
<PaperclipIcon className="size-4" />
<RiAttachment2 className="size-4" />
</div>
</TooltipTrigger>
<TooltipContent>
@@ -418,7 +417,7 @@ export function MessageAttachment({
type="button"
variant="ghost"
>
<XIcon />
<RiCloseLine />
<span className="sr-only">Remove</span>
</Button>
)}
+12 -12
View File
@@ -11,10 +11,10 @@ import {
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
import {
ChevronDownIcon,
ExternalLinkIcon,
MessageCircleIcon,
} from "lucide-react";
RiArrowDownSLine,
RiExternalLinkLine,
RiChat3Line,
} from "@remixicon/react";
import { type ComponentProps, createContext, useContext } from "react";
const providers = {
@@ -140,7 +140,7 @@ const providers = {
`https://t3.chat/new?${new URLSearchParams({
q,
})}`,
icon: <MessageCircleIcon />,
icon: <RiChat3Line />,
},
v0: {
title: "Open in v0",
@@ -238,7 +238,7 @@ export const OpenInTrigger = ({ children, ...props }: OpenInTriggerProps) => (
{children ?? (
<Button type="button" variant="outline">
Open in chat
<ChevronDownIcon className="size-4" />
<RiArrowDownSLine className="size-4" />
</Button>
)}
</DropdownMenuTrigger>
@@ -258,7 +258,7 @@ export const OpenInChatGPT = (props: OpenInChatGPTProps) => {
>
<span className="shrink-0">{providers.chatgpt.icon}</span>
<span className="flex-1">{providers.chatgpt.title}</span>
<ExternalLinkIcon className="size-4 shrink-0" />
<RiExternalLinkLine className="size-4 shrink-0" />
</a>
</DropdownMenuItem>
);
@@ -278,7 +278,7 @@ export const OpenInClaude = (props: OpenInClaudeProps) => {
>
<span className="shrink-0">{providers.claude.icon}</span>
<span className="flex-1">{providers.claude.title}</span>
<ExternalLinkIcon className="size-4 shrink-0" />
<RiExternalLinkLine className="size-4 shrink-0" />
</a>
</DropdownMenuItem>
);
@@ -298,7 +298,7 @@ export const OpenInT3 = (props: OpenInT3Props) => {
>
<span className="shrink-0">{providers.t3.icon}</span>
<span className="flex-1">{providers.t3.title}</span>
<ExternalLinkIcon className="size-4 shrink-0" />
<RiExternalLinkLine className="size-4 shrink-0" />
</a>
</DropdownMenuItem>
);
@@ -318,7 +318,7 @@ export const OpenInScira = (props: OpenInSciraProps) => {
>
<span className="shrink-0">{providers.scira.icon}</span>
<span className="flex-1">{providers.scira.title}</span>
<ExternalLinkIcon className="size-4 shrink-0" />
<RiExternalLinkLine className="size-4 shrink-0" />
</a>
</DropdownMenuItem>
);
@@ -338,7 +338,7 @@ export const OpenInv0 = (props: OpenInv0Props) => {
>
<span className="shrink-0">{providers.v0.icon}</span>
<span className="flex-1">{providers.v0.title}</span>
<ExternalLinkIcon className="size-4 shrink-0" />
<RiExternalLinkLine className="size-4 shrink-0" />
</a>
</DropdownMenuItem>
);
@@ -358,7 +358,7 @@ export const OpenInCursor = (props: OpenInCursorProps) => {
>
<span className="shrink-0">{providers.cursor.icon}</span>
<span className="flex-1">{providers.cursor.title}</span>
<ExternalLinkIcon className="size-4 shrink-0" />
<RiExternalLinkLine className="size-4 shrink-0" />
</a>
</DropdownMenuItem>
);
+2 -2
View File
@@ -16,7 +16,7 @@ import {
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
import { ChevronsUpDownIcon } from "lucide-react";
import { RiExpandUpDownLine } from "@remixicon/react";
import type { ComponentProps } from "react";
import { createContext, useContext } from "react";
import { Shimmer } from "./shimmer";
@@ -135,7 +135,7 @@ export const PlanTrigger = ({ className, ...props }: PlanTriggerProps) => (
variant="ghost"
{...props}
>
<ChevronsUpDownIcon className="size-4" />
<RiExpandUpDownLine className="size-4" />
<span className="sr-only">Toggle plan</span>
</Button>
</CollapsibleTrigger>
+16 -16
View File
@@ -37,14 +37,14 @@ import {
import { cn } from "@/lib/utils";
import type { ChatStatus, FileUIPart } from "ai";
import {
CornerDownLeftIcon,
ImageIcon,
MicIcon,
PaperclipIcon,
PlusIcon,
SquareIcon,
XIcon,
} from "lucide-react";
RiCornerDownLeftLine,
RiImageLine,
RiMicLine,
RiAttachment2,
RiAddLine,
RiStopFill,
RiCloseLine,
} from "@remixicon/react";
import { Loader } from "@/components/ai-elements/loader";
import { nanoid } from "nanoid";
import {
@@ -319,7 +319,7 @@ export function PromptInputAttachment({
/>
) : (
<div className="flex size-5 items-center justify-center text-muted-foreground">
<PaperclipIcon className="size-3" />
<RiAttachment2 className="size-3" />
</div>
)}
</div>
@@ -333,7 +333,7 @@ export function PromptInputAttachment({
type="button"
variant="ghost"
>
<XIcon />
<RiCloseLine />
<span className="sr-only">Remove</span>
</Button>
</div>
@@ -421,7 +421,7 @@ export const PromptInputActionAddAttachments = ({
attachments.openFileDialog();
}}
>
<ImageIcon className="mr-2 size-4" /> {label}
<RiImageLine className="mr-2 size-4" /> {label}
</DropdownMenuItem>
);
};
@@ -989,7 +989,7 @@ export const PromptInputActionMenuTrigger = ({
}: PromptInputActionMenuTriggerProps) => (
<DropdownMenuTrigger asChild>
<PromptInputButton className={className} {...props}>
{children ?? <PlusIcon className="size-4" />}
{children ?? <RiAddLine className="size-4" />}
</PromptInputButton>
</DropdownMenuTrigger>
);
@@ -1029,14 +1029,14 @@ export const PromptInputSubmit = ({
children,
...props
}: PromptInputSubmitProps) => {
let Icon = <CornerDownLeftIcon className="size-4" />;
let Icon = <RiCornerDownLeftLine className="size-4" />;
if (status === "submitted") {
Icon = <Loader size={16} />;
} else if (status === "streaming") {
Icon = <SquareIcon className="size-4" />;
Icon = <RiStopFill className="size-4" />;
} else if (status === "error") {
Icon = <XIcon className="size-4" />;
Icon = <RiCloseLine className="size-4" />;
}
return (
@@ -1208,7 +1208,7 @@ export const PromptInputSpeechButton = ({
onClick={toggleListening}
{...props}
>
<MicIcon className="size-4" />
<RiMicLine className="size-4" />
</PromptInputButton>
);
};
+3 -3
View File
@@ -8,7 +8,7 @@ import {
} from "@/components/ui/collapsible";
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
import { ChevronDownIcon, PaperclipIcon } from "lucide-react";
import { RiArrowDownSLine, RiAttachment2 } from "@remixicon/react";
import type { ComponentProps } from "react";
export type QueueMessagePart = {
@@ -174,7 +174,7 @@ export const QueueItemFile = ({
)}
{...props}
>
<PaperclipIcon size={12} />
<RiAttachment2 size={12} />
<span className="max-w-[100px] truncate">{children}</span>
</span>
);
@@ -241,7 +241,7 @@ export const QueueSectionLabel = ({
...props
}: QueueSectionLabelProps) => (
<span className={cn("flex items-center gap-2", className)} {...props}>
<ChevronDownIcon className="group-data-[state=closed]:-rotate-90 size-4 transition-transform" />
<RiArrowDownSLine className="group-data-[state=closed]:-rotate-90 size-4 transition-transform" />
{icon}
<span>
{count} {label}
+3 -3
View File
@@ -7,7 +7,7 @@ import {
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
import { BrainIcon, ChevronDownIcon } from "lucide-react";
import { RiBrainLine, RiArrowDownSLine } from "@remixicon/react";
import type { ComponentProps, ReactNode } from "react";
import { createContext, memo, useContext, useEffect, useState } from "react";
import { Streamdown } from "streamdown";
@@ -139,9 +139,9 @@ export const ReasoningTrigger = memo(
>
{children ?? (
<>
<BrainIcon className="size-4" />
<RiBrainLine className="size-4" />
{getThinkingMessage(isStreaming, duration)}
<ChevronDownIcon
<RiArrowDownSLine
className={cn(
"size-4 transition-transform",
isOpen ? "rotate-180" : "rotate-0"
+3 -3
View File
@@ -15,7 +15,7 @@ import {
TabsTrigger,
} from "@/components/ui/tabs";
import { cn } from "@/lib/utils";
import { ChevronDownIcon, Code } from "lucide-react";
import { RiArrowDownSLine, RiCodeLine } from "@remixicon/react";
import { getStatusBadge } from "./tool";
@@ -52,11 +52,11 @@ export const SandboxHeader = ({
{...props}
>
<div className="flex items-center gap-2">
<Code className="size-4 text-muted-foreground" />
<RiCodeLine className="size-4 text-muted-foreground" />
<span className="font-medium text-sm">{title}</span>
{getStatusBadge(state)}
</div>
<ChevronDownIcon className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
<RiArrowDownSLine className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
</CollapsibleTrigger>
);
+3 -3
View File
@@ -6,7 +6,7 @@ import {
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
import { BookIcon, ChevronDownIcon } from "lucide-react";
import { RiBookOpenLine, RiArrowDownSLine } from "@remixicon/react";
import type { ComponentProps } from "react";
export type SourcesProps = ComponentProps<"div">;
@@ -35,7 +35,7 @@ export const SourcesTrigger = ({
{children ?? (
<>
<p className="font-medium">Used {count} sources</p>
<ChevronDownIcon className="h-4 w-4" />
<RiArrowDownSLine className="h-4 w-4" />
</>
)}
</CollapsibleTrigger>
@@ -69,7 +69,7 @@ export const Source = ({ href, title, children, ...props }: SourceProps) => (
>
{children ?? (
<>
<BookIcon className="h-4 w-4" />
<RiBookOpenLine className="h-4 w-4" />
<span className="block font-medium">{title}</span>
</>
)}
+3 -3
View File
@@ -6,7 +6,7 @@ import {
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
import { ChevronDownIcon, SearchIcon } from "lucide-react";
import { RiArrowDownSLine, RiSearchLine } from "@remixicon/react";
import type { ComponentProps, ReactNode } from "react";
export type TaskItemFileProps = ComponentProps<"div">;
@@ -60,9 +60,9 @@ export const TaskTrigger = ({
<CollapsibleTrigger asChild className={cn("group", className)} {...props}>
{children ?? (
<div className="flex w-full cursor-pointer items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground">
{icon ?? <SearchIcon className="size-4" />}
{icon ?? <RiSearchLine className="size-4" />}
<p className="text-sm">{title}</p>
<ChevronDownIcon className="size-4 transition-transform group-data-[state=open]:rotate-180" />
<RiArrowDownSLine className="size-4 transition-transform group-data-[state=open]:rotate-180" />
</div>
)}
</CollapsibleTrigger>
+16 -16
View File
@@ -9,13 +9,13 @@ import {
import { cn } from "@/lib/utils";
import type { ToolUIPart } from "ai";
import {
CheckCircleIcon,
ChevronDownIcon,
CircleIcon,
ClockIcon,
WrenchIcon,
XCircleIcon,
} from "lucide-react";
RiCheckboxCircleLine,
RiArrowDownSLine,
RiCircleLine,
RiTimeLine,
RiToolsLine,
RiCloseCircleLine,
} from "@remixicon/react";
import type { ComponentProps, ReactNode } from "react";
import { isValidElement } from "react";
import { CodeBlock } from "./code-block";
@@ -48,13 +48,13 @@ export const getStatusBadge = (status: ToolUIPart["state"]) => {
};
const icons: Record<ToolUIPart["state"], ReactNode> = {
"input-streaming": <CircleIcon className="size-4" />,
"input-available": <ClockIcon className="size-4 animate-pulse" />,
"approval-requested": <ClockIcon className="size-4 text-yellow-600" />,
"approval-responded": <CheckCircleIcon className="size-4 text-blue-600" />,
"output-available": <CheckCircleIcon className="size-4 text-green-600" />,
"output-error": <XCircleIcon className="size-4 text-red-600" />,
"output-denied": <XCircleIcon className="size-4 text-orange-600" />,
"input-streaming": <RiCircleLine className="size-4" />,
"input-available": <RiTimeLine className="size-4 animate-pulse" />,
"approval-requested": <RiTimeLine className="size-4 text-yellow-600" />,
"approval-responded": <RiCheckboxCircleLine className="size-4 text-blue-600" />,
"output-available": <RiCheckboxCircleLine className="size-4 text-green-600" />,
"output-error": <RiCloseCircleLine className="size-4 text-red-600" />,
"output-denied": <RiCloseCircleLine className="size-4 text-orange-600" />,
};
return (
@@ -80,13 +80,13 @@ export const ToolHeader = ({
{...props}
>
<div className="flex items-center gap-2">
<WrenchIcon className="size-4 text-muted-foreground" />
<RiToolsLine className="size-4 text-muted-foreground" />
<span className="font-medium text-sm">
{title ?? type.split("-").slice(1).join("-")}
</span>
{getStatusBadge(state)}
</div>
<ChevronDownIcon className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
<RiArrowDownSLine className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
</CollapsibleTrigger>
);
+2 -2
View File
@@ -14,7 +14,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { ChevronDownIcon } from "lucide-react";
import { RiArrowDownSLine } from "@remixicon/react";
import type { ComponentProps, ReactNode } from "react";
import { createContext, useContext, useEffect, useState } from "react";
@@ -220,7 +220,7 @@ export const WebPreviewConsole = ({
variant="ghost"
>
Console
<ChevronDownIcon
<RiArrowDownSLine
className={cn(
"h-4 w-4 transition-transform duration-200",
consoleOpen && "rotate-180"
+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";
+12
View File
@@ -0,0 +1,12 @@
'use client';
import type { ComponentType } from 'react';
import { FirecrawlWebFetchTool } from './firecrawl-web-fetch-tool';
import { FirecrawlWebSearchTool } from './firecrawl-web-search-tool';
import type { ToolComponentProps } from './types';
// Add a renderer here for each tool that needs a custom presentation.
export const toolComponents: Record<string, ComponentType<ToolComponentProps> | undefined> = {
firecrawlWebSearch: FirecrawlWebSearchTool,
firecrawlWebFetch: FirecrawlWebFetchTool,
};
+45
View File
@@ -0,0 +1,45 @@
'use client';
import { getToolName } from 'ai';
import {
Tool,
ToolContent,
ToolHeader,
ToolInput,
ToolOutput,
} from '@/components/ai-elements/tool';
import { Loader } from '@/components/ai-elements/loader';
import { toolComponents } from './registry';
import type { ToolComponentProps } from './types';
export function ToolPartRenderer({ part }: Pick<ToolComponentProps, 'part'>) {
const toolName = getToolName(part);
const CustomRenderer = toolComponents[toolName];
if (CustomRenderer) {
return <CustomRenderer part={part} toolName={toolName} />;
}
if (part.state === 'input-streaming' && !part.input) {
return (
<div className="mb-4 flex items-center gap-2 text-muted-foreground text-sm">
<Loader size={14} />
<span>Running {toolName}...</span>
</div>
);
}
return (
<Tool>
<ToolHeader
title={toolName}
type={part.type === 'dynamic-tool' ? `tool-${toolName}` : part.type}
state={part.state}
/>
<ToolContent>
{part.input != null && <ToolInput input={part.input} />}
<ToolOutput output={part.output} errorText={part.errorText} />
</ToolContent>
</Tool>
);
}
+2 -4
View File
@@ -1,8 +1,6 @@
import type { UIMessage } from "ai";
type MessagePart = UIMessage["parts"][number];
import type { DynamicToolUIPart, ToolUIPart } from "ai";
export interface ToolComponentProps {
part: MessagePart;
part: ToolUIPart | DynamicToolUIPart;
toolName: string;
}
+2 -2
View File
@@ -2,7 +2,7 @@
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDownIcon } from "lucide-react"
import { RiArrowDownSLine } from "@remixicon/react"
import { cn } from "@/lib/utils"
@@ -41,7 +41,7 @@ function AccordionTrigger({
{...props}
>
{children}
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
<RiArrowDownSLine className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
+3 -3
View File
@@ -1,6 +1,6 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { ChevronRight, MoreHorizontal } from "lucide-react"
import { RiArrowRightSLine, RiMoreLine } from "@remixicon/react"
import { cn } from "@/lib/utils"
@@ -75,7 +75,7 @@ function BreadcrumbSeparator({
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
{children ?? <RiArrowRightSLine />}
</li>
)
}
@@ -92,7 +92,7 @@ function BreadcrumbEllipsis({
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="size-4" />
<RiMoreLine className="size-4" />
<span className="sr-only">More</span>
</span>
)
+7 -7
View File
@@ -2,10 +2,10 @@
import * as React from "react"
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react"
RiArrowDownSLine,
RiArrowLeftSLine,
RiArrowRightSLine,
} from "@remixicon/react"
import {
DayPicker,
getDefaultClassNames,
@@ -145,13 +145,13 @@ function Calendar({
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
<RiArrowLeftSLine className={cn("size-4", className)} {...props} />
)
}
if (orientation === "right") {
return (
<ChevronRightIcon
<RiArrowRightSLine
className={cn("size-4", className)}
{...props}
/>
@@ -159,7 +159,7 @@ function Calendar({
}
return (
<ChevronDownIcon className={cn("size-4", className)} {...props} />
<RiArrowDownSLine className={cn("size-4", className)} {...props} />
)
},
DayButton: CalendarDayButton,
+5 -5
View File
@@ -4,7 +4,7 @@ import * as React from "react"
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react"
import { ArrowLeft, ArrowRight } from "lucide-react"
import { RiArrowLeftLine, RiArrowRightLine } from "@remixicon/react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
@@ -77,10 +77,10 @@ function Carousel({
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
if (event.key === "RiArrowLeftLine") {
event.preventDefault()
scrollPrev()
} else if (event.key === "ArrowRight") {
} else if (event.key === "RiArrowRightLine") {
event.preventDefault()
scrollNext()
}
@@ -195,7 +195,7 @@ function CarouselPrevious({
onClick={scrollPrev}
{...props}
>
<ArrowLeft />
<RiArrowLeftLine />
<span className="sr-only">Previous slide</span>
</Button>
)
@@ -225,7 +225,7 @@ function CarouselNext({
onClick={scrollNext}
{...props}
>
<ArrowRight />
<RiArrowRightLine />
<span className="sr-only">Next slide</span>
</Button>
)
+2 -2
View File
@@ -2,7 +2,7 @@
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import { RiCheckLine } from "@remixicon/react"
import { cn } from "@/lib/utils"
@@ -23,7 +23,7 @@ function Checkbox({
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
<RiCheckLine className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
+5 -5
View File
@@ -2,7 +2,7 @@
import * as React from "react"
import { Combobox as ComboboxPrimitive } from "@base-ui/react"
import { CheckIcon, ChevronDownIcon, XIcon } from "lucide-react"
import { RiCheckLine, RiArrowDownSLine, RiCloseLine } from "@remixicon/react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
@@ -31,7 +31,7 @@ function ComboboxTrigger({
{...props}
>
{children}
<ChevronDownIcon
<RiArrowDownSLine
data-slot="combobox-trigger-icon"
className="text-muted-foreground pointer-events-none size-4"
/>
@@ -47,7 +47,7 @@ function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
className={cn(className)}
{...props}
>
<XIcon className="pointer-events-none" />
<RiCloseLine className="pointer-events-none" />
</ComboboxPrimitive.Clear>
)
}
@@ -160,7 +160,7 @@ function ComboboxItem({
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none size-4 pointer-coarse:size-5" />
<RiCheckLine className="pointer-events-none size-4 pointer-coarse:size-5" />
</ComboboxPrimitive.ItemIndicator>
</ComboboxPrimitive.Item>
)
@@ -265,7 +265,7 @@ function ComboboxChip({
className="-ml-1 opacity-50 hover:opacity-100"
data-slot="combobox-chip-remove"
>
<XIcon className="pointer-events-none" />
<RiCloseLine className="pointer-events-none" />
</ComboboxPrimitive.ChipRemove>
)}
</ComboboxPrimitive.Chip>
+2 -2
View File
@@ -2,7 +2,7 @@
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { SearchIcon } from "lucide-react"
import { RiSearchLine } from "@remixicon/react"
import { cn } from "@/lib/utils"
import {
@@ -69,7 +69,7 @@ function CommandInput({
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<RiSearchLine className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
+4 -4
View File
@@ -2,7 +2,7 @@
import * as React from "react"
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { RiCheckLine, RiArrowRightSLine, RiCircleLine } from "@remixicon/react"
import { cn } from "@/lib/utils"
@@ -72,7 +72,7 @@ function ContextMenuSubTrigger({
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
<RiArrowRightSLine className="ml-auto" />
</ContextMenuPrimitive.SubTrigger>
)
}
@@ -152,7 +152,7 @@ function ContextMenuCheckboxItem({
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
<RiCheckLine className="size-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
@@ -176,7 +176,7 @@ function ContextMenuRadioItem({
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
<RiCircleLine className="size-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
+1 -1
View File
@@ -45,7 +45,7 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
'relative z-50 grid w-full max-w-lg gap-4 border border-border bg-background p-6 shadow-lg duration-200 sm:rounded-lg',
'relative z-50 grid w-full max-w-lg gap-4 border border-border bg-background p-6 shadow-lg duration-200 rounded-lg',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
+4 -4
View File
@@ -2,7 +2,7 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { RiCheckLine, RiArrowRightSLine, RiCircleLine } from "@remixicon/react"
import { cn } from "@/lib/utils"
@@ -100,7 +100,7 @@ function DropdownMenuCheckboxItem({
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
<RiCheckLine className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
@@ -135,7 +135,7 @@ function DropdownMenuRadioItem({
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
<RiCircleLine className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
@@ -217,7 +217,7 @@ function DropdownMenuSubTrigger({
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
<RiArrowRightSLine className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
+2 -2
View File
@@ -2,7 +2,7 @@
import * as React from "react"
import { OTPInput, OTPInputContext } from "input-otp"
import { MinusIcon } from "lucide-react"
import { RiSubtractLine } from "@remixicon/react"
import { cn } from "@/lib/utils"
@@ -69,7 +69,7 @@ function InputOTPSlot({
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
return (
<div data-slot="input-otp-separator" role="separator" {...props}>
<MinusIcon />
<RiSubtractLine />
</div>
)
}
+4 -4
View File
@@ -2,7 +2,7 @@
import * as React from "react"
import * as MenubarPrimitive from "@radix-ui/react-menubar"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { RiCheckLine, RiArrowRightSLine, RiCircleLine } from "@remixicon/react"
import { cn } from "@/lib/utils"
@@ -129,7 +129,7 @@ function MenubarCheckboxItem({
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
<RiCheckLine className="size-4" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
@@ -153,7 +153,7 @@ function MenubarRadioItem({
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
<RiCircleLine className="size-2 fill-current" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
@@ -235,7 +235,7 @@ function MenubarSubTrigger({
{...props}
>
{children}
<ChevronRightIcon className="ml-auto h-4 w-4" />
<RiArrowRightSLine className="ml-auto h-4 w-4" />
</MenubarPrimitive.SubTrigger>
)
}
+2 -2
View File
@@ -1,5 +1,5 @@
import * as React from "react"
import { ChevronDownIcon } from "lucide-react"
import { RiArrowDownSLine } from "@remixicon/react"
import { cn } from "@/lib/utils"
@@ -24,7 +24,7 @@ function NativeSelect({
)}
{...props}
/>
<ChevronDownIcon
<RiArrowDownSLine
className="text-muted-foreground pointer-events-none absolute top-1/2 right-3.5 size-4 -translate-y-1/2 opacity-50 select-none"
aria-hidden="true"
data-slot="native-select-icon"
+2 -2
View File
@@ -1,7 +1,7 @@
import * as React from "react"
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
import { cva } from "class-variance-authority"
import { ChevronDownIcon } from "lucide-react"
import { RiArrowDownSLine } from "@remixicon/react"
import { cn } from "@/lib/utils"
@@ -74,7 +74,7 @@ function NavigationMenuTrigger({
{...props}
>
{children}{" "}
<ChevronDownIcon
<RiArrowDownSLine
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
+7 -7
View File
@@ -1,9 +1,9 @@
import * as React from "react"
import {
ChevronLeftIcon,
ChevronRightIcon,
MoreHorizontalIcon,
} from "lucide-react"
RiArrowLeftSLine,
RiArrowRightSLine,
RiMoreLine,
} from "@remixicon/react"
import { cn } from "@/lib/utils"
import { buttonVariants, type Button } from "@/components/ui/button"
@@ -76,7 +76,7 @@ function PaginationPrevious({
className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
{...props}
>
<ChevronLeftIcon />
<RiArrowLeftSLine />
<span className="hidden sm:block">Previous</span>
</PaginationLink>
)
@@ -94,7 +94,7 @@ function PaginationNext({
{...props}
>
<span className="hidden sm:block">Next</span>
<ChevronRightIcon />
<RiArrowRightSLine />
</PaginationLink>
)
}
@@ -110,7 +110,7 @@ function PaginationEllipsis({
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontalIcon className="size-4" />
<RiMoreLine className="size-4" />
<span className="sr-only">More pages</span>
</span>
)
+2 -2
View File
@@ -2,7 +2,7 @@
import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { CircleIcon } from "lucide-react"
import { RiCircleFill } from "@remixicon/react"
import { cn } from "@/lib/utils"
@@ -36,7 +36,7 @@ function RadioGroupItem({
data-slot="radio-group-indicator"
className="relative flex items-center justify-center"
>
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
<RiCircleFill className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
+2 -2
View File
@@ -1,7 +1,7 @@
"use client"
import * as React from "react"
import { GripVerticalIcon } from "lucide-react"
import { RiDraggable } from "@remixicon/react"
import * as ResizablePrimitive from "react-resizable-panels"
import { cn } from "@/lib/utils"
@@ -46,7 +46,7 @@ function ResizableHandle({
>
{withHandle && (
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
<GripVerticalIcon className="size-2.5" />
<RiDraggable className="size-2.5" />
</div>
)}
</ResizablePrimitive.Separator>
+5 -5
View File
@@ -2,7 +2,7 @@
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { RiCheckLine, RiArrowDownSLine, RiArrowUpSLine } from "@remixicon/react"
import { cn } from "@/lib/utils"
@@ -44,7 +44,7 @@ function SelectTrigger({
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
<RiArrowDownSLine className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
@@ -119,7 +119,7 @@ function SelectItem({
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
<RiCheckLine className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
@@ -153,7 +153,7 @@ function SelectScrollUpButton({
)}
{...props}
>
<ChevronUpIcon className="size-4" />
<RiArrowUpSLine className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
@@ -171,7 +171,7 @@ function SelectScrollDownButton({
)}
{...props}
>
<ChevronDownIcon className="size-4" />
<RiArrowDownSLine className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
+2 -2
View File
@@ -2,7 +2,7 @@
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { RiCloseLine } from "@remixicon/react"
import { cn } from "@/lib/utils"
@@ -76,7 +76,7 @@ function SheetContent({
{children}
{showCloseButton && (
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<RiCloseLine className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
+2 -2
View File
@@ -3,7 +3,7 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { PanelLeftIcon } from "lucide-react"
import { RiLayoutLeftLine } from "@remixicon/react"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
@@ -273,7 +273,7 @@ function SidebarTrigger({
}}
{...props}
>
<PanelLeftIcon />
<RiLayoutLeftLine className="size-5" aria-hidden="true" />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
+11 -11
View File
@@ -1,12 +1,12 @@
"use client"
import {
CircleCheckIcon,
InfoIcon,
Loader2Icon,
OctagonXIcon,
TriangleAlertIcon,
} from "lucide-react"
RiCheckboxCircleLine,
RiInformationLine,
RiLoader4Line,
RiCloseCircleLine,
RiErrorWarningLine,
} from "@remixicon/react"
import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner"
@@ -18,11 +18,11 @@ const Toaster = ({ ...props }: ToasterProps) => {
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
success: <RiCheckboxCircleLine className="size-4" />,
info: <RiInformationLine className="size-4" />,
warning: <RiErrorWarningLine className="size-4" />,
error: <RiCloseCircleLine className="size-4" />,
loading: <RiLoader4Line className="size-4 animate-spin" />,
}}
style={
{
+3 -3
View File
@@ -1,10 +1,10 @@
import { Loader2Icon } from "lucide-react"
import { RiLoader4Line } from "@remixicon/react"
import { cn } from "@/lib/utils"
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
function Spinner({ className, ...props }: React.ComponentProps<typeof RiLoader4Line>) {
return (
<Loader2Icon
<RiLoader4Line
role="status"
aria-label="Loading"
className={cn("size-4 animate-spin", className)}